From 1c5c3319d4986aa6a29f225a643c9034064c899d Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Thu, 29 Feb 2024 15:37:34 +0000 Subject: [PATCH] offline transaction signing with automount for BTC, BCH, LTC and ETH/ERC20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- mmgen/autosign.py | 49 ++++- mmgen/data/version | 2 +- mmgen/main_autosign.py | 4 +- mmgen/main_txbump.py | 43 ++++- mmgen/main_txcreate.py | 11 ++ mmgen/main_txsend.py | 69 ++++++- mmgen/proto/btc/tx/bump.py | 6 + mmgen/proto/btc/tx/online.py | 6 + mmgen/proto/btc/tx/signed.py | 3 + mmgen/proto/btc/tx/unsigned.py | 5 +- mmgen/proto/eth/tx/bump.py | 6 + mmgen/proto/eth/tx/online.py | 12 ++ mmgen/proto/eth/tx/signed.py | 6 + mmgen/proto/eth/tx/unsigned.py | 8 +- mmgen/tx/__init__.py | 9 +- mmgen/tx/completed.py | 14 +- mmgen/tx/new.py | 2 +- mmgen/tx/online.py | 9 +- mmgen/tx/sign.py | 4 +- mmgen/tx/signed.py | 4 + mmgen/tx/unsigned.py | 6 + mmgen/tx/util.py | 35 ++++ test/cmdtest_py_d/cfg.py | 4 + test/cmdtest_py_d/ct_automount.py | 259 ++++++++++++++++++++++++++ test/cmdtest_py_d/ct_automount_eth.py | 141 ++++++++++++++ test/cmdtest_py_d/ct_autosign.py | 34 +++- test/test-release.d/cfg.sh | 13 +- 27 files changed, 719 insertions(+), 45 deletions(-) create mode 100755 mmgen/tx/util.py create mode 100755 test/cmdtest_py_d/ct_automount.py create mode 100755 test/cmdtest_py_d/ct_automount_eth.py diff --git a/mmgen/autosign.py b/mmgen/autosign.py index daf0da01..43271ca4 100755 --- a/mmgen/autosign.py +++ b/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 = { diff --git a/mmgen/data/version b/mmgen/data/version index c8d8f2a7..99c41596 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -14.1.dev14 +14.1.dev15 diff --git a/mmgen/main_autosign.py b/mmgen/main_autosign.py index 7f335833..fad9ded8 100755 --- a/mmgen/main_autosign.py +++ b/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 diff --git a/mmgen/main_txbump.py b/mmgen/main_txbump.py index 41ca9276..07bce193 100755 --- a/mmgen/main_txbump.py +++ b/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) diff --git a/mmgen/main_txcreate.py b/mmgen/main_txcreate.py index 771e4908..2ad82ede 100755 --- a/mmgen/main_txcreate.py +++ b/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) diff --git a/mmgen/main_txsend.py b/mmgen/main_txsend.py index 53a29f4d..560c32e9 100755 --- a/mmgen/main_txsend.py +++ b/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] ', + '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() diff --git a/mmgen/proto/btc/tx/bump.py b/mmgen/proto/btc/tx/bump.py index 8686b999..d100252a 100755 --- a/mmgen/proto/btc/tx/bump.py +++ b/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 diff --git a/mmgen/proto/btc/tx/online.py b/mmgen/proto/btc/tx/online.py index 47b58d24..d3542cf7 100755 --- a/mmgen/proto/btc/tx/online.py +++ b/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 diff --git a/mmgen/proto/btc/tx/signed.py b/mmgen/proto/btc/tx/signed.py index 6b105b3a..6245f1b0 100755 --- a/mmgen/proto/btc/tx/signed.py +++ b/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 diff --git a/mmgen/proto/btc/tx/unsigned.py b/mmgen/proto/btc/tx/unsigned.py index 4f7d1b0c..df4d64ea 100755 --- a/mmgen/proto/btc/tx/unsigned.py +++ b/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 diff --git a/mmgen/proto/eth/tx/bump.py b/mmgen/proto/eth/tx/bump.py index 9f646e34..9253ca63 100755 --- a/mmgen/proto/eth/tx/bump.py +++ b/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 diff --git a/mmgen/proto/eth/tx/online.py b/mmgen/proto/eth/tx/online.py index 1f61f54d..98bab91e 100755 --- a/mmgen/proto/eth/tx/online.py +++ b/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 diff --git a/mmgen/proto/eth/tx/signed.py b/mmgen/proto/eth/tx/signed.py index 65831a55..0ee90c4d 100755 --- a/mmgen/proto/eth/tx/signed.py +++ b/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 diff --git a/mmgen/proto/eth/tx/unsigned.py b/mmgen/proto/eth/tx/unsigned.py index d5563736..9a6c9c72 100755 --- a/mmgen/proto/eth/tx/unsigned.py +++ b/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 diff --git a/mmgen/tx/__init__.py b/mmgen/tx/__init__.py index f7c387ab..62e351aa 100755 --- a/mmgen/tx/__init__.py +++ b/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) diff --git a/mmgen/tx/completed.py b/mmgen/tx/completed.py index 09f6389b..4c415ac3 100755 --- a/mmgen/tx/completed.py +++ b/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 diff --git a/mmgen/tx/new.py b/mmgen/tx/new.py index bee44e9d..27fdd82f 100755 --- a/mmgen/tx/new.py +++ b/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?') diff --git a/mmgen/tx/online.py b/mmgen/tx/online.py index dce3c754..7bcb1541 100755 --- a/mmgen/tx/online.py +++ b/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' diff --git a/mmgen/tx/sign.py b/mmgen/tx/sign.py index a918a585..69a0e6a8 100755 --- a/mmgen/tx/sign.py +++ b/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 diff --git a/mmgen/tx/signed.py b/mmgen/tx/signed.py index b48607f2..44d4b723 100755 --- a/mmgen/tx/signed.py +++ b/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' diff --git a/mmgen/tx/unsigned.py b/mmgen/tx/unsigned.py index e195585f..3c20f14e 100755 --- a/mmgen/tx/unsigned.py +++ b/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 diff --git a/mmgen/tx/util.py b/mmgen/tx/util.py new file mode 100755 index 00000000..e027f06b --- /dev/null +++ b/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 +# 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 diff --git a/test/cmdtest_py_d/cfg.py b/test/cmdtest_py_d/cfg.py index 80e0daab..0fb604d4 100755 --- a/test/cmdtest_py_d/cfg.py +++ b/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 } diff --git a/test/cmdtest_py_d/ct_automount.py b/test/cmdtest_py_d/ct_automount.py new file mode 100755 index 00000000..8ba74678 --- /dev/null +++ b/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 +# 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() diff --git a/test/cmdtest_py_d/ct_automount_eth.py b/test/cmdtest_py_d/ct_automount_eth.py new file mode 100755 index 00000000..385a466b --- /dev/null +++ b/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 +# 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']) diff --git a/test/cmdtest_py_d/ct_autosign.py b/test/cmdtest_py_d/ct_autosign.py index c4f21551..711013ef 100755 --- a/test/cmdtest_py_d/ct_autosign.py +++ b/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: diff --git a/test/test-release.d/cfg.sh b/test/test-release.d/cfg.sh index 4d217c82..078e7f4b 100755 --- a/test/test-release.d/cfg.sh +++ b/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