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.
This commit is contained in:
The MMGen Project 2025-03-15 18:24:53 +00:00
commit 6967456f8f
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
8 changed files with 157 additions and 17 deletions

View file

@ -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')

View file

@ -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())

View file

@ -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)]

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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])