From 6967456f8f92dddbf937d63ab596c69b33a9bfb2 Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Sat, 15 Mar 2025 18:24:53 +0000 Subject: [PATCH] mmgen-txsend: add --dump-hex and --mark-sent options Use --dump-hex to dump the serialized transaction hex to file or standard output instead of sending the transaction. With --autosign, use --mark-sent to mark the transaction as sent on the removable device after a successful out-of-band send. --- mmgen/fileutil.py | 4 +++ mmgen/main_txsend.py | 33 ++++++++++++++++- test/cmdtest_d/common.py | 4 +++ test/cmdtest_d/ct_automount.py | 18 +++++++--- test/cmdtest_d/ct_autosign.py | 35 ++++++++++++++---- test/cmdtest_d/ct_base.py | 5 +-- test/cmdtest_d/ct_regtest.py | 66 +++++++++++++++++++++++++++++++++- test/cmdtest_d/ct_shared.py | 9 +++-- 8 files changed, 157 insertions(+), 17 deletions(-) diff --git a/mmgen/fileutil.py b/mmgen/fileutil.py index 224a8558..3a6ad4ef 100755 --- a/mmgen/fileutil.py +++ b/mmgen/fileutil.py @@ -109,6 +109,10 @@ def check_infile(f, *, blkdev_ok=False): def check_outfile(f, *, blkdev_ok=False): return _check_file_type_and_access(f, 'output file', blkdev_ok=blkdev_ok) +def check_outfile_dir(fn, *, blkdev_ok=False): + return _check_file_type_and_access( + os.path.dirname(os.path.abspath(fn)), 'output directory', blkdev_ok=blkdev_ok) + def check_outdir(f): return _check_file_type_and_access(f, 'output directory') diff --git a/mmgen/main_txsend.py b/mmgen/main_txsend.py index bfab486f..2b66c74a 100755 --- a/mmgen/main_txsend.py +++ b/mmgen/main_txsend.py @@ -44,6 +44,11 @@ opts_data = { --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 +-H, --dump-hex=F Instead of sending to the network, dump the transaction hex + to file ‘F’. Use filename ‘-’ to dump to standard output. +-m, --mark-sent Mark the transaction as sent by adding it to the removable + device. Used in combination with --autosign when a trans- + action has been successfully sent out-of-band. -q, --quiet Suppress warnings; overwrite files without prompting -s, --status Get status of a sent transaction (or current transaction, whether sent or unsent, when used with --autosign) @@ -58,6 +63,13 @@ cfg = Config(opts_data=opts_data) if cfg.autosign and cfg.outdir: die(1, '--outdir cannot be used in combination with --autosign') +if cfg.mark_sent and not cfg.autosign: + die(1, '--mark-sent is used only in combination with --autosign') + +if cfg.dump_hex and cfg.dump_hex != '-': + from .fileutil import check_outfile_dir + check_outfile_dir(cfg.dump_hex) + if len(cfg._args) == 1: infile = cfg._args[0] from .fileutil import check_infile @@ -110,6 +122,10 @@ async def main(): cfg._util.vmsg(f'Getting {tx.desc} ‘{tx.infile}’') + if cfg.mark_sent: + await post_send(tx) + sys.exit(0) + if cfg.status: if tx.coin_txid: cfg._util.qmsg(f'{tx.proto.coin} txid: {tx.coin_txid.hl()}') @@ -127,7 +143,22 @@ async def main(): if not cfg.autosign: tx.file.write(ask_write_default_yes=True) - if await tx.send(): + if cfg.dump_hex: + from .fileutil import write_data_to_file + write_data_to_file( + cfg, + cfg.dump_hex, + tx.serialized + '\n', + desc = 'serialized transaction hex data', + ask_overwrite = False, + ask_tty = False) + if cfg.autosign: + from .ui import keypress_confirm + if keypress_confirm(cfg, 'Mark transaction as sent on removable device?'): + await post_send(tx) + else: + await post_send(tx) + elif await tx.send(): await post_send(tx) async_run(main()) diff --git a/test/cmdtest_d/common.py b/test/cmdtest_d/common.py index 87041a9e..9bb08695 100755 --- a/test/cmdtest_d/common.py +++ b/test/cmdtest_d/common.py @@ -108,6 +108,7 @@ def get_file_with_ext( no_dot = False, return_list = False, delete_all = False, + subdir = None, substr = False): dot = '' if no_dot else '.' @@ -118,6 +119,9 @@ def get_file_with_ext( or fn.endswith(dot + ext) or (substr and ext in fn)) + if subdir: + tdir = os.path.join(tdir, subdir) + # Don’t use os.scandir here - it returns broken paths under Windows/MSYS2 flist = [os.path.join(tdir, name) for name in os.listdir(tdir) if have_match(name)] diff --git a/test/cmdtest_d/ct_automount.py b/test/cmdtest_d/ct_automount.py index dc66b69b..f2d2c1e2 100755 --- a/test/cmdtest_d/ct_automount.py +++ b/test/cmdtest_d/ct_automount.py @@ -59,7 +59,9 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtest): ('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_txsend2_dump_hex', 'dumping the transaction to hex'), + ('alice_txsend2_cli', 'sending the transaction via cli'), + ('alice_txsend2_mark_sent', 'marking the transaction sent'), ('alice_txbump3', 'bumping the transaction'), ('alice_txsend3', 'sending the bumped transaction'), ('alice_txbump4', 'bumping the transaction (new outputs, fee too low)'), @@ -143,10 +145,18 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtest): return self.run_setup(mn_type='default', use_dfl_wallet=True, passwd=rt_pw) def alice_txsend1(self): - return self._user_txsend('alice', 'This one’s worth a comment', no_wait=True) + return self._user_txsend('alice', comment='This one’s worth a comment', no_wait=True) - def alice_txsend2(self): - return self._user_txsend('alice', need_rbf=True) + def alice_txsend2_dump_hex(self): + return self._user_txsend('alice', need_rbf=True, dump_hex=True) + + def alice_txsend2_cli(self): + if not self.proto.cap('rbf'): + return 'skip' + return self._user_dump_hex_send_cli('alice') + + def alice_txsend2_mark_sent(self): + return self._user_txsend('alice', need_rbf=True, mark_sent=True) def alice_txsend3(self): return self._user_txsend('alice', need_rbf=True) diff --git a/test/cmdtest_d/ct_autosign.py b/test/cmdtest_d/ct_autosign.py index 9dca7534..28808d4e 100755 --- a/test/cmdtest_d/ct_autosign.py +++ b/test/cmdtest_d/ct_autosign.py @@ -81,6 +81,7 @@ class CmdTestAutosignBase(CmdTestBase): atexit.register(self._macOS_eject_disk, self.asi.dev_label) self.opts = ['--coins='+','.join(self.coins)] + self.txhex_file = f'{self.tmpdir}/tx_dump.hex' if not self.live: self.spawn_env['MMGEN_TEST_SUITE_ROOT_PFX'] = self.tmpdir @@ -492,7 +493,15 @@ class CmdTestAutosignThreaded(CmdTestAutosignBase): return do_return() - def _user_txsend(self, user, comment=None, no_wait=False, need_rbf=False): + def _user_txsend( + self, + user, + *, + comment = None, + no_wait = False, + need_rbf = False, + dump_hex = False, + mark_sent = False): if need_rbf and not self.proto.cap('rbf'): return 'skip' @@ -500,12 +509,26 @@ class CmdTestAutosignThreaded(CmdTestAutosignBase): if not no_wait: self._wait_signed('transaction') + extra_opt = ( + [f'--dump-hex={self.txhex_file}'] if dump_hex + else ['--mark-sent'] if mark_sent + else []) + self.insert_device_online() - t = self.spawn('mmgen-txsend', [f'--{user}', '--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 = self.spawn('mmgen-txsend', [f'--{user}', '--quiet', '--autosign'] + extra_opt) + + if mark_sent: + t.written_to_file('Sent automount transaction') + else: + t.view_tx('t') + t.do_comment(comment) + if dump_hex: + t.written_to_file('Serialized transaction hex data') + t.expect('(y/N): ', 'n') # mark as sent? + else: + self._do_confirm_send(t, quiet=True) + t.written_to_file('Sent automount transaction') + t.read() self.remove_device_online() return t diff --git a/test/cmdtest_d/ct_base.py b/test/cmdtest_d/ct_base.py index e96ebee5..f6dba953 100755 --- a/test/cmdtest_d/ct_base.py +++ b/test/cmdtest_d/ct_base.py @@ -76,8 +76,9 @@ class CmdTestBase: def get_file_with_ext(self, ext, **kwargs): return get_file_with_ext(self.tmpdir, ext, **kwargs) - def read_from_tmpfile(self, fn, binary=False): - return read_from_file(os.path.join(self.tmpdir, fn), binary=binary) + def read_from_tmpfile(self, fn, binary=False, subdir=None): + tdir = os.path.join(self.tmpdir, subdir) if subdir else self.tmpdir + return read_from_file(os.path.join(tdir, fn), binary=binary) def write_to_tmpfile(self, fn, data, binary=False): return write_to_file(os.path.join(self.tmpdir, fn), data, binary=binary) diff --git a/test/cmdtest_d/ct_regtest.py b/test/cmdtest_d/ct_regtest.py index 7deaab6d..9187945b 100755 --- a/test/cmdtest_d/ct_regtest.py +++ b/test/cmdtest_d/ct_regtest.py @@ -27,7 +27,7 @@ from mmgen.proto.btc.regtest import MMGenRegtest from mmgen.proto.bch.cashaddr import b32a from mmgen.proto.btc.common import b58a from mmgen.color import yellow -from mmgen.util import msg_r, die, gmsg, capfirst, suf, fmt_list +from mmgen.util import msg_r, die, gmsg, capfirst, suf, fmt_list, is_hex_str from mmgen.protocol import init_proto from mmgen.addrlist import AddrList from mmgen.wallet import Wallet, get_wallet_cls @@ -189,6 +189,7 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared): ('subgroup.view', ['label']), ('subgroup._auto_chg_deps', ['twexport', 'label']), ('subgroup.auto_chg', ['_auto_chg_deps']), + ('subgroup.dump_hex', ['fund_users']), ('stop', 'stopping regtest daemon'), ) cmd_subgroups = { @@ -458,6 +459,16 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared): '(no unused addresses)'), ('carol_delete_wallet', 'unloading and deleting Carol’s tracking wallet'), ), + 'dump_hex': ( + 'sending from dumped hex', + ('bob_dump_hex_create', 'dump_hex transaction - creating'), + ('bob_dump_hex_sign', 'dump_hex transaction - signing'), + ('bob_dump_hex_dump_stdout', 'dump_hex transaction - dumping tx hex to stdout'), + ('bob_dump_hex_dump', 'dump_hex transaction - dumping tx hex to file'), + ('bob_dump_hex_send_cli', 'dump_hex transaction - sending via cli'), + ('generate', 'mining a block'), + ('bob_bal7', 'Bob’s balance'), + ), } def __init__(self, trunner, cfgs, spawn): @@ -494,6 +505,7 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared): self.burn_addr = make_burn_addr(self.proto) self.user_sids = {} self.protos = (self.proto,) + self.dump_hex_subdir = os.path.join(self.tmpdir, 'nochg_tx') def _add_comments_to_addr_file(self, proto, addrfile, outfile, use_comments=False): silence() @@ -2185,6 +2197,58 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared): 'L', 'contains no unused addresses of address type') + def bob_dump_hex_create(self): + if not os.path.exists(self.dump_hex_subdir): + os.mkdir(self.dump_hex_subdir) + autochg_arg = self._user_sid('bob') + ':C' + return self.txcreate_ui_common( + self.spawn('mmgen-txcreate', + [ + '-d', + self.dump_hex_subdir, + '-B', + '--bob', + '--fee=0.00009713', + autochg_arg + ]), + auto_chg_addr = autochg_arg) + + def bob_dump_hex_sign(self): + txfile = get_file_with_ext(self.dump_hex_subdir, 'rawtx') + return self.txsign_ui_common( + self.spawn('mmgen-txsign', ['-d', self.dump_hex_subdir, '--bob', txfile]), + do_passwd = True, + passwd = rt_pw) + + def _bob_dump_hex_dump(self, file): + txfile = get_file_with_ext(self.dump_hex_subdir, 'sigtx') + t = self.spawn('mmgen-txsend', ['-d', self.dump_hex_subdir, f'--dump-hex={file}', '--bob', txfile]) + t.expect('view: ', '\n') + t.expect('(y/N): ', '\n') # add comment? + t.written_to_file('Sent transaction') + return t + + def bob_dump_hex_dump(self): + return self._bob_dump_hex_dump('tx_dump.hex') + + def bob_dump_hex_dump_stdout(self): + return self._bob_dump_hex_dump('-') + + def _user_dump_hex_send_cli(self, user, *, subdir=None): + txhex = self.read_from_tmpfile('tx_dump.hex', subdir=subdir).strip() + t = self.spawn('mmgen-cli', [f'--{user}', 'sendrawtransaction', txhex]) + txid = t.read().splitlines()[0] + assert is_hex_str(txid) and len(txid) == 64 + return t + + def bob_dump_hex_send_cli(self): + return self._user_dump_hex_send_cli('bob', subdir='nochg_tx') + + def bob_bal7(self): + if not self.coin == 'btc': + return 'skip' + return self._user_bal_cli('bob', chks=['499.99990287', '46.51845565']) + def stop(self): self.spawn('', msg_only=True) if cfg.no_daemon_stop: diff --git a/test/cmdtest_d/ct_shared.py b/test/cmdtest_d/ct_shared.py index 009ebc37..3d52a03f 100755 --- a/test/cmdtest_d/ct_shared.py +++ b/test/cmdtest_d/ct_shared.py @@ -134,15 +134,18 @@ class CmdTestShared: ni = False, save = True, do_passwd = False, + passwd = None, has_label = False): txdo = (caller or self.test_name)[:4] == 'txdo' - if do_passwd: - t.passphrase('MMGen wallet', self.wpasswd) + if do_passwd and txdo: + t.passphrase('MMGen wallet', passwd or self.wpasswd) - if not ni and not txdo: + if not (ni or txdo): t.view_tx(view) + if do_passwd: + t.passphrase('MMGen wallet', passwd or self.wpasswd) t.do_comment(add_comment, has_label=has_label) t.expect('(Y/n): ', ('n', 'y')[save])