Browse Source

new modules: `autosign.signable`, `autosign.swap_mgr`

The MMGen Project 5 days ago
parent
commit
ce40c500e1

+ 19 - 431
mmgen/autosign/__init__.py

@@ -18,440 +18,14 @@ from pathlib import Path
 from subprocess import run, PIPE, DEVNULL
 from subprocess import run, PIPE, DEVNULL
 
 
 from ..cfg import Config
 from ..cfg import Config
-from ..util import msg, msg_r, ymsg, rmsg, gmsg, bmsg, die, suf, fmt, fmt_list, is_int, have_sudo, capfirst
-from ..color import yellow, red, orange, brown, blue, gray
+from ..util import msg, msg_r, ymsg, rmsg, gmsg, bmsg, die, suf, fmt, fmt_list, is_int, cached_property
+from ..color import yellow, brown, gray
 from ..wallet import Wallet, get_wallet_cls
 from ..wallet import Wallet, get_wallet_cls
 from ..addrlist import AddrIdxList
 from ..addrlist import AddrIdxList
 from ..filename import find_file_in_dir
 from ..filename import find_file_in_dir
 from ..fileutil import shred_file
 from ..fileutil import shred_file
 from ..ui import keypress_confirm
 from ..ui import keypress_confirm
 
 
-def SwapMgr(*args, **kwargs):
-	match sys.platform:
-		case 'linux':
-			return SwapMgrLinux(*args, **kwargs)
-		case 'darwin':
-			return SwapMgrMacOS(*args, **kwargs)
-
-class SwapMgrBase:
-
-	def __init__(self, cfg, *, ignore_zram=False):
-		self.cfg = cfg
-		self.ignore_zram = ignore_zram
-		self.desc = 'disk swap' if ignore_zram else 'swap'
-
-	def enable(self, *, quiet=False):
-		ret = self.do_enable()
-		if not quiet:
-			self.cfg._util.qmsg(
-				f'{capfirst(self.desc)} successfully enabled' if ret else
-				f'{capfirst(self.desc)} is already enabled' if ret is None else
-				f'Could not enable {self.desc}')
-		return ret
-
-	def disable(self, *, quiet=False):
-		self.cfg._util.qmsg_r(f'Attempting to disable {self.desc}...')
-		ret = self.do_disable()
-		self.cfg._util.qmsg('success')
-		if not quiet:
-			self.cfg._util.qmsg(
-				f'{capfirst(self.desc)} successfully disabled ({fmt_list(ret, fmt="no_quotes")})'
-					if ret and isinstance(ret, list) else
-				f'{capfirst(self.desc)} successfully disabled' if ret else
-				f'No active {self.desc}')
-		return ret
-
-	def process_cmds(self, op, cmds):
-		if not cmds:
-			return
-		if have_sudo(silent=True) and not self.cfg.test_suite:
-			for cmd in cmds:
-				run(cmd.split(), check=True)
-		else:
-			pre = 'failure\n' if op == 'disable' else ''
-			fs = blue('{a} {b} manually by executing the following command{c}:\n{d}')
-			post = orange('[To prevent this message in the future, enable sudo without a password]')
-			m = pre + fs.format(
-				a = 'Please disable' if op == 'disable' else 'Enable',
-				b = self.desc,
-				c = suf(cmds),
-				d = fmt_list(cmds, indent='  ', fmt='col')) + '\n' + post
-			msg(m)
-			if not self.cfg.test_suite:
-				sys.exit(1)
-
-class SwapMgrLinux(SwapMgrBase):
-
-	def get_active(self):
-		for cmd in ('/sbin/swapon', 'swapon'):
-			try:
-				cp = run([cmd, '--show=NAME', '--noheadings'], stdout=PIPE, text=True, check=True)
-				break
-			except Exception:
-				if cmd == 'swapon':
-					raise
-		res = cp.stdout.splitlines()
-		return [e for e in res if not e.startswith('/dev/zram')] if self.ignore_zram else res
-
-	def do_enable(self):
-		if ret := self.get_active():
-			ymsg(f'Warning: {self.desc} is already enabled: ({fmt_list(ret, fmt="no_quotes")})')
-		self.process_cmds('enable', ['sudo swapon --all'])
-		return True
-
-	def do_disable(self):
-		swapdevs = self.get_active()
-		if not swapdevs:
-			return None
-		self.process_cmds('disable', [f'sudo swapoff {swapdev}' for swapdev in swapdevs])
-		return swapdevs
-
-class SwapMgrMacOS(SwapMgrBase):
-
-	def get_active(self):
-		cmd = 'launchctl print system/com.apple.dynamic_pager'
-		return run(cmd.split(), stdout=DEVNULL, stderr=DEVNULL).returncode == 0
-
-	def _do_action(self, active, op, cmd):
-		if self.get_active() is active:
-			return None
-		else:
-			cmd = f'sudo launchctl {cmd} -w /System/Library/LaunchDaemons/com.apple.dynamic_pager.plist'
-			self.process_cmds(op, [cmd])
-			return True
-
-	def do_enable(self):
-		return self._do_action(active=True, op='enable', cmd='load')
-
-	def do_disable(self):
-		return self._do_action(active=False, op='disable', cmd='unload')
-
-class Signable:
-
-	non_xmr_signables = (
-		'transaction',
-		'automount_transaction',
-		'message')
-
-	xmr_signables = (              # order is important!
-		'xmr_wallet_outputs_file', # import XMR wallet outputs BEFORE signing transactions
-		'xmr_transaction')
-
-	class base:
-
-		clean_all = False
-		multiple_ok = True
-		action_desc = 'signed'
-		fail_msg = 'failed to sign'
-
-		def __init__(self, parent):
-			self.parent = parent
-			self.cfg = parent.cfg
-			self.dir = getattr(parent, self.dir_name)
-			self.name = type(self).__name__
-
-		@property
-		def unsigned(self):
-			return self._unprocessed('_unsigned', self.rawext, self.sigext)
-
-		def _unprocessed(self, attrname, rawext, sigext):
-			if not hasattr(self, attrname):
-				dirlist = sorted(self.dir.iterdir())
-				names = {f.name for f in dirlist}
-				setattr(
-					self,
-					attrname,
-					tuple(f for f in dirlist
-						if f.name.endswith('.' + rawext)
-							and f.name[:-len(rawext)] + sigext not in names))
-			return getattr(self, attrname)
-
-		def print_bad_list(self, bad_files):
-			msg('\n{a}\n{b}'.format(
-				a = red(f'Failed {self.desc}s:'),
-				b = '  {}\n'.format('\n  '.join(
-					self.gen_bad_list(sorted(bad_files, key=lambda f: f.name))))))
-
-		def gen_bad_list(self, bad_files):
-			for f in bad_files:
-				yield red(f.name)
-
-	class transaction(base):
-		desc = 'non-automount transaction'
-		dir_name = 'tx_dir'
-		rawext = 'rawtx'
-		sigext = 'sigtx'
-		automount = False
-
-		async def sign(self, f):
-			from ..tx import UnsignedTX
-			tx1 = UnsignedTX(
-				cfg       = self.cfg,
-				filename  = f,
-				automount = self.automount)
-			if tx1.proto.coin == 'XMR':
-				ctx = Signable.xmr_compat_transaction(self.parent)
-				for k in ('desc', 'print_summary', 'print_bad_list'):
-					setattr(self, k, getattr(ctx, k))
-				return await ctx.sign(f, compat_call=True)
-			if tx1.proto.sign_mode == 'daemon':
-				from ..rpc import rpc_init
-				tx1.rpc = await rpc_init(self.cfg, tx1.proto, ignore_wallet=True)
-			from ..tx.keys import TxKeys
-			tx2 = await tx1.sign(
-				TxKeys(
-					self.cfg,
-					tx1,
-					seedfiles = self.parent.wallet_files[:],
-					keylist = self.parent.keylist,
-					passwdfile = str(self.parent.keyfile),
-					autosign = True).keys)
-			if tx2:
-				tx2.file.write(ask_write=False, outdir=self.dir)
-				return tx2
-			else:
-				return False
-
-		def print_summary(self, signables):
-
-			if self.cfg.full_summary:
-				bmsg('\nAutosign summary:\n')
-				msg_r('\n'.join(tx.info.format(terse=True) for tx in signables))
-				return
-
-			def gen():
-				for tx in signables:
-					non_mmgen = [o for o in tx.outputs if not o.mmid]
-					if non_mmgen:
-						yield (tx, non_mmgen)
-
-			body = list(gen())
-
-			if body:
-				bmsg('\nAutosign summary:')
-				fs = '{}  {} {}'
-				t_wid, a_wid = 6, 44
-
-				def gen():
-					yield fs.format('TX ID ', 'Non-MMGen outputs'+' '*(a_wid-17), 'Amount')
-					yield fs.format('-'*t_wid, '-'*a_wid, '-'*7)
-					for tx, non_mmgen in body:
-						for nm in non_mmgen:
-							yield fs.format(
-								tx.txid.fmt(t_wid, color=True) if nm is non_mmgen[0] else ' '*t_wid,
-								nm.addr.fmt(nm.addr.view_pref, a_wid, color=True),
-								nm.amt.hl() + ' ' + yellow(tx.coin))
-
-				msg('\n' + '\n'.join(gen()))
-			else:
-				msg('\nNo non-MMGen outputs')
-
-	class automount_transaction(transaction):
-		desc = 'automount transaction'
-		dir_name = 'txauto_dir'
-		rawext = 'arawtx'
-		sigext = 'asigtx'
-		subext = 'asubtx'
-		multiple_ok = False
-		automount = True
-
-		@property
-		def unsubmitted(self):
-			return self._unprocessed('_unsubmitted', self.sigext, self.subext)
-
-		@property
-		def unsubmitted_raw(self):
-			return self._unprocessed('_unsubmitted_raw', self.rawext, self.subext)
-
-		unsent = unsubmitted
-		unsent_raw = unsubmitted_raw
-
-		@property
-		def submitted(self):
-			return self._processed('_submitted', self.subext)
-
-		def _processed(self, attrname, ext):
-			if not hasattr(self, attrname):
-				setattr(self, attrname, tuple(f for f in sorted(self.dir.iterdir())
-					if f.name.endswith('.' + ext)))
-			return getattr(self, attrname)
-
-		def die_wrong_num_txs(self, tx_type, *, msg=None, desc=None, show_dir=False):
-			match len(getattr(self, tx_type)): # num_txs
-				case 0: subj, suf, pred = ('No', 's', 'present')
-				case 1: subj, suf, pred = ('One', '', 'already present')
-				case _: subj, suf, pred = ('More than one', '', 'already present')
-			die('AutosignTXError', '{m}{a} {b} transaction{c} {d} {e}!'.format(
-				m = msg + '\n' if msg else '',
-				a = subj,
-				b = desc or tx_type,
-				c = suf,
-				d = pred,
-				e = f'in ‘{getattr(self.parent, self.dir_name)}’'
-					if show_dir else 'on removable device'))
-
-		def check_create_ok(self):
-			if len(self.unsigned):
-				self.die_wrong_num_txs('unsigned', msg='Cannot create transaction')
-			if len(self.unsent):
-				die('AutosignTXError', 'Cannot create transaction: you have an unsent transaction')
-
-		def get_unsubmitted(self, tx_type='unsubmitted'):
-			if len(self.unsubmitted) == 1:
-				return self.unsubmitted[0]
-			else:
-				self.die_wrong_num_txs(tx_type)
-
-		def get_unsent(self):
-			return self.get_unsubmitted('unsent')
-
-		def get_submitted(self):
-			if len(self.submitted) == 0:
-				self.die_wrong_num_txs('submitted')
-			else:
-				return self.submitted
-
-		def get_abortable(self):
-			if len(self.unsent_raw) != 1:
-				self.die_wrong_num_txs('unsent_raw', desc='unsent')
-			if len(self.unsent) > 1:
-				self.die_wrong_num_txs('unsent')
-			if self.unsent:
-				if self.unsent[0].stem != self.unsent_raw[0].stem:
-					die(1, f'{self.unsent[0]}, {self.unsent_raw[0]}: file mismatch')
-			return self.unsent_raw + self.unsent
-
-		def shred_abortable(self):
-			files = self.get_abortable() # raises AutosignTXError if no unsent TXs available
-			keypress_confirm(
-				self.cfg,
-				'The following file{} will be securely deleted:\n{}\nOK?'.format(
-					suf(files),
-					fmt_list(map(str, files), fmt='col', indent='  ')),
-					do_exit = True)
-			for fn in files:
-				msg(f'Shredding file ‘{fn}’')
-				shred_file(self.cfg, fn, iterations=15)
-			sys.exit(0)
-
-		async def get_last_sent(self, *, tx_range):
-			return await self.get_last_created(
-				# compat fallback - ‘sent_timestamp’ attr is missing in some old TX files:
-				sort_key = lambda x: x.sent_timestamp or x.timestamp,
-				tx_range = tx_range)
-
-		async def get_last_created(self, *, tx_range, sort_key=lambda x: x.timestamp):
-			from ..tx import CompletedTX
-			fns = [f for f in self.dir.iterdir() if f.name.endswith(self.subext)]
-			files = sorted(
-				[await CompletedTX(cfg=self.cfg, filename=str(txfile), quiet_open=True)
-					for txfile in fns],
-				key = sort_key)
-			if files:
-				return files[len(files) - 1 - tx_range.last:len(files) - tx_range.first]
-			else:
-				die(1, 'No sent automount transactions!')
-
-	class xmr_signable: # mixin class
-		automount = True
-		summary_footer = ''
-
-		def need_daemon_restart(self, m, new_idx):
-			old_idx = self.parent.xmr_cur_wallet_idx
-			self.parent.xmr_cur_wallet_idx = new_idx
-			return old_idx != new_idx or m.wd.state != 'ready'
-
-		def print_summary(self, signables):
-			bmsg('\nAutosign summary:')
-			msg('\n'.join(s.get_info(indent='  ') for s in signables) + self.summary_footer)
-
-	class xmr_transaction(xmr_signable, automount_transaction):
-		desc = 'Monero non-compat transaction'
-		dir_name = 'xmr_tx_dir'
-		rawext = 'rawtx'
-		sigext = 'sigtx'
-		subext = 'subtx'
-
-		async def sign(self, f, compat_call=False):
-			from .. import xmrwallet
-			from ..xmrwallet.file.tx import MoneroMMGenTX
-			tx1 = MoneroMMGenTX.Completed(self.parent.xmrwallet_cfg, f)
-			m = xmrwallet.op(
-				'sign',
-				self.parent.xmrwallet_cfg,
-				infile  = str(self.parent.wallet_files[0]), # MMGen wallet file
-				wallets = str(tx1.src_wallet_idx),
-				compat_call = compat_call)
-			tx2 = await m.main(f, restart_daemon=self.need_daemon_restart(m, tx1.src_wallet_idx))
-			tx2.write(ask_write=False)
-			return tx2
-
-	class xmr_compat_transaction(xmr_transaction):
-		desc = 'Monero compat transaction'
-		dir_name = 'txauto_dir'
-		rawext = 'arawtx'
-		sigext = 'asigtx'
-		subext = 'asubtx'
-
-	class xmr_wallet_outputs_file(xmr_signable, base):
-		desc = 'Monero wallet outputs file'
-		dir_name = 'xmr_outputs_dir'
-		rawext = 'raw'
-		sigext = 'sig'
-		clean_all = True
-		summary_footer = '\n'
-
-		@property
-		def unsigned(self):
-			import json
-			return tuple(
-				f for f in super().unsigned
-					if not json.loads(f.read_text())['MoneroMMGenWalletOutputsFile']['data']['imported'])
-
-		async def sign(self, f):
-			from .. import xmrwallet
-			wallet_idx = xmrwallet.op_cls('wallet').get_idx_from_fn(f)
-			m = xmrwallet.op(
-				'import_outputs',
-				self.parent.xmrwallet_cfg,
-				infile  = str(self.parent.wallet_files[0]), # MMGen wallet file
-				wallets = str(wallet_idx))
-			obj = await m.main(f, wallet_idx, restart_daemon=self.need_daemon_restart(m, wallet_idx))
-			obj.write(quiet=not obj.data.sign)
-			self.action_desc = 'imported and signed' if obj.data.sign else 'imported'
-			return obj
-
-	class message(base):
-		desc = 'message file'
-		dir_name = 'msg_dir'
-		rawext = 'rawmsg.json'
-		sigext = 'sigmsg.json'
-		fail_msg = 'failed to sign or signed incompletely'
-
-		async def sign(self, f):
-			from ..msg import UnsignedMsg, SignedMsg
-			m = UnsignedMsg(self.cfg, infile=f)
-			await m.sign(wallet_files=self.parent.wallet_files[:], passwd_file=str(self.parent.keyfile))
-			m = SignedMsg(self.cfg, data=m.__dict__)
-			m.write_to_file(
-				outdir = self.dir.resolve(),
-				ask_overwrite = False)
-			if m.data.get('failed_sids'):
-				die(
-					'MsgFileFailedSID',
-					f'Failed Seed IDs: {fmt_list(m.data["failed_sids"], fmt="bare")}')
-			return m
-
-		def print_summary(self, signables):
-			gmsg('\nSigned message files:')
-			for message in signables:
-				gmsg('  ' + message.signed_filename)
-
-		def gen_bad_list(self, bad_files):
-			for f in bad_files:
-				sigfile = f.parent / (f.name[:-len(self.rawext)] + self.sigext)
-				yield orange(sigfile.name) if sigfile.exists() else red(f.name)
-
 class Autosign:
 class Autosign:
 
 
 	dev_label = 'MMGEN_TX'
 	dev_label = 'MMGEN_TX'
@@ -490,6 +64,15 @@ class Autosign:
 		'xmr_tx_dir':      'xmr/tx',
 		'xmr_tx_dir':      'xmr/tx',
 		'xmr_outputs_dir': 'xmr/outputs'}
 		'xmr_outputs_dir': 'xmr/outputs'}
 
 
+	non_xmr_signables = (
+		'transaction',
+		'automount_transaction',
+		'message')
+
+	xmr_signables = (              # order is important!
+		'xmr_wallet_outputs_file', # import XMR wallet outputs BEFORE signing transactions
+		'xmr_transaction')
+
 	have_xmr = False
 	have_xmr = False
 	xmr_only = False
 	xmr_only = False
 
 
@@ -575,20 +158,23 @@ class Autosign:
 
 
 		if not self.xmr_only:
 		if not self.xmr_only:
 			self.dirs |= self.non_xmr_dirs
 			self.dirs |= self.non_xmr_dirs
-			self.signables += Signable.non_xmr_signables
+			self.signables += self.non_xmr_signables
 
 
 		if self.have_xmr:
 		if self.have_xmr:
 			self.dirs |= self.xmr_dirs | (
 			self.dirs |= self.xmr_dirs | (
 				{'txauto_dir': 'txauto'} if cfg.xmrwallet_compat and self.xmr_only else {})
 				{'txauto_dir': 'txauto'} if cfg.xmrwallet_compat and self.xmr_only else {})
 			self.signables = (
 			self.signables = (
-				Signable.xmr_signables # xmr_wallet_outputs_file must be signed before XMR TXs
+				self.xmr_signables # xmr_wallet_outputs_file must be signed before XMR TXs
 				+ (('automount_transaction',) if cfg.xmrwallet_compat and self.xmr_only else ())
 				+ (('automount_transaction',) if cfg.xmrwallet_compat and self.xmr_only else ())
 				+ self.signables)      # self.signables could contain compat XMR TXs
 				+ self.signables)      # self.signables could contain compat XMR TXs
 
 
 		for name, path in self.dirs.items():
 		for name, path in self.dirs.items():
 			setattr(self, name, self.mountpoint / path)
 			setattr(self, name, self.mountpoint / path)
 
 
-		self.swap = SwapMgr(self.cfg, ignore_zram=True)
+	@cached_property
+	def swap(self):
+		from .swap_mgr import SwapMgr
+		return SwapMgr(self.cfg, ignore_zram=True)
 
 
 	async def check_daemons_running(self):
 	async def check_daemons_running(self):
 		from ..protocol import init_proto
 		from ..protocol import init_proto
@@ -683,6 +269,7 @@ class Autosign:
 		return not fails
 		return not fails
 
 
 	async def sign_all(self, target_name):
 	async def sign_all(self, target_name):
+		from .signable import Signable
 		target = getattr(Signable, target_name)(self)
 		target = getattr(Signable, target_name)(self)
 		if target.unsigned:
 		if target.unsigned:
 			good = []
 			good = []
@@ -892,6 +479,7 @@ class Autosign:
 						if raw.is_file():
 						if raw.is_file():
 							do_shred(raw)
 							do_shred(raw)
 
 
+			from .signable import Signable
 			s = getattr(Signable, s_name)(self)
 			s = getattr(Signable, s_name)(self)
 
 
 			msg_r(f'Cleaning directory ‘{s.dir}’..')
 			msg_r(f'Cleaning directory ‘{s.dir}’..')

+ 337 - 0
mmgen/autosign/signable.py

@@ -0,0 +1,337 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2026 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+autosign.signable: Signable class for MMGen Wallet autosigning
+"""
+
+import sys
+
+from ..util import msg, msg_r, gmsg, bmsg, die, suf, fmt_list
+from ..color import yellow, red, orange
+from ..fileutil import shred_file
+from ..ui import keypress_confirm
+
+class Signable:
+
+	class base:
+
+		clean_all = False
+		multiple_ok = True
+		action_desc = 'signed'
+		fail_msg = 'failed to sign'
+
+		def __init__(self, parent):
+			self.parent = parent
+			self.cfg = parent.cfg
+			self.dir = getattr(parent, self.dir_name)
+			self.name = type(self).__name__
+
+		@property
+		def unsigned(self):
+			return self._unprocessed('_unsigned', self.rawext, self.sigext)
+
+		def _unprocessed(self, attrname, rawext, sigext):
+			if not hasattr(self, attrname):
+				dirlist = sorted(self.dir.iterdir())
+				names = {f.name for f in dirlist}
+				setattr(
+					self,
+					attrname,
+					tuple(f for f in dirlist
+						if f.name.endswith('.' + rawext)
+							and f.name[:-len(rawext)] + sigext not in names))
+			return getattr(self, attrname)
+
+		def print_bad_list(self, bad_files):
+			msg('\n{a}\n{b}'.format(
+				a = red(f'Failed {self.desc}s:'),
+				b = '  {}\n'.format('\n  '.join(
+					self.gen_bad_list(sorted(bad_files, key=lambda f: f.name))))))
+
+		def gen_bad_list(self, bad_files):
+			for f in bad_files:
+				yield red(f.name)
+
+	class transaction(base):
+		desc = 'non-automount transaction'
+		dir_name = 'tx_dir'
+		rawext = 'rawtx'
+		sigext = 'sigtx'
+		automount = False
+
+		async def sign(self, f):
+			from ..tx import UnsignedTX
+			tx1 = UnsignedTX(
+				cfg       = self.cfg,
+				filename  = f,
+				automount = self.automount)
+			if tx1.proto.coin == 'XMR':
+				ctx = Signable.xmr_compat_transaction(self.parent)
+				for k in ('desc', 'print_summary', 'print_bad_list'):
+					setattr(self, k, getattr(ctx, k))
+				return await ctx.sign(f, compat_call=True)
+			if tx1.proto.sign_mode == 'daemon':
+				from ..rpc import rpc_init
+				tx1.rpc = await rpc_init(self.cfg, tx1.proto, ignore_wallet=True)
+			from ..tx.keys import TxKeys
+			tx2 = await tx1.sign(
+				TxKeys(
+					self.cfg,
+					tx1,
+					seedfiles = self.parent.wallet_files[:],
+					keylist = self.parent.keylist,
+					passwdfile = str(self.parent.keyfile),
+					autosign = True).keys)
+			if tx2:
+				tx2.file.write(ask_write=False, outdir=self.dir)
+				return tx2
+			else:
+				return False
+
+		def print_summary(self, signables):
+
+			if self.cfg.full_summary:
+				bmsg('\nAutosign summary:\n')
+				msg_r('\n'.join(tx.info.format(terse=True) for tx in signables))
+				return
+
+			def gen():
+				for tx in signables:
+					non_mmgen = [o for o in tx.outputs if not o.mmid]
+					if non_mmgen:
+						yield (tx, non_mmgen)
+
+			body = list(gen())
+
+			if body:
+				bmsg('\nAutosign summary:')
+				fs = '{}  {} {}'
+				t_wid, a_wid = 6, 44
+
+				def gen():
+					yield fs.format('TX ID ', 'Non-MMGen outputs'+' '*(a_wid-17), 'Amount')
+					yield fs.format('-'*t_wid, '-'*a_wid, '-'*7)
+					for tx, non_mmgen in body:
+						for nm in non_mmgen:
+							yield fs.format(
+								tx.txid.fmt(t_wid, color=True) if nm is non_mmgen[0] else ' '*t_wid,
+								nm.addr.fmt(nm.addr.view_pref, a_wid, color=True),
+								nm.amt.hl() + ' ' + yellow(tx.coin))
+
+				msg('\n' + '\n'.join(gen()))
+			else:
+				msg('\nNo non-MMGen outputs')
+
+	class automount_transaction(transaction):
+		desc = 'automount transaction'
+		dir_name = 'txauto_dir'
+		rawext = 'arawtx'
+		sigext = 'asigtx'
+		subext = 'asubtx'
+		multiple_ok = False
+		automount = True
+
+		@property
+		def unsubmitted(self):
+			return self._unprocessed('_unsubmitted', self.sigext, self.subext)
+
+		@property
+		def unsubmitted_raw(self):
+			return self._unprocessed('_unsubmitted_raw', self.rawext, self.subext)
+
+		unsent = unsubmitted
+		unsent_raw = unsubmitted_raw
+
+		@property
+		def submitted(self):
+			return self._processed('_submitted', self.subext)
+
+		def _processed(self, attrname, ext):
+			if not hasattr(self, attrname):
+				setattr(self, attrname, tuple(f for f in sorted(self.dir.iterdir())
+					if f.name.endswith('.' + ext)))
+			return getattr(self, attrname)
+
+		def die_wrong_num_txs(self, tx_type, *, msg=None, desc=None, show_dir=False):
+			match len(getattr(self, tx_type)): # num_txs
+				case 0: subj, suf, pred = ('No', 's', 'present')
+				case 1: subj, suf, pred = ('One', '', 'already present')
+				case _: subj, suf, pred = ('More than one', '', 'already present')
+			die('AutosignTXError', '{m}{a} {b} transaction{c} {d} {e}!'.format(
+				m = msg + '\n' if msg else '',
+				a = subj,
+				b = desc or tx_type,
+				c = suf,
+				d = pred,
+				e = f'in ‘{getattr(self.parent, self.dir_name)}’'
+					if show_dir else 'on removable device'))
+
+		def check_create_ok(self):
+			if len(self.unsigned):
+				self.die_wrong_num_txs('unsigned', msg='Cannot create transaction')
+			if len(self.unsent):
+				die('AutosignTXError', 'Cannot create transaction: you have an unsent transaction')
+
+		def get_unsubmitted(self, tx_type='unsubmitted'):
+			if len(self.unsubmitted) == 1:
+				return self.unsubmitted[0]
+			else:
+				self.die_wrong_num_txs(tx_type)
+
+		def get_unsent(self):
+			return self.get_unsubmitted('unsent')
+
+		def get_submitted(self):
+			if len(self.submitted) == 0:
+				self.die_wrong_num_txs('submitted')
+			else:
+				return self.submitted
+
+		def get_abortable(self):
+			if len(self.unsent_raw) != 1:
+				self.die_wrong_num_txs('unsent_raw', desc='unsent')
+			if len(self.unsent) > 1:
+				self.die_wrong_num_txs('unsent')
+			if self.unsent:
+				if self.unsent[0].stem != self.unsent_raw[0].stem:
+					die(1, f'{self.unsent[0]}, {self.unsent_raw[0]}: file mismatch')
+			return self.unsent_raw + self.unsent
+
+		def shred_abortable(self):
+			files = self.get_abortable() # raises AutosignTXError if no unsent TXs available
+			keypress_confirm(
+				self.cfg,
+				'The following file{} will be securely deleted:\n{}\nOK?'.format(
+					suf(files),
+					fmt_list(map(str, files), fmt='col', indent='  ')),
+					do_exit = True)
+			for fn in files:
+				msg(f'Shredding file ‘{fn}’')
+				shred_file(self.cfg, fn, iterations=15)
+			sys.exit(0)
+
+		async def get_last_sent(self, *, tx_range):
+			return await self.get_last_created(
+				# compat fallback - ‘sent_timestamp’ attr is missing in some old TX files:
+				sort_key = lambda x: x.sent_timestamp or x.timestamp,
+				tx_range = tx_range)
+
+		async def get_last_created(self, *, tx_range, sort_key=lambda x: x.timestamp):
+			from ..tx import CompletedTX
+			fns = [f for f in self.dir.iterdir() if f.name.endswith(self.subext)]
+			files = sorted(
+				[await CompletedTX(cfg=self.cfg, filename=str(txfile), quiet_open=True)
+					for txfile in fns],
+				key = sort_key)
+			if files:
+				return files[len(files) - 1 - tx_range.last:len(files) - tx_range.first]
+			else:
+				die(1, 'No sent automount transactions!')
+
+	class xmr_signable: # mixin class
+		automount = True
+		summary_footer = ''
+
+		def need_daemon_restart(self, m, new_idx):
+			old_idx = self.parent.xmr_cur_wallet_idx
+			self.parent.xmr_cur_wallet_idx = new_idx
+			return old_idx != new_idx or m.wd.state != 'ready'
+
+		def print_summary(self, signables):
+			bmsg('\nAutosign summary:')
+			msg('\n'.join(s.get_info(indent='  ') for s in signables) + self.summary_footer)
+
+	class xmr_transaction(xmr_signable, automount_transaction):
+		desc = 'Monero non-compat transaction'
+		dir_name = 'xmr_tx_dir'
+		rawext = 'rawtx'
+		sigext = 'sigtx'
+		subext = 'subtx'
+
+		async def sign(self, f, compat_call=False):
+			from .. import xmrwallet
+			from ..xmrwallet.file.tx import MoneroMMGenTX
+			tx1 = MoneroMMGenTX.Completed(self.parent.xmrwallet_cfg, f)
+			m = xmrwallet.op(
+				'sign',
+				self.parent.xmrwallet_cfg,
+				infile  = str(self.parent.wallet_files[0]), # MMGen wallet file
+				wallets = str(tx1.src_wallet_idx),
+				compat_call = compat_call)
+			tx2 = await m.main(f, restart_daemon=self.need_daemon_restart(m, tx1.src_wallet_idx))
+			tx2.write(ask_write=False)
+			return tx2
+
+	class xmr_compat_transaction(xmr_transaction):
+		desc = 'Monero compat transaction'
+		dir_name = 'txauto_dir'
+		rawext = 'arawtx'
+		sigext = 'asigtx'
+		subext = 'asubtx'
+
+	class xmr_wallet_outputs_file(xmr_signable, base):
+		desc = 'Monero wallet outputs file'
+		dir_name = 'xmr_outputs_dir'
+		rawext = 'raw'
+		sigext = 'sig'
+		clean_all = True
+		summary_footer = '\n'
+
+		@property
+		def unsigned(self):
+			import json
+			return tuple(
+				f for f in super().unsigned
+					if not json.loads(f.read_text())['MoneroMMGenWalletOutputsFile']['data']['imported'])
+
+		async def sign(self, f):
+			from .. import xmrwallet
+			wallet_idx = xmrwallet.op_cls('wallet').get_idx_from_fn(f)
+			m = xmrwallet.op(
+				'import_outputs',
+				self.parent.xmrwallet_cfg,
+				infile  = str(self.parent.wallet_files[0]), # MMGen wallet file
+				wallets = str(wallet_idx))
+			obj = await m.main(f, wallet_idx, restart_daemon=self.need_daemon_restart(m, wallet_idx))
+			obj.write(quiet=not obj.data.sign)
+			self.action_desc = 'imported and signed' if obj.data.sign else 'imported'
+			return obj
+
+	class message(base):
+		desc = 'message file'
+		dir_name = 'msg_dir'
+		rawext = 'rawmsg.json'
+		sigext = 'sigmsg.json'
+		fail_msg = 'failed to sign or signed incompletely'
+
+		async def sign(self, f):
+			from ..msg import UnsignedMsg, SignedMsg
+			m = UnsignedMsg(self.cfg, infile=f)
+			await m.sign(wallet_files=self.parent.wallet_files[:], passwd_file=str(self.parent.keyfile))
+			m = SignedMsg(self.cfg, data=m.__dict__)
+			m.write_to_file(
+				outdir = self.dir.resolve(),
+				ask_overwrite = False)
+			if m.data.get('failed_sids'):
+				die(
+					'MsgFileFailedSID',
+					f'Failed Seed IDs: {fmt_list(m.data["failed_sids"], fmt="bare")}')
+			return m
+
+		def print_summary(self, signables):
+			gmsg('\nSigned message files:')
+			for message in signables:
+				gmsg('  ' + message.signed_filename)
+
+		def gen_bad_list(self, bad_files):
+			for f in bad_files:
+				sigfile = f.parent / (f.name[:-len(self.rawext)] + self.sigext)
+				yield orange(sigfile.name) if sigfile.exists() else red(f.name)

+ 119 - 0
mmgen/autosign/swap_mgr.py

@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2026 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+autosign.swap_mgr: swap management for MMGen Wallet autosigning
+"""
+
+import sys
+from subprocess import run, PIPE, DEVNULL
+
+from ..util import msg, ymsg, suf, fmt_list, have_sudo, capfirst
+from ..color import orange, blue
+
+def SwapMgr(*args, **kwargs):
+	match sys.platform:
+		case 'linux':
+			return SwapMgrLinux(*args, **kwargs)
+		case 'darwin':
+			return SwapMgrMacOS(*args, **kwargs)
+
+class SwapMgrBase:
+
+	def __init__(self, cfg, *, ignore_zram=False):
+		self.cfg = cfg
+		self.ignore_zram = ignore_zram
+		self.desc = 'disk swap' if ignore_zram else 'swap'
+
+	def enable(self, *, quiet=False):
+		ret = self.do_enable()
+		if not quiet:
+			self.cfg._util.qmsg(
+				f'{capfirst(self.desc)} successfully enabled' if ret else
+				f'{capfirst(self.desc)} is already enabled' if ret is None else
+				f'Could not enable {self.desc}')
+		return ret
+
+	def disable(self, *, quiet=False):
+		self.cfg._util.qmsg_r(f'Attempting to disable {self.desc}...')
+		ret = self.do_disable()
+		self.cfg._util.qmsg('success')
+		if not quiet:
+			self.cfg._util.qmsg(
+				f'{capfirst(self.desc)} successfully disabled ({fmt_list(ret, fmt="no_quotes")})'
+					if ret and isinstance(ret, list) else
+				f'{capfirst(self.desc)} successfully disabled' if ret else
+				f'No active {self.desc}')
+		return ret
+
+	def process_cmds(self, op, cmds):
+		if not cmds:
+			return
+		if have_sudo(silent=True) and not self.cfg.test_suite:
+			for cmd in cmds:
+				run(cmd.split(), check=True)
+		else:
+			pre = 'failure\n' if op == 'disable' else ''
+			fs = blue('{a} {b} manually by executing the following command{c}:\n{d}')
+			post = orange('[To prevent this message in the future, enable sudo without a password]')
+			m = pre + fs.format(
+				a = 'Please disable' if op == 'disable' else 'Enable',
+				b = self.desc,
+				c = suf(cmds),
+				d = fmt_list(cmds, indent='  ', fmt='col')) + '\n' + post
+			msg(m)
+			if not self.cfg.test_suite:
+				sys.exit(1)
+
+class SwapMgrLinux(SwapMgrBase):
+
+	def get_active(self):
+		for cmd in ('/sbin/swapon', 'swapon'):
+			try:
+				cp = run([cmd, '--show=NAME', '--noheadings'], stdout=PIPE, text=True, check=True)
+				break
+			except Exception:
+				if cmd == 'swapon':
+					raise
+		res = cp.stdout.splitlines()
+		return [e for e in res if not e.startswith('/dev/zram')] if self.ignore_zram else res
+
+	def do_enable(self):
+		if ret := self.get_active():
+			ymsg(f'Warning: {self.desc} is already enabled: ({fmt_list(ret, fmt="no_quotes")})')
+		self.process_cmds('enable', ['sudo swapon --all'])
+		return True
+
+	def do_disable(self):
+		swapdevs = self.get_active()
+		if not swapdevs:
+			return None
+		self.process_cmds('disable', [f'sudo swapoff {swapdev}' for swapdev in swapdevs])
+		return swapdevs
+
+class SwapMgrMacOS(SwapMgrBase):
+
+	def get_active(self):
+		cmd = 'launchctl print system/com.apple.dynamic_pager'
+		return run(cmd.split(), stdout=DEVNULL, stderr=DEVNULL).returncode == 0
+
+	def _do_action(self, active, op, cmd):
+		if self.get_active() is active:
+			return None
+		else:
+			cmd = f'sudo launchctl {cmd} -w /System/Library/LaunchDaemons/com.apple.dynamic_pager.plist'
+			self.process_cmds(op, [cmd])
+			return True
+
+	def do_enable(self):
+		return self._do_action(active=True, op='enable', cmd='load')
+
+	def do_disable(self):
+		return self._do_action(active=False, op='disable', cmd='unload')

+ 1 - 1
mmgen/main_txbump.py

@@ -168,7 +168,7 @@ async def main():
 
 
 	if cfg.autosign:
 	if cfg.autosign:
 		from .tx.util import mount_removable_device
 		from .tx.util import mount_removable_device
-		from .autosign import Signable
+		from .autosign.signable import Signable
 		asi = mount_removable_device(cfg)
 		asi = mount_removable_device(cfg)
 		si = Signable.automount_transaction(asi)
 		si = Signable.automount_transaction(asi)
 		if si.unsigned or si.unsent:
 		if si.unsigned or si.unsent:

+ 1 - 1
mmgen/main_txcreate.py

@@ -138,7 +138,7 @@ async def main():
 
 
 	if cfg.autosign:
 	if cfg.autosign:
 		from .tx.util import mount_removable_device
 		from .tx.util import mount_removable_device
-		from .autosign import Signable
+		from .autosign.signable import Signable
 		asi = mount_removable_device(cfg, add_cfg={'xmrwallet_compat': True})
 		asi = mount_removable_device(cfg, add_cfg={'xmrwallet_compat': True})
 		Signable.automount_transaction(asi).check_create_ok()
 		Signable.automount_transaction(asi).check_create_ok()
 
 

+ 1 - 1
mmgen/main_txsend.py

@@ -109,7 +109,7 @@ def init_autosign(arg):
 	global asi, si, infile, tx_range
 	global asi, si, infile, tx_range
 	from .tx.util import mount_removable_device
 	from .tx.util import mount_removable_device
 	from .tx.online import SentTXRange
 	from .tx.online import SentTXRange
-	from .autosign import Signable
+	from .autosign.signable import Signable
 	asi = mount_removable_device(cfg)
 	asi = mount_removable_device(cfg)
 	si = Signable.automount_transaction(asi)
 	si = Signable.automount_transaction(asi)
 	if cfg.abort:
 	if cfg.abort:

+ 3 - 3
mmgen/xmrwallet/ops/submit.py

@@ -42,7 +42,7 @@ class OpSubmit(OpWallet):
 		if self.uargs.infile:
 		if self.uargs.infile:
 			fn = Path(self.uargs.infile)
 			fn = Path(self.uargs.infile)
 		else:
 		else:
-			from ...autosign import Signable
+			from ...autosign.signable import Signable
 			fn = Signable.xmr_transaction(self.asi).get_unsubmitted()
 			fn = Signable.xmr_transaction(self.asi).get_unsubmitted()
 		return self.get_tx_cls('ColdSigned')(cfg=self.cfg, fn=fn)
 		return self.get_tx_cls('ColdSigned')(cfg=self.cfg, fn=fn)
 
 
@@ -115,7 +115,7 @@ class OpResubmit(OpSubmit):
 			die(1, '--autosign is required for this operation')
 			die(1, '--autosign is required for this operation')
 
 
 	def get_tx(self):
 	def get_tx(self):
-		from ...autosign import Signable
+		from ...autosign.signable import Signable
 		fns = Signable.xmr_transaction(self.asi).get_submitted()
 		fns = Signable.xmr_transaction(self.asi).get_submitted()
 		cls = self.get_tx_cls('Submitted')
 		cls = self.get_tx_cls('Submitted')
 		return sorted((cls(self.cfg, Path(fn)) for fn in fns),
 		return sorted((cls(self.cfg, Path(fn)) for fn in fns),
@@ -127,5 +127,5 @@ class OpAbort(OpBase):
 	def __init__(self, cfg, uarg_tuple):
 	def __init__(self, cfg, uarg_tuple):
 		super().__init__(cfg, uarg_tuple)
 		super().__init__(cfg, uarg_tuple)
 		self.mount_removable_device()
 		self.mount_removable_device()
-		from ...autosign import Signable
+		from ...autosign.signable import Signable
 		Signable.xmr_transaction(self.asi).shred_abortable() # prompts user, then raises exception or exits
 		Signable.xmr_transaction(self.asi).shred_abortable() # prompts user, then raises exception or exits

+ 4 - 3
test/cmdtest_d/autosign.py

@@ -28,7 +28,8 @@ from mmgen.cfg import Config
 from mmgen.color import red, blue, yellow, cyan, orange, purple, gray
 from mmgen.color import red, blue, yellow, cyan, orange, purple, gray
 from mmgen.util import msg, suf, die, indent, fmt
 from mmgen.util import msg, suf, die, indent, fmt
 from mmgen.led import LEDControl
 from mmgen.led import LEDControl
-from mmgen.autosign import Autosign, Signable
+from mmgen.autosign import Autosign
+from mmgen.autosign.signable import Signable
 
 
 from ..include.common import (
 from ..include.common import (
 	omsg,
 	omsg,
@@ -998,11 +999,11 @@ class CmdTestAutosign(CmdTestAutosignBase):
 		res = t.read()
 		res = t.read()
 		self.remove_device()
 		self.remove_device()
 		for signable_list in present:
 		for signable_list in present:
-			for signable_clsname in getattr(Signable, signable_list):
+			for signable_clsname in getattr(Autosign, signable_list):
 				desc = getattr(Signable, signable_clsname).desc
 				desc = getattr(Signable, signable_clsname).desc
 				assert f'No unsigned {desc}s' in res, f'‘No unsigned {desc}s’ missing in output'
 				assert f'No unsigned {desc}s' in res, f'‘No unsigned {desc}s’ missing in output'
 		for signable_list in absent:
 		for signable_list in absent:
-			for signable_clsname in getattr(Signable, signable_list):
+			for signable_clsname in getattr(Autosign, signable_list):
 				desc = getattr(Signable, signable_clsname).desc
 				desc = getattr(Signable, signable_clsname).desc
 				assert not f'No unsigned {desc}s' in res, f'‘No unsigned {desc}s’ should be absent in output'
 				assert not f'No unsigned {desc}s' in res, f'‘No unsigned {desc}s’ should be absent in output'
 		return t
 		return t