Browse Source

offline transaction signing with automount for BTC, BCH, LTC and ETH/ERC20

Previously supported only for XMR, offline transaction autosigning with no
filename arguments and automatic mounting/unmounting of the removable device
on the online machine is now available for all coins MMGen Wallet supports
transacting with.  To activate, invoke ‘mmgen-txcreate’ and ‘mmgen-txsend’
with the --autosign option.

Be aware that transactions must be created, signed and sent one at a time when
using this feature.  For bulk transaction signing, you must use the old manual
mounting method.

Example create-sign-send workflow for BTC:

    $ mmgen-txcreate --autosign bc1qxmymxf8p5ckvlxkmkwgw8ap5t2xuaffmrpexap,0.00123 B

    (remove device - insert offline - wait for signing - remove - insert online)

    $ mmgen-txsend --autosign

Unsigned or unsent transactions may be aborted as follows:

    $ mmgen-txsend --abort

And sent RBF transactions may be fee-bumped:

    $ mmgen-txbump --autosign

You can check the status of the current transaction, whether sent or unsent,
with the following command:

    $ mmgen-txsend --status

That’s all there is to it!

Testing (add the -e option to see script output):

    $ test/cmdtest.py autosign_automount
    $ test/cmdtest.py --coin=eth autosign_eth
The MMGen Project 1 year ago
parent
commit
1c5c3319

+ 47 - 2
mmgen/autosign.py

@@ -28,6 +28,7 @@ class Signable:
 
 	non_xmr_signables = (
 		'transaction',
+		'automount_transaction',
 		'message')
 
 	xmr_signables = (              # order is important!
@@ -62,6 +63,13 @@ class Signable:
 		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
+
 		def _unprocessed(self,attrname,rawext,sigext):
 			if not hasattr(self,attrname):
 				dirlist = sorted(self.dir.iterdir())
@@ -91,20 +99,45 @@ class Signable:
 				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')
+
 		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
+
+		async def get_last_created(self):
+			from .tx import CompletedTX
+			ext = '.' + Signable.automount_transaction.subext
+			files = [f for f in self.dir.iterdir() if f.name.endswith(ext)]
+			return sorted(
+				[await CompletedTX(cfg=self.cfg, filename=str(txfile), quiet_open=True) for txfile in files],
+				key = lambda x: x.timestamp)[-1]
+
 	class transaction(base):
-		desc = 'transaction'
+		desc = 'non-automount transaction'
 		rawext = 'rawtx'
 		sigext = 'sigtx'
 		dir_name = 'tx_dir'
@@ -112,7 +145,10 @@ class Signable:
 
 		async def sign(self,f):
 			from .tx import UnsignedTX
-			tx1 = UnsignedTX( cfg=self.cfg, filename=f )
+			tx1 = UnsignedTX(
+					cfg       = self.cfg,
+					filename  = f,
+					automount = self.name=='automount_transaction')
 			if tx1.proto.sign_mode == 'daemon':
 				from .rpc import rpc_init
 				tx1.rpc = await rpc_init( self.cfg, tx1.proto, ignore_wallet=True )
@@ -168,6 +204,14 @@ class Signable:
 			for f in bad_files:
 				yield red(f.name)
 
+	class automount_transaction(transaction):
+		desc   = 'automount transaction'
+		dir_name = 'txauto_dir'
+		rawext = 'arawtx'
+		sigext = 'asigtx'
+		subext = 'asubtx'
+		multiple_ok = False
+
 	class xmr_signable(transaction): # mixin class
 
 		def need_daemon_restart(self,m,new_idx):
@@ -278,6 +322,7 @@ class Autosign:
 
 	non_xmr_dirs = {
 		'tx_dir':     'tx',
+		'txauto_dir': 'txauto',
 		'msg_dir':    'msg',
 	}
 	xmr_dirs = {

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-14.1.dev14
+14.1.dev15

+ 1 - 3
mmgen/main_autosign.py

@@ -87,9 +87,7 @@ the status LED indicates whether the program is busy or in standby mode, i.e.
 ready for device insertion or removal.
 
 The removable device must have a partition labeled MMGEN_TX with a user-
-writable root directory and a directory named ‘/tx’, where unsigned MMGen
-transactions are placed.  Optionally, the directory ‘/msg’ may be created
-and unsigned message files produced by ‘mmgen-msg’ placed there.
+writable root directory.
 
 On both the signing and online machines the mountpoint ‘{asi.mountpoint}’
 (as currently configured) must exist and ‘/etc/fstab’ must contain the

+ 34 - 9
mmgen/main_txbump.py

@@ -33,10 +33,17 @@ opts_data = {
                 creating a new transaction, and optionally sign and send the
                 new transaction
 		 """,
-		'usage':   f'[opts] <{gc.proj_name} TX file> [seed source] ...',
+		'usage':   f'[opts] [{gc.proj_name} TX file] [seed source] ...',
 		'options': """
 -h, --help             Print this help message
 --, --longhelp         Print help message for long options (common options)
+-a, --autosign         Bump the most recent transaction created and sent with
+                       the --autosign option. The removable device is mounted
+                       and unmounted automatically.  The transaction file
+                       argument must be omitted.  Note that only sent trans-
+                       actions may be bumped with this option.  To redo an
+                       unsent --autosign transaction, first delete it using
+                       ‘mmgen-txsend --abort’ and then create a new one
 -b, --brain-params=l,p Use seed length 'l' and hash preset 'p' for
                        brainwallet input
 -c, --comment-file=  f Source the transaction's comment from file 'f'
@@ -103,10 +110,10 @@ FMT CODES:
 
 cfg = Config(opts_data=opts_data)
 
-tx_file = cfg._args.pop(0)
-
-from .fileutil import check_infile
-check_infile(tx_file)
+if not cfg.autosign:
+	tx_file = cfg._args.pop(0)
+	from .fileutil import check_infile
+	check_infile(tx_file)
 
 from .tx import CompletedTX, BumpTX, UnsignedTX, OnlineSignedTX
 from .tx.sign import txsign,get_seed_files,get_keyaddrlist,get_keylist
@@ -120,20 +127,37 @@ silent = cfg.yes and cfg.fee is not None and cfg.output_to_reduce is not None
 
 async def main():
 
-	orig_tx = await CompletedTX(cfg=cfg,filename=tx_file)
+	if cfg.autosign:
+		from .tx.util import init_removable_device
+		from .autosign import Signable
+		asi = init_removable_device(cfg)
+		asi.do_mount()
+		si = Signable.automount_transaction(asi)
+		if si.unsigned or si.unsent:
+			state = 'unsigned' if si.unsigned else 'unsent'
+			die(1,
+				'Only sent transactions can be bumped with --autosign.  Instead of bumping\n'
+				f'your {state} transaction, abort it with ‘mmgen-txsend --abort’ and create\n'
+				'a new one.')
+		orig_tx = await si.get_last_created()
+		kal = kl = sign_and_send = None
+	else:
+		orig_tx = await CompletedTX(cfg=cfg, filename=tx_file)
 
 	if not silent:
 		msg(green('ORIGINAL TRANSACTION'))
 		msg(orig_tx.info.format(terse=True))
 
-	kal = get_keyaddrlist(cfg,orig_tx.proto)
-	kl = get_keylist(cfg)
-	sign_and_send = bool(seed_files or kl or kal)
+	if not cfg.autosign:
+		kal = get_keyaddrlist(cfg, orig_tx.proto)
+		kl = get_keylist(cfg)
+		sign_and_send = any([seed_files, kl, kal])
 
 	from .tw.ctl import TwCtl
 	tx = await BumpTX(
 		cfg  = cfg,
 		data = orig_tx.__dict__,
+		automount = cfg.autosign,
 		check_sent = cfg.autosign or sign_and_send,
 		twctl = await TwCtl(cfg,orig_tx.proto) if orig_tx.proto.tokensym else None )
 
@@ -181,6 +205,7 @@ async def main():
 			die(2,'Transaction could not be signed')
 	else:
 		tx.file.write(
+			outdir                = asi.txauto_dir if cfg.autosign else None,
 			ask_write             = not cfg.yes,
 			ask_write_default_yes = False,
 			ask_overwrite         = not cfg.yes)

+ 11 - 0
mmgen/main_txcreate.py

@@ -32,6 +32,9 @@ opts_data = {
 		'options': """
 -h, --help            Print this help message
 --, --longhelp        Print help message for long options (common options)
+-a, --autosign        Create a transaction for offline autosigning (see
+                      ‘mmgen-autosign’). The removable device is mounted and
+                      unmounted automatically
 -A, --fee-adjust=  f  Adjust transaction fee by factor 'f' (see below)
 -B, --no-blank        Don't blank screen before displaying unspent outputs
 -c, --comment-file=f  Source the transaction's comment from file 'f'
@@ -83,6 +86,13 @@ cfg = Config(opts_data=opts_data)
 
 async def main():
 
+	if cfg.autosign:
+		from .tx.util import init_removable_device
+		from .autosign import Signable
+		asi = init_removable_device(cfg)
+		asi.do_mount()
+		Signable.automount_transaction(asi).check_create_ok()
+
 	from .tx import NewTX
 	tx1 = await NewTX(cfg=cfg,proto=cfg._proto)
 
@@ -95,6 +105,7 @@ async def main():
 		do_info  = cfg.info )
 
 	tx2.file.write(
+		outdir                = asi.txauto_dir if cfg.autosign else None,
 		ask_write             = not cfg.yes,
 		ask_overwrite         = not cfg.yes,
 		ask_write_default_yes = False)

+ 59 - 10
mmgen/main_txsend.py

@@ -23,19 +23,31 @@ mmgen-txsend: Broadcast a transaction signed by 'mmgen-txsign' to the network
 import sys
 
 from .cfg import gc,Config
-from .util import async_run
+from .util import async_run, msg, suf, die, fmt_list
+from .fileutil import shred_file
 
 opts_data = {
-	'sets': [('yes', True, 'quiet', True)],
+	'sets': [
+		('yes', True, 'quiet', True),
+		('abort', True, 'autosign', True),
+	],
 	'text': {
 		'desc':    f'Send a signed {gc.proj_name} cryptocoin transaction',
-		'usage':   '[opts] <signed transaction file>',
+		'usage':   '[opts] [signed transaction file]',
 		'options': """
 -h, --help      Print this help message
 --, --longhelp  Print help message for long options (common options)
+-a, --autosign  Send an autosigned transaction created by ‘mmgen-txcreate
+                --autosign’.  The removable device is mounted and unmounted
+                automatically. The transaction file argument must be omitted
+                when using this option
+-A, --abort     Abort an unsent transaction created by ‘mmgen-txcreate
+                --autosign’ and delete it from the removable device.  The
+                transaction may be signed or unsigned.
 -d, --outdir= d Specify an alternate directory 'd' for output
 -q, --quiet     Suppress warnings; overwrite files without prompting
--s, --status    Get status of a sent transaction
+-s, --status    Get status of a sent transaction (or the current transaction,
+                whether sent or unsent, when used with --autosign)
 -v, --verbose   Be more verbose
 -y, --yes       Answer 'yes' to prompts, suppress non-essential output
 """
@@ -44,10 +56,41 @@ opts_data = {
 
 cfg = Config(opts_data=opts_data)
 
+if cfg.autosign and cfg.outdir:
+	die(1, '--outdir cannot be used in combination with --autosign')
+
 if len(cfg._args) == 1:
 	infile = cfg._args[0]
 	from .fileutil import check_infile
 	check_infile(infile)
+elif not cfg._args and cfg.autosign:
+	from .tx.util import init_removable_device
+	from .autosign import Signable
+	asi = init_removable_device(cfg)
+	asi.do_mount()
+	si = Signable.automount_transaction(asi)
+	if cfg.abort:
+		files = si.get_abortable() # raises AutosignTXError if no unsent TXs available
+		from .ui import keypress_confirm
+		if keypress_confirm(
+				cfg,
+				'The following file{} will be securely deleted:\n{}\nOK?'.format(
+					suf(files),
+					fmt_list(map(str, files), fmt='col', indent='  '))):
+			for f in files:
+				msg(f'Shredding file ‘{f}’')
+				shred_file(f)
+			sys.exit(0)
+		else:
+			die(1, 'Exiting at user request')
+	elif cfg.status:
+		if si.unsent:
+			die(1, 'Transaction is unsent')
+		if si.unsigned:
+			die(1, 'Transaction is unsigned')
+	else:
+		infile = si.get_unsent()
+		cfg._util.qmsg(f'Got signed transaction file ‘{infile}’')
 else:
 	cfg._opts.usage()
 
@@ -59,10 +102,14 @@ async def main():
 
 	from .tx import OnlineSignedTX, SentTX
 
-	tx = await OnlineSignedTX(
-		cfg        = cfg,
-		filename   = infile,
-		quiet_open = True)
+	if cfg.status and cfg.autosign:
+		tx = await si.get_last_created()
+	else:
+		tx = await OnlineSignedTX(
+			cfg        = cfg,
+			filename   = infile,
+			automount  = cfg.autosign,
+			quiet_open = True)
 
 	from .rpc import rpc_init
 	tx.rpc = await rpc_init(cfg,tx.proto)
@@ -78,11 +125,13 @@ async def main():
 	if not cfg.yes:
 		tx.info.view_with_prompt('View transaction details?')
 		if tx.add_comment(): # edits an existing comment, returns true if changed
-			tx.file.write(ask_write_default_yes=True)
+			if not cfg.autosign:
+				tx.file.write(ask_write_default_yes=True)
 
 	if await tx.send():
-		tx2 = await SentTX(cfg=cfg, data=tx.__dict__)
+		tx2 = await SentTX(cfg=cfg, data=tx.__dict__, automount=cfg.autosign)
 		tx2.file.write(
+			outdir        = asi.txauto_dir if cfg.autosign else None,
 			ask_overwrite = False,
 			ask_write     = False)
 		tx2.print_contract_addr()

+ 6 - 0
mmgen/proto/btc/tx/bump.py

@@ -16,6 +16,7 @@ from ....tx import bump as TxBase
 from ....util import msg
 from .new import New
 from .completed import Completed
+from .unsigned import AutomountUnsigned
 
 class Bump(Completed,New,TxBase.Bump):
 	desc = 'fee-bumped transaction'
@@ -52,3 +53,8 @@ class Bump(Completed,New,TxBase.Bump):
 				c = self.coin ))
 			return False
 		return ret
+
+class AutomountBump(Bump):
+	desc      = 'unsigned fee-bumped automount transaction'
+	ext       = AutomountUnsigned.ext
+	automount = AutomountUnsigned.automount

+ 6 - 0
mmgen/proto/btc/tx/online.py

@@ -77,3 +77,9 @@ class OnlineSigned(Signed,TxBase.OnlineSigned):
 
 class Sent(TxBase.Sent, OnlineSigned):
 	pass
+
+class AutomountOnlineSigned(TxBase.AutomountOnlineSigned, OnlineSigned):
+	pass
+
+class AutomountSent(TxBase.AutomountSent, AutomountOnlineSigned):
+	pass

+ 3 - 0
mmgen/proto/btc/tx/signed.py

@@ -30,3 +30,6 @@ class Signed(Completed,TxBase.Signed):
 				Your transaction fee estimates will be inaccurate
 				Please re-create and re-sign the transaction using the option --vsize-adj={1/ratio:1.2f}
 			""").strip())
+
+class AutomountSigned(TxBase.AutomountSigned, Signed):
+	pass

+ 4 - 1
mmgen/proto/btc/tx/unsigned.py

@@ -67,7 +67,7 @@ class Unsigned(Completed,TxBase.Unsigned):
 		try:
 			self.update_serialized(ret['hex'])
 			from ....tx import SignedTX
-			new = await SignedTX(cfg=self.cfg,data=self.__dict__)
+			new = await SignedTX(cfg=self.cfg, data=self.__dict__, automount=self.automount)
 			tx_decoded = await self.rpc.call( 'decoderawtransaction', ret['hex'] )
 			new.compare_size_and_estimated_size(tx_decoded)
 			new.coin_txid = CoinTxID(self.deserialized.txid)
@@ -81,3 +81,6 @@ class Unsigned(Completed,TxBase.Unsigned):
 				import sys,traceback
 				ymsg( '\n' + ''.join(traceback.format_exception(*sys.exc_info())) )
 			return False
+
+class AutomountUnsigned(TxBase.AutomountUnsigned, Unsigned):
+	pass

+ 6 - 0
mmgen/proto/eth/tx/bump.py

@@ -33,3 +33,9 @@ class Bump(Completed,New,TxBase.Bump):
 
 class TokenBump(TokenCompleted,TokenNew,Bump):
 	desc = 'fee-bumped transaction'
+
+class AutomountBump(Bump):
+	pass
+
+class TokenAutomountBump(TokenBump):
+	pass

+ 12 - 0
mmgen/proto/eth/tx/online.py

@@ -77,3 +77,15 @@ class Sent(TxBase.Sent, OnlineSigned):
 
 class TokenSent(TxBase.Sent, TokenOnlineSigned):
 	pass
+
+class AutomountOnlineSigned(TxBase.AutomountOnlineSigned, OnlineSigned):
+	pass
+
+class AutomountSent(TxBase.AutomountSent, AutomountOnlineSigned):
+	pass
+
+class TokenAutomountOnlineSigned(TxBase.AutomountOnlineSigned, TokenOnlineSigned):
+	pass
+
+class TokenAutomountSent(TxBase.AutomountSent, TokenAutomountOnlineSigned):
+	pass

+ 6 - 0
mmgen/proto/eth/tx/signed.py

@@ -54,3 +54,9 @@ class TokenSigned(TokenCompleted,Signed):
 	def parse_txfile_serialized_data(self):
 		raise NotImplementedError(
 			'Signed transaction files cannot be parsed offline, because tracking wallet is required!')
+
+class AutomountSigned(TxBase.AutomountSigned, Signed):
+	pass
+
+class TokenAutomountSigned(TxBase.AutomountSigned, TokenSigned):
+	pass

+ 7 - 1
mmgen/proto/eth/tx/unsigned.py

@@ -80,7 +80,7 @@ class Unsigned(Completed,TxBase.Unsigned):
 			await self.do_sign(keys[0].sec.wif)
 			msg('OK')
 			from ....tx import SignedTX
-			return await SignedTX(cfg=self.cfg,data=self.__dict__)
+			return await SignedTX(cfg=self.cfg, data=self.__dict__, automount=self.automount)
 		except Exception as e:
 			msg(f'{e}: transaction signing failed!')
 			return False
@@ -107,3 +107,9 @@ class TokenUnsigned(TokenCompleted,Unsigned):
 				gasPrice  = o['gasPrice'],
 				nonce     = o['nonce'])
 		(self.serialized,self.coin_txid) = await t.txsign(tx_in,wif,o['from'],chain_id=o['chainId'])
+
+class AutomountUnsigned(TxBase.AutomountUnsigned, Unsigned):
+	pass
+
+class TokenAutomountUnsigned(TxBase.AutomountUnsigned, TokenUnsigned):
+	pass

+ 8 - 1
mmgen/tx/__init__.py

@@ -52,6 +52,11 @@ def _get_cls_info(clsname,modname,args,kwargs):
 
 	kwargs['proto'] = proto
 
+	if 'automount' in kwargs:
+		if kwargs['automount']:
+			clsname = 'Automount' + clsname
+		del kwargs['automount']
+
 	return ( kwargs['cfg'], proto, clsname, modname, kwargs )
 
 
@@ -74,7 +79,9 @@ async def _get_obj_async( _clsname, _modname, *args, **kwargs ):
 	if proto and proto.tokensym and clsname in (
 			'New',
 			'OnlineSigned',
-			'Sent'):
+			'AutomountOnlineSigned',
+			'Sent',
+			'AutomountSent'):
 		from ..tw.ctl import TwCtl
 		kwargs['twctl'] = await TwCtl(cfg,proto)
 

+ 8 - 6
mmgen/tx/completed.py

@@ -54,15 +54,17 @@ class Completed(Base):
 		"""
 		see twctl:import_token()
 		"""
-		from .unsigned import Unsigned
-		from .online import Sent
-		for cls in (Unsigned, Sent):
+		from .unsigned import Unsigned, AutomountUnsigned
+		from .online import Sent, AutomountSent
+		for cls in (Unsigned, AutomountUnsigned, Sent, AutomountSent):
 			if ext == getattr(cls, 'ext'):
 				return cls
 
 		if proto.tokensym:
 			from .online import OnlineSigned as Signed
+			from .online import AutomountOnlineSigned as AutomountSigned
 		else:
-			from .signed import Signed
-		if ext == Signed.ext:
-			return Signed
+			from .signed import Signed, AutomountSigned
+		for cls in (Signed, AutomountSigned):
+			if ext == getattr(cls, 'ext'):
+				return cls

+ 1 - 1
mmgen/tx/new.py

@@ -436,7 +436,7 @@ class New(Base):
 		self.cfg._util.qmsg('Transaction successfully created')
 
 		from . import UnsignedTX
-		new = UnsignedTX(cfg=self.cfg,data=self.__dict__)
+		new = UnsignedTX(cfg=self.cfg, data=self.__dict__, automount=self.cfg.autosign)
 
 		if not self.cfg.yes:
 			new.info.view_with_prompt('View transaction details?')

+ 8 - 1
mmgen/tx/online.py

@@ -12,7 +12,7 @@
 tx.online: online signed transaction class
 """
 
-from .signed import Signed
+from .signed import Signed, AutomountSigned
 
 class OnlineSigned(Signed):
 
@@ -31,6 +31,13 @@ class OnlineSigned(Signed):
 			expect  = 'YES' if self.cfg.quiet or self.cfg.yes else 'YES, I REALLY WANT TO DO THIS' )
 		msg('Sending transaction')
 
+class AutomountOnlineSigned(AutomountSigned, OnlineSigned):
+	pass
+
 class Sent(OnlineSigned):
 	desc = 'sent transaction'
 	ext = 'subtx'
+
+class AutomountSent(AutomountOnlineSigned):
+	desc = 'sent automount transaction'
+	ext = 'asubtx'

+ 2 - 2
mmgen/tx/sign.py

@@ -112,8 +112,8 @@ def _pop_matching_fns(args,cmplist): # strips found args
 	return list(reversed([args.pop(args.index(a)) for a in reversed(args) if get_extension(a) in cmplist]))
 
 def get_tx_files(cfg, args):
-	from .unsigned import Unsigned
-	ret = _pop_matching_fns(args,[Unsigned.ext])
+	from .unsigned import Unsigned, AutomountUnsigned
+	ret = _pop_matching_fns(args, [(AutomountUnsigned if cfg.autosign else Unsigned).ext])
 	if not ret:
 		die(1,'You must specify a raw transaction file!')
 	return ret

+ 4 - 0
mmgen/tx/signed.py

@@ -18,3 +18,7 @@ class Signed(Completed):
 	desc = 'signed transaction'
 	ext  = 'sigtx'
 	signed = True
+
+class AutomountSigned(Signed):
+	desc = 'signed automount transaction'
+	ext  = 'asigtx'

+ 6 - 0
mmgen/tx/unsigned.py

@@ -18,6 +18,7 @@ from ..util import remove_dups
 class Unsigned(Completed):
 	desc = 'unsigned transaction'
 	ext  = 'rawtx'
+	automount = False
 
 	def delete_attrs(self,desc,attr):
 		for e in getattr(self,desc):
@@ -28,3 +29,8 @@ class Unsigned(Completed):
 		return remove_dups(
 			(e.mmid.sid for e in getattr(self,desc) if e.mmid),
 			quiet = True )
+
+class AutomountUnsigned(Unsigned):
+	desc = 'unsigned automount transaction'
+	ext  = 'arawtx'
+	automount = True

+ 35 - 0
mmgen/tx/util.py

@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
+# Copyright (C)2013-2024 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
+
+"""
+tx.util: transaction utilities
+"""
+
+def get_autosign_obj(cfg):
+	from ..cfg import Config
+	from ..autosign import Autosign
+	return Autosign(
+		Config({
+			'mountpoint': cfg.autosign_mountpoint,
+			'test_suite': cfg.test_suite,
+			'test_suite_root_pfx': cfg.test_suite_root_pfx,
+			'coins': cfg.coin,
+			'online': True, # used only in online environment (txcreate, txsend)
+		})
+	)
+
+def init_removable_device(cfg):
+	asi = get_autosign_obj(cfg)
+	if not asi.get_insert_status():
+		from ..util import die
+		die(1, 'Removable device not present!')
+	import atexit
+	atexit.register(lambda: asi.do_umount())
+	return asi

+ 4 - 0
test/cmdtest_py_d/cfg.py

@@ -34,6 +34,8 @@ cmd_groups_dfl = {
 	'output':           ('CmdTestOutput',{'modname':'misc','full_data':True}),
 	'autosign_clean':   ('CmdTestAutosignClean', {'modname':'autosign'}),
 	'autosign':         ('CmdTestAutosign',{}),
+	'autosign_automount': ('CmdTestAutosignAutomount', {'modname':'automount'}),
+	'autosign_eth':     ('CmdTestAutosignETH', {'modname':'automount_eth'}),
 	'regtest':          ('CmdTestRegtest',{}),
 #	'chainsplit':       ('CmdTestChainsplit',{}),
 	'ethdev':           ('CmdTestEthdev',{}),
@@ -221,6 +223,8 @@ cfgs = { # addr_idx_lists (except 31,32,33,34) must contain exactly 8 addresses
 	'39': {}, # xmr_autosign
 	'40': {}, # cfgfile
 	'41': {}, # opts
+	'49': {}, # autosign_automount
+	'59': {}, # autosign_eth
 	'99': {}, # dummy
 }
 

+ 259 - 0
test/cmdtest_py_d/ct_automount.py

@@ -0,0 +1,259 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
+# Copyright (C)2013-2024 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
+
+"""
+test.cmdtest_py_d.ct_automount: autosigning with automount tests for the cmdtest.py test suite
+"""
+import os, time
+from pathlib import Path
+
+from .ct_autosign import CmdTestAutosignThreaded
+from .ct_regtest import CmdTestRegtest, rt_pw
+from .common import get_file_with_ext
+from ..include.common import cfg
+
+class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtest):
+	'automounted transacting operations via regtest mode'
+
+	networks = ('btc', 'bch', 'ltc')
+	tmpdir_nums = [49]
+
+	rtFundAmt = None # pylint
+	rt_data = {
+		'rtFundAmt': {'btc':'500', 'bch':'500', 'ltc':'5500'},
+	}
+
+	cmd_group = (
+		('setup',                            'regtest mode setup'),
+		('walletgen_alice',                  'wallet generation (Alice)'),
+		('addrgen_alice',                    'address generation (Alice)'),
+		('addrimport_alice',                 'importing Alice’s addresses'),
+		('fund_alice',                       'funding Alice’s wallet'),
+		('generate',                         'mining a block'),
+		('alice_bal1',                       'checking Alice’s balance'),
+		('alice_txcreate1',                  'creating a transaction'),
+		('alice_txcreate_bad_have_unsigned', 'creating the transaction again (error)'),
+		('copy_wallet',                      'copying Alice’s wallet'),
+		('alice_run_autosign_setup',         'running ‘autosign setup’ (with default wallet)'),
+		('autosign_start_thread',            'starting autosign wait loop'),
+		('alice_txstatus1',                  'getting transaction status (unsigned)'),
+		('alice_txstatus2',                  'getting transaction status (unsent)'),
+		('alice_txsend1',                    'sending a transaction, editing comment'),
+		('alice_txstatus3',                  'getting transaction status (in mempool)'),
+		('alice_txsend_bad_no_unsent',       'sending the transaction again (error)'),
+		('generate',                         'mining a block'),
+		('alice_txstatus4',                  'getting transaction status (one confirmation)'),
+		('alice_txcreate2',                  'creating a transaction'),
+		('alice_txsend_abort1',              'aborting the transaction (raw only)'),
+		('alice_txsend_abort2',              'aborting the transaction again (error)'),
+		('alice_txcreate3',                  'creating a transaction'),
+		('alice_txsend_abort3',              'aborting the transaction (user exit)'),
+		('alice_txsend_abort4',              'aborting the transaction (raw + signed)'),
+		('alice_txsend_abort5',              'aborting the transaction again (error)'),
+		('generate',                         'mining a block'),
+		('alice_txcreate4',                  'creating a transaction'),
+		('alice_txbump1',                    'bumping the unsigned transaction (error)'),
+		('alice_txbump2',                    'bumping the unsent transaction (error)'),
+		('alice_txsend2',                    'sending the transaction'),
+		('alice_txbump3',                    'bumping the transaction'),
+		('alice_txsend3',                    'sending the bumped transaction'),
+		('autosign_kill_thread',             'stopping autosign wait loop'),
+		('stop',                             'stopping regtest daemon'),
+		('txview',                           'viewing transactions'),
+	)
+
+	def __init__(self, trunner, cfgs, spawn):
+
+		self.coins = [cfg.coin.lower()]
+
+		CmdTestAutosignThreaded.__init__(self, trunner, cfgs, spawn)
+		CmdTestRegtest.__init__(self, trunner, cfgs, spawn)
+
+		if trunner == None:
+			return
+
+		self.opts.append('--alice')
+
+	def _alice_txcreate(self, chg_addr, opts=[], exit_val=0):
+		self.insert_device_online()
+		sid = self._user_sid('alice')
+		t = self.spawn(
+			'mmgen-txcreate',
+			opts
+			+ ['--alice', '--autosign']
+			+ [f'{self.burn_addr},1.23456', f'{sid}:{chg_addr}'])
+		if exit_val:
+			t.read()
+			self.remove_device_online()
+			t.req_exit_val = exit_val
+			return t
+		t = self.txcreate_ui_common(
+			t,
+			inputs          = '1',
+			interactive_fee = '32s',
+			file_desc       = 'Unsigned automount transaction')
+		t.read()
+		self.remove_device_online()
+		return t
+
+	def alice_txcreate1(self):
+		return self._alice_txcreate(chg_addr='C:5')
+
+	def alice_txcreate2(self):
+		return self._alice_txcreate(chg_addr='L:5')
+
+	alice_txcreate3 = alice_txcreate2
+
+	def alice_txcreate4(self):
+		if cfg.coin == 'BCH':
+			return 'skip'
+		return self._alice_txcreate(chg_addr='L:4')
+
+	def _alice_txsend_abort(self, err=False, user_exit=False, del_expect=[]):
+		self.insert_device_online()
+		t = self.spawn('mmgen-txsend', ['--quiet', '--abort'])
+		if err:
+			t.expect('No unsent transactions')
+			t.req_exit_val = 2
+		else:
+			t.expect('(y/N): ', 'n' if user_exit else 'y')
+			if user_exit:
+				t.expect('Exiting at user request')
+				t.req_exit_val = 1
+			else:
+				for pat in del_expect:
+					t.expect(pat, regex=True)
+		self.remove_device_online()
+		return t
+
+	def alice_txsend_abort1(self):
+		return self._alice_txsend_abort(del_expect=['Shredding .*arawtx'])
+
+	def alice_txsend_abort2(self):
+		return self._alice_txsend_abort(err=True)
+
+	def alice_txsend_abort3(self):
+		return self._alice_txsend_abort(user_exit=True)
+
+	def alice_txsend_abort4(self):
+		self._wait_signed('transaction')
+		return self._alice_txsend_abort(del_expect=[r'Shredding .*arawtx', r'Shredding .*asigtx'])
+
+	alice_txsend_abort5 = alice_txsend_abort2
+
+	def alice_txcreate_bad_have_unsigned(self):
+		return self._alice_txcreate(chg_addr='C:5', exit_val=2)
+
+	def copy_wallet(self):
+		self.spawn('', msg_only=True)
+		if cfg.coin == 'BTC':
+			return 'skip_msg'
+		src  = Path(self.tr.data_dir, 'regtest', cfg.coin.lower(), 'alice')
+		dest = Path(self.tr.data_dir, 'regtest', 'btc', 'alice')
+		dest.mkdir(parents=True, exist_ok=True)
+		wf = Path(get_file_with_ext(src, 'mmdat')).absolute()
+		link_path = dest / wf.name
+		if not link_path.exists():
+			link_path.symlink_to(wf)
+		return 'ok'
+
+	def alice_run_autosign_setup(self):
+		self.insert_device()
+		t = self.run_setup(mn_type='default', use_dfl_wallet=True, passwd=rt_pw)
+		t.read()
+		self.remove_device()
+		return t
+
+	def alice_txsend1(self):
+		return self._alice_txsend('This one’s worth a comment', no_wait=True)
+
+	def alice_txsend2(self):
+		if cfg.coin == 'BCH':
+			return 'skip'
+		return self._alice_txsend()
+
+	def alice_txsend3(self):
+		if cfg.coin == 'BCH':
+			return 'skip'
+		return self._alice_txsend()
+
+	def _alice_txstatus(self, expect, exit_val=None):
+		self.insert_device_online()
+		t = self.spawn('mmgen-txsend', ['--alice', '--autosign', '--status', '--verbose'])
+		t.expect(expect)
+		self.remove_device_online()
+		if exit_val:
+			t.req_exit_val = exit_val
+		return t
+
+	def alice_txstatus1(self):
+		return self._alice_txstatus('unsigned', 1)
+
+	def alice_txstatus2(self):
+		self._wait_signed('transaction')
+		return self._alice_txstatus('unsent', 1)
+
+	def alice_txstatus3(self):
+		return self._alice_txstatus('in mempool')
+
+	def alice_txstatus4(self):
+		return self._alice_txstatus('1 confirmation')
+
+	def _alice_txsend(self, comment=None, no_wait=False):
+		if not no_wait:
+			self._wait_signed('transaction')
+		self.insert_device_online()
+		t = self.spawn('mmgen-txsend', ['--quiet', '--autosign'])
+		t.view_tx('t')
+		t.do_comment(comment)
+		self._do_confirm_send(t, quiet=True)
+		t.written_to_file('Sent automount transaction')
+		t.read()
+		self.remove_device_online()
+		return t
+
+	def alice_txsend_bad_no_unsent(self):
+		self.insert_device_online()
+		t = self.spawn('mmgen-txsend', ['--quiet', '--autosign'])
+		t.expect('No unsent transactions')
+		t.read()
+		t.req_exit_val = 2
+		self.remove_device_online()
+		return t
+
+	def _alice_txbump(self, bad_tx_desc=None):
+		if cfg.coin == 'BCH':
+			return 'skip'
+		self.insert_device_online()
+		t = self.spawn('mmgen-txbump', ['--autosign'])
+		if bad_tx_desc:
+			t.expect('Only sent transactions')
+			t.expect(bad_tx_desc)
+			t.req_exit_val = 1
+		else:
+			t.expect(f'to deduct the fee from .* change output\): ', '\n', regex=True)
+			t.expect(r'(Y/n): ', 'y')  # output OK?
+			t.expect('transaction fee: ', '200s\n')
+			t.expect(r'(Y/n): ', 'y')  # fee OK?
+			t.expect(r'(y/N): ', '\n') # add comment?
+			t.expect(r'(y/N): ', 'y')  # save?
+		t.read()
+		self.remove_device_online()
+		return t
+
+	def alice_txbump1(self):
+		return self._alice_txbump(bad_tx_desc='unsigned transaction')
+
+	def alice_txbump2(self):
+		self._wait_signed('transaction')
+		return self._alice_txbump(bad_tx_desc='unsent transaction')
+
+	def alice_txbump3(self):
+		return self._alice_txbump()

+ 141 - 0
test/cmdtest_py_d/ct_automount_eth.py

@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
+# Copyright (C)2013-2024 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
+
+"""
+test.cmdtest_py_d.ct_automount_eth: Ethereum automount autosigning tests for the cmdtest.py test suite
+"""
+import os, re
+
+from .ct_autosign import CmdTestAutosignThreaded
+from .ct_ethdev import CmdTestEthdev, parity_devkey_fn
+from .common import dfl_words_file
+from ..include.common import cfg
+
+class CmdTestAutosignETH(CmdTestAutosignThreaded, CmdTestEthdev):
+	'automounted transacting operations for Ethereum via ethdev'
+
+	networks = ('eth', 'etc')
+	tmpdir_nums = [59]
+
+	cmd_group = (
+		('setup',                  f'dev mode tests for coin {cfg.coin} (start daemon)'),
+		('addrgen',                'generating addresses'),
+		('addrimport',             'importing addresses'),
+		('addrimport_dev_addr',    "importing dev faucet address 'Ox00a329c..'"),
+		('fund_dev_address',       'funding the default (Parity dev) address'),
+		('fund_mmgen_address',     'funding an MMGen address'),
+		('create_tx',              'creating a transaction'),
+		('run_autosign_setup',     'running ‘autosign setup’'),
+		('autosign_start_thread',  'starting autosign wait loop'),
+		('send_tx',                'sending the transaction'),
+		('token_compile1',         'compiling ERC20 token #1'),
+		('token_deploy1a',         'deploying ERC20 token #1 (SafeMath)'),
+		('token_deploy1b',         'deploying ERC20 token #1 (Owned)'),
+		('token_deploy1c',         'deploying ERC20 token #1 (Token)'),
+		('tx_status2',             'getting the transaction status'),
+		('token_fund_user',        'transferring token funds from dev to user'),
+		('token_addrgen_addr1',    'generating token addresses'),
+		('token_addrimport_addr1', 'importing token addresses using token address (MM1)'),
+		('token_bal1',             f'the {cfg.coin} balance and token balance'),
+		('create_token_tx',        'creating a token transaction'),
+		('send_token_tx',          'sending a token transaction'),
+		('token_bal2',             f'the {cfg.coin} balance and token balance'),
+		('autosign_kill_thread',   'stopping autosign wait loop'),
+		('txview',                 'viewing transactions'),
+		('stop',                   'stopping daemon'),
+	)
+
+	def __init__(self, trunner, cfgs, spawn):
+
+		self.coins = [cfg.coin.lower()]
+
+		CmdTestAutosignThreaded.__init__(self, trunner, cfgs, spawn)
+		CmdTestEthdev.__init__(self, trunner, cfgs, spawn)
+
+		if trunner == None:
+			return
+
+		self.opts.append('--alice')
+
+	def fund_mmgen_address(self):
+		keyfile = os.path.join(self.tmpdir, parity_devkey_fn)
+		t = self.spawn(
+			'mmgen-txdo',
+			['--quiet']
+			+ [f'--keys-from-file={keyfile}']
+			+ ['--fee=40G', '98831F3A:E:1,123.456', dfl_words_file],
+		)
+		t.expect('efresh balance:\b', 'q')
+		t.expect('from: ', '10')
+		t.expect('(Y/n): ', 'y')
+		t.expect('(Y/n): ', 'y')
+		t.expect('(y/N): ', 'n')
+		t.expect('view: ', 'n')
+		t.expect('confirm: ', 'YES')
+		return t
+
+	def create_tx(self):
+		self.insert_device_online()
+		t = self.txcreate(
+			args = ['--autosign', '98831F3A:E:11,54.321'],
+			menu = [],
+			acct = '1')
+		t.read()
+		self.remove_device_online()
+		return t
+
+	def run_autosign_setup(self):
+		self.insert_device()
+		t = self.run_setup(mn_type='bip39', mn_file='test/ref/98831F3A.bip39', use_dfl_wallet=None)
+		t.read()
+		self.remove_device()
+		return t
+
+	def send_tx(self, add_args=[]):
+		self._wait_signed('transaction')
+		self.insert_device_online()
+		t = self.spawn('mmgen-txsend', ['--quiet', '--autosign'] + add_args)
+		t.view_tx('t')
+		t.expect('(y/N): ', 'n')
+		self._do_confirm_send(t, quiet=True)
+		t.written_to_file('Sent automount transaction')
+		self.remove_device_online()
+		return t
+
+	def token_fund_user(self):
+		return self.token_transfer_ops(op='do_transfer', num_tokens=1)
+
+	def token_addrgen_addr1(self):
+		return self.token_addrgen(num_tokens=1)
+
+	def token_bal1(self):
+		return self.token_bal(pat=r':E:11\s+1000\s+54\.321\s+')
+
+	def token_bal2(self):
+		return self.token_bal(pat=r':E:11\s+998.76544\s+54.318\d+\s+.*:E:12\s+1\.23456\s+')
+
+	def token_bal(self, pat):
+		t = self.spawn('mmgen-tool', ['--quiet', '--token=mm1', 'twview', 'wide=1'])
+		text = t.read(strip_color=True)
+		assert re.search(pat, text, re.DOTALL), f'output failed to match regex {pat}'
+		return t
+
+	def create_token_tx(self):
+		self.insert_device_online()
+		t = self.token_txcreate(
+			args      = ['--autosign', '98831F3A:E:12,1.23456'],
+			token     = 'MM1',
+			file_desc = 'Unsigned automount transaction')
+		t.read()
+		self.remove_device_online()
+		return t
+
+	def send_token_tx(self):
+		return self.send_tx(add_args=['--token=MM1'])

+ 30 - 4
test/cmdtest_py_d/ct_autosign.py

@@ -209,6 +209,16 @@ class CmdTestAutosignClean(CmdTestAutosignBase):
 			):
 				(self.asi.tx_dir / fn).touch()
 
+			for fn in (
+				'a.arawtx', 'a.asigtx', 'a.asubtx',
+				'b.arawtx', 'b.asigtx',
+				'c.asubtx',
+				'd.arawtx', 'd.asubtx',
+				'e.arawtx',
+				'f.asigtx', 'f.asubtx',
+			):
+				(self.asi.txauto_dir / fn).touch()
+
 			for fn in (
 				'a.rawmsg.json', 'a.sigmsg.json',
 				'b.rawmsg.json',
@@ -270,6 +280,7 @@ class CmdTestAutosignClean(CmdTestAutosignBase):
 
 		chk_non_xmr = """
 			tx:          a.sigtx b.sigtx c.rawtx d.sigtx
+			txauto:      a.asubtx b.asigtx c.asubtx d.asubtx e.arawtx f.asubtx
 			msg:         a.sigmsg.json b.rawmsg.json c.sigmsg.json d.sigmsg.json
 		"""
 		chk_xmr = """
@@ -281,10 +292,10 @@ class CmdTestAutosignClean(CmdTestAutosignBase):
 		shred_count = 0
 
 		if not self.asi.xmr_only:
-			for k in ('tx_dir','msg_dir'):
+			for k in ('tx_dir', 'txauto_dir', 'msg_dir'):
 				shutil.rmtree(getattr(self.asi, k))
 			chk += chk_non_xmr.rstrip()
-			shred_count += 4
+			shred_count += 9
 
 		if self.asi.have_xmr:
 			shutil.rmtree(self.asi.xmr_dir)
@@ -371,6 +382,21 @@ class CmdTestAutosignThreaded(CmdTestAutosignBase):
 	def do_umount_online(self, *args, **kwargs):
 		return self._mount_ops('asi_online', 'do_umount', *args, **kwargs)
 
+	async def txview(self):
+		self.spawn('', msg_only=True)
+		self.do_mount()
+		src = Path(self.asi.txauto_dir)
+		from mmgen.tx import CompletedTX
+		txs = sorted(
+			[await CompletedTX(cfg=cfg, filename=path, quiet_open=True) for path in sorted(src.iterdir())],
+			key = lambda x: x.timestamp)
+		for tx in txs:
+			imsg(blue(f'\nViewing ‘{tx.infile.name}’:'))
+			out = tx.info.format(terse=True)
+			imsg(indent(out, indent='  '))
+		self.do_umount()
+		return 'ok'
+
 class CmdTestAutosign(CmdTestAutosignBase):
 	'autosigning transactions for all supported coins'
 	coins           = ['btc','bch','ltc','eth']
@@ -754,9 +780,9 @@ class CmdTestAutosignLive(CmdTestAutosignBTC):
 
 		def prompt_insert_sign(t):
 			omsg(orange(insert_msg))
-			t.expect(f'{self.tx_count} transactions signed')
+			t.expect(f'{self.tx_count} non-automount transactions signed')
 			if self.bad_tx_count:
-				t.expect(f'{self.bad_tx_count} transactions failed to sign')
+				t.expect(f'{self.bad_tx_count} non-automount transactions failed to sign')
 			t.expect('Waiting')
 
 		if led_opts:

+ 10 - 3
test/test-release.d/cfg.sh

@@ -153,8 +153,15 @@ init_tests() {
 	d_etc="operations for Ethereum Classic using devnet"
 	t_etc="parity $cmdtest_py --coin=etc ethdev"
 
-	d_autosign="transaction and message autosigning"
-	t_autosign="- $cmdtest_py autosign autosign_clean"
+	d_autosign="transaction autosigning with automount"
+	t_autosign="
+		- $cmdtest_py autosign autosign_clean autosign_automount
+		- $cmdtest_py --coin=bch autosign_automount
+		s $cmdtest_py --coin=ltc autosign_automount
+		- $cmdtest_py --coin=eth autosign_eth
+		s $cmdtest_py --coin=etc autosign_eth
+	"
+	[ "$FAST" ]  && t_autosign_skip='s'
 
 	d_autosign_btc="transaction and message autosigning (Bitcoin only)"
 	t_autosign_btc="- $cmdtest_py autosign_btc"
@@ -164,7 +171,7 @@ init_tests() {
 
 	d_btc="overall operations with emulated RPC data (Bitcoin)"
 	t_btc="
-		- $cmdtest_py --exclude regtest,autosign,autosign_clean,ref_altcoin
+		- $cmdtest_py --exclude regtest,autosign,autosign_clean,autosign_automount,ref_altcoin
 		- $cmdtest_py --segwit
 		- $cmdtest_py --segwit-random
 		- $cmdtest_py --bech32