mmgen-txbump: support new outputs in the replacement TX

The former behavior permitted only increasing the transaction fee.  Now the
replacement TX can contain entirely new outputs.

From mmgen-txbump --help:

    If no outputs are specified, the original outputs will be used for the
    replacement transaction, otherwise a new transaction will be created with
    the outputs listed on the command line.  The syntax for the output
    arguments is identical to that of ‘mmgen-txcreate’.

Testing:

    $ test/cmdtest.py regtest_legacy.main autosign_automount
    $ test/cmdtest.py --coin=eth ethdev.main
This commit is contained in:
The MMGen Project 2025-02-16 14:42:32 +00:00
commit ef5f6e4b22
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
12 changed files with 213 additions and 84 deletions

View file

@ -1 +1 @@
15.1.dev15
15.1.dev16

View file

@ -33,7 +33,10 @@ opts_data = {
Create, and optionally send and sign, a replacement transaction
on networks that support replace-by-fee (RBF)
""",
'usage': f'[opts] [{gc.proj_name} TX file] [seed source] ...',
'usage2': (
f'[opts] [{gc.proj_name} TX file] [seed source] ...',
f'[opts] {{u_args}} [{gc.proj_name} TX file] [seed source] ...',
),
'options': """
-- -h, --help Print this help message
-- --, --longhelp Print help message for long (global) options
@ -80,6 +83,18 @@ opts_data = {
-- -z, --show-hash-presets Show information on available hash presets
""",
'notes': """
With --autosign, the TX file argument is omitted, and the last submitted TX
file on the removable device will be used.
If no outputs are specified, the original outputs will be used for the
replacement transaction, otherwise a new transaction will be created with the
outputs listed on the command line. The syntax for the output arguments is
identical to that of mmgen-txcreate.
The user should take care to select a fee sufficient to ensure the original
transaction is replaced in the mempool.
{e}{s}
Seed source files must have the canonical extensions listed in the 'FileExt'
column below:
@ -88,6 +103,8 @@ column below:
"""
},
'code': {
'usage': lambda cfg, proto, help_notes, s: s.format(
u_args = help_notes('txcreate_args', 'tx')),
'options': lambda cfg, help_notes, proto, s: s.format(
cfg = cfg,
gc = gc,
@ -108,15 +125,22 @@ column below:
cfg = Config(opts_data=opts_data)
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
seed_files = get_seed_files(cfg, cfg._args) if (cfg._args or cfg.send) else None
seed_files = get_seed_files(
cfg,
cfg._args,
ignore_dfl_wallet = not cfg.send,
empty_ok = not cfg.send)
if cfg.autosign:
if cfg.send:
die(1, '--send cannot be used together with --autosign')
else:
tx_file = cfg._args.pop()
from .fileutil import check_infile
check_infile(tx_file)
from .ui import do_license_msg
do_license_msg(cfg)
@ -158,33 +182,17 @@ async def main():
check_sent = cfg.autosign or sign_and_send,
twctl = await TwCtl(cfg, orig_tx.proto) if orig_tx.proto.tokensym else None)
from .rpc import rpc_init
tx.rpc = await rpc_init(cfg, tx.proto)
tx.orig_rel_fee = tx.get_orig_rel_fee()
msg('Creating replacement transaction')
tx.check_sufficient_funds_for_bump()
output_idx = tx.choose_output()
if not silent:
msg(f'Minimum fee for new transaction: {tx.min_fee.hl()} {tx.proto.coin}')
tx.usr_fee = tx.get_usr_fee_interactive(fee=cfg.fee, desc='User-selected')
tx.bump_fee(output_idx, tx.usr_fee)
assert tx.fee <= tx.proto.max_tx_fee
if not cfg.yes:
tx.add_comment() # edits an existing comment
await tx.create_serialized(bump=True)
tx.add_timestamp()
tx.add_blockcount()
cfg._util.qmsg('Fee successfully increased')
if cfg._args:
tx.new_outputs = True
tx.is_swap = False
tx.outputs = tx.OutputList(tx)
tx.cfg = cfg # NB: with --automount, must use current cfg opts, not those from orig_tx
await tx.create(cfg._args, caller='txdo' if sign_and_send else 'txcreate')
else:
tx.new_outputs = False
await tx.create_feebump(silent=silent)
if not silent:
msg(green('\nREPLACEMENT TRANSACTION:'))

View file

@ -21,6 +21,9 @@ from .unsigned import AutomountUnsigned
class Bump(Completed, New, TxBase.Bump):
desc = 'fee-bumped transaction'
def get_orig_rel_fee(self):
return self.fee_abs2rel(self.sum_inputs() - self.sum_outputs())
@property
def min_fee(self):
return self.sum_inputs() - self.sum_outputs() + self.relay_fee
@ -33,7 +36,7 @@ class Bump(Completed, New, TxBase.Bump):
def convert_and_check_fee(self, fee, desc):
ret = super().convert_and_check_fee(fee, desc)
if ret is False:
if ret is False or self.new_outputs:
return ret
if ret < self.min_fee:
msg('{} {c}: {} fee too small. Minimum fee: {} {c} ({} {})'.format(

View file

@ -135,9 +135,9 @@ class New(Base, TxNew):
if len(self.nondata_outputs) > 1 and not self.chg_output.mmid:
do_err()
async def create_serialized(self, locktime=None, bump=None):
async def create_serialized(self, locktime=None):
if not bump:
if not self.is_bump:
# Set all sequence numbers to the same value, in conformity with the behavior of most modern wallets:
do_rbf = self.proto.cap('rbf') and not self.cfg.no_rbf
seqnum_val = self.proto.max_int - (2 if do_rbf else 1 if locktime else 0)
@ -158,7 +158,7 @@ class New(Base, TxNew):
ret = await self.rpc.call('createrawtransaction', inputs_list, outputs_dict)
if locktime and not bump:
if locktime and not self.is_bump:
msg(f'Setting nLockTime to {self.info.strfmt_locktime(locktime)}!')
assert isinstance(locktime, int), 'locktime value not an integer'
self.locktime = locktime

View file

@ -21,6 +21,9 @@ from .new import New, TokenNew
class Bump(Completed, New, TxBase.Bump):
desc = 'fee-bumped transaction'
def get_orig_rel_fee(self): # disable this check for ETH
return 0
@property
def min_fee(self):
return self.fee * Decimal('1.101')

View file

@ -65,7 +65,7 @@ class New(Base, TxBase.New):
# Instead of serializing tx data as with BTC, just create a JSON dump.
# This complicates things but means we avoid using the rlp library to deserialize the data,
# thus removing an attack vector
async def create_serialized(self, locktime=None, bump=None):
async def create_serialized(self, locktime=None):
assert len(self.inputs) == 1, 'Transaction has more than one input!'
o_num = len(self.outputs)
o_ok = 0 if self.usr_contract_data else 1

View file

@ -79,6 +79,7 @@ class Base(MMGenObject):
locktime = None
chain = None
signed = False
is_bump = False
is_swap = False
file_format = 'json'
non_mmgen_inputs_msg = f"""

View file

@ -14,12 +14,13 @@ tx.bump: transaction bump class
from .new import New
from .completed import Completed
from ..util import msg, is_int, die
from ..util import msg, ymsg, is_int, die
class Bump(Completed, New):
desc = 'fee-bumped transaction'
ext = 'rawtx'
bump_output_idx = None
is_bump = True
def __init__(self, check_sent, *args, **kwargs):
@ -35,6 +36,48 @@ class Bump(Completed, New):
self.coin_txid = ''
self.sent_timestamp = None
async def get_inputs(self, outputs_sum):
return True
def check_bumped_fee_ok(self, abs_fee):
orig = int(self.orig_rel_fee)
new = int(self.fee_abs2rel(abs_fee))
if new <= orig:
ymsg('New fee ({b} {d}) <= original fee ({a} {d}). Please choose a higher fee'.format(
a=orig, b=new, d=self.rel_fee_disp))
return False
return True
async def create_feebump(self, silent):
from ..rpc import rpc_init
self.rpc = await rpc_init(self.cfg, self.proto)
msg('Creating replacement transaction')
self.check_sufficient_funds_for_bump()
output_idx = self.choose_output()
if not silent:
msg(f'Minimum fee for new transaction: {self.min_fee.hl()} {self.proto.coin}')
self.usr_fee = self.get_usr_fee_interactive(fee=self.cfg.fee, desc='User-selected')
self.bump_fee(output_idx, self.usr_fee)
assert self.fee <= self.proto.max_tx_fee
if not self.cfg.yes:
self.add_comment() # edits an existing comment
await self.create_serialized()
self.add_timestamp()
self.add_blockcount()
self.cfg._util.qmsg('Fee successfully increased')
def check_sufficient_funds_for_bump(self):
if not [o.amt for o in self.outputs if o.amt >= self.min_fee]:
die(1,

View file

@ -137,21 +137,24 @@ class New(Base):
if fee:
abs_fee = self.convert_and_check_fee(fee, desc)
if abs_fee:
prompt = '{a} TX fee{b}: {c}{d} {e} ({f} {g})\n'.format(
a = desc,
b = (f' (after {self.cfg.fee_adjust:.2f}X adjustment)'
if self.cfg.fee_adjust != 1 and desc.startswith('Network-estimated')
else ''),
c = ('', '')[self.fee_is_approximate],
d = abs_fee.hl(),
e = self.coin,
f = pink(self.fee_abs2rel(abs_fee)),
g = self.rel_fee_disp)
from ..ui import keypress_confirm
if self.cfg.yes or keypress_confirm(self.cfg, prompt+'OK?', default_yes=True):
if self.cfg.yes:
msg(prompt)
return abs_fee
if self.is_bump and not self.check_bumped_fee_ok(abs_fee):
pass
else:
prompt = '{a} TX fee{b}: {c}{d} {e} ({f} {g})\n'.format(
a = desc,
b = (f' (after {self.cfg.fee_adjust:.2f}X adjustment)'
if self.cfg.fee_adjust != 1 and desc.startswith('Network-estimated')
else ''),
c = ('', '')[self.fee_is_approximate],
d = abs_fee.hl(),
e = self.coin,
f = pink(self.fee_abs2rel(abs_fee)),
g = self.rel_fee_disp)
from ..ui import keypress_confirm
if self.cfg.yes or keypress_confirm(self.cfg, prompt+'OK?', default_yes=True):
if self.cfg.yes:
msg(prompt)
return abs_fee
fee = line_input(self.cfg, self.usr_fee_prompt)
desc = 'User-selected'
@ -431,21 +434,22 @@ class New(Base):
self.get_addrdata_from_files(self.proto, addrfile_args),
await TwAddrData(self.cfg, self.proto, twctl=self.twctl))
self.twuo = await TwUnspentOutputs(
self.cfg,
self.proto,
minconf = self.cfg.minconf,
addrs = await self.get_input_addrs_from_inputs_opt())
await self.twuo.get_data()
if not self.is_bump:
self.twuo = await TwUnspentOutputs(
self.cfg,
self.proto,
minconf = self.cfg.minconf,
addrs = await self.get_input_addrs_from_inputs_opt())
await self.twuo.get_data()
from ..ui import do_license_msg
do_license_msg(self.cfg)
if not self.cfg.inputs:
if not (self.is_bump or self.cfg.inputs):
await self.twuo.view_filter_and_sort()
self.twuo.display_total()
if not self.is_bump:
self.twuo.display_total()
if do_info:
del self.twuo.twctl
@ -461,7 +465,10 @@ class New(Base):
while True:
if not await self.get_inputs(outputs_sum):
continue
if funds_left := await self.get_fee(self.cfg.fee, outputs_sum):
fee_hint = None
if self.is_swap:
fee_hint = self.update_vault_output(self.vault_output.amt or self.sum_inputs())
if funds_left := await self.get_fee(fee_hint or self.cfg.fee, outputs_sum):
break
self.check_non_mmgen_inputs(caller)
@ -482,6 +489,9 @@ class New(Base):
self.cfg._util.qmsg('Transaction successfully created')
if self.is_bump:
return
from . import UnsignedTX
new = UnsignedTX(cfg=self.cfg, data=self.__dict__, automount=self.cfg.autosign)

View file

@ -128,15 +128,15 @@ def get_tx_files(cfg, args):
die(1, 'You must specify a raw transaction file!')
return ret
def get_seed_files(cfg, args):
def get_seed_files(cfg, args, ignore_dfl_wallet=False, empty_ok=False):
# favor unencrypted seed sources first, as they don't require passwords
ret = _pop_matching_fns(args, get_wallet_extensions('unenc'))
from ..filename import find_file_in_dir
wf = find_file_in_dir(get_wallet_cls('mmgen'), cfg.data_dir) # Make this the first encrypted ss in the list
if wf:
ret.append(wf)
if not ignore_dfl_wallet: # Make this the first encrypted ss in the list
if wf := find_file_in_dir(get_wallet_cls('mmgen'), cfg.data_dir):
ret.append(wf)
ret += _pop_matching_fns(args, get_wallet_extensions('enc'))
if not (ret or cfg.mmgen_keys_from_file or cfg.keys_from_file): # or cfg.use_wallet_dat
if not (ret or empty_ok or cfg.mmgen_keys_from_file or cfg.keys_from_file): # or cfg.use_wallet_dat
die(1, 'You must specify a seed or key source!')
return ret

View file

@ -61,6 +61,13 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
('alice_txsend2', 'sending the transaction'),
('alice_txbump3', 'bumping the transaction'),
('alice_txsend3', 'sending the bumped transaction'),
('alice_txbump4', 'bumping the transaction (new outputs, fee too low)'),
('alice_txbump_abort1', 'aborting the transaction'),
('alice_txbump5', 'bumping the transaction (new outputs)'),
('alice_txsend5', 'sending the bumped transaction'),
('alice_txstatus5', 'getting transaction status (in mempool)'),
('generate', 'mining a block'),
('alice_bal2', 'checking Alice’s balance'),
('wait_loop_kill', 'stopping autosign wait loop'),
('stop', 'stopping regtest daemon'),
('txview', 'viewing transactions'),
@ -176,6 +183,9 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
def alice_txsend3(self):
return self._alice_txsend(need_rbf=True)
def alice_txsend5(self):
return self._alice_txsend(need_rbf=True)
def _alice_txstatus(self, expect, exit_val=None, need_rbf=False):
if need_rbf and not self.proto.cap('rbf'):
@ -204,6 +214,9 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
def alice_txstatus4(self):
return self._alice_txstatus('1 confirmation', 0)
def alice_txstatus5(self):
return self._alice_txstatus('in mempool', need_rbf=True)
def _alice_txsend(self, comment=None, no_wait=False, need_rbf=False):
if need_rbf and not self.proto.cap('rbf'):
@ -211,6 +224,7 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
if not no_wait:
self._wait_signed('transaction')
self.insert_device_online()
t = self.spawn('mmgen-txsend', ['--alice', '--quiet', '--autosign'])
t.view_tx('t')
@ -229,22 +243,30 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
self.remove_device_online()
return t
def _alice_txbump(self, bad_tx_expect=None):
if cfg.coin == 'BCH':
def _alice_txbump(self, fee_opt=None, output_args=[], bad_tx_expect=None, low_fee_fix=None):
if not self.proto.cap('rbf'):
return 'skip'
self.insert_device_online()
t = self.spawn(
'mmgen-txbump',
['--alice', '--autosign'],
['--alice', '--autosign']
+ ([fee_opt] if fee_opt else [])
+ output_args,
exit_val = 1 if bad_tx_expect else None)
if bad_tx_expect:
time.sleep(0.5)
t.expect('Only sent transactions')
t.expect(bad_tx_expect)
else:
t.expect(r'to deduct the fee from .* change output\): ', '\n', regex=True)
t.expect(r'(Y/n): ', 'y') # output OK?
t.expect('transaction fee: ', '200s\n')
if not output_args:
t.expect(r'to deduct the fee from .* change output\): ', '\n', regex=True)
t.expect(r'(Y/n): ', 'y') # output OK?
if low_fee_fix or not fee_opt:
if low_fee_fix:
t.expect('Please choose a higher fee')
t.expect('transaction fee: ', (low_fee_fix or '200s') + '\n')
if output_args:
t.expect(r'(Y/n): ', 'y')
t.expect(r'(Y/n): ', 'y') # fee OK?
t.expect(r'(y/N): ', '\n') # add comment?
t.expect(r'(y/N): ', 'y') # save?
@ -261,3 +283,29 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
def alice_txbump3(self):
return self._alice_txbump()
def alice_txbump4(self):
sid = self._user_sid('alice')
return self._alice_txbump(
fee_opt = '--fee=3s',
output_args = [f'{self.burn_addr},7.654321', f'{sid}:C:1'],
low_fee_fix = '300s')
def alice_txbump_abort1(self):
if not self.proto.cap('rbf'):
return 'skip'
return self._alice_txsend_abort(shred_expect=['Shredding .*arawtx'])
def alice_txbump5(self):
sid = self._user_sid('alice')
return self._alice_txbump(
fee_opt = '--fee=400s',
output_args = ['data:message for posterity', f'{self.burn_addr},7.654321', f'{sid}:C:1'])
def alice_bal2(self):
bals = {
'btc': '491.11002204',
'ltc': '5491.11002204',
'bch': '498.7653392',
}
return self.user_bal('alice', bals.get(self.coin, None))

View file

@ -71,9 +71,9 @@ rt_data = {
'tx_fee': {'btc':'0.0001', 'bch':'0.001', 'ltc':'0.01'},
'rtFundAmt': {'btc':'500', 'bch':'500', 'ltc':'5500'},
'rtFee': {
'btc': ('20s', '10s', '60s', '31s', '10s', '20s'),
'bch': ('20s', '10s', '60s', '0.0001', '10s', '20s'),
'ltc': ('1000s', '500s', '1500s', '0.05', '400s', '1000s')
'btc': ('20s', '10s', '60s', '31s', '10s', '20s', '40s'),
'bch': ('20s', '10s', '60s', '0.0001', '10s', '20s', '40s'),
'ltc': ('1000s', '500s', '1500s', '0.05', '400s', '1000s', '1200s')
},
'rtBals': {
'btc': ('499.9999488', '399.9998282', '399.9998147', '399.9996877',
@ -291,8 +291,10 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
('bob_send_maybe_rbf', 'sending funds to Alice (RBF, if supported)'),
('get_mempool1', 'mempool (before RBF bump)'),
('bob_rbf_status1', 'getting status of transaction'),
('bob_rbf_bump', 'bumping RBF transaction'),
('bob_rbf_bump_newoutputs', 'bumping RBF transaction (new outputs)'),
('get_mempool2', 'mempool (after RBF bump)'),
('bob_rbf_bump', 'bumping RBF transaction'),
('get_mempool3', 'mempool (after RBF bump)'),
('bob_rbf_status2', 'getting status of transaction after replacement'),
('bob_rbf_status3', 'getting status of replacement transaction (mempool)'),
('generate', 'mining a block'),
@ -1173,10 +1175,18 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
t.written_to_file('Fee-bumped transaction')
return t
def bob_rbf_bump_newoutputs(self):
return self._bob_rbf_bump(
['--send', 'data:embedded forever', f'{self.burn_addr},0.1', f'{self._user_sid("bob")}:C:5'],
rtFee[6])
def bob_rbf_bump(self):
return self._bob_rbf_bump(['--send'], rtFee[2])
def _bob_rbf_bump(self, add_args, fee):
ext = ',{}]{x}.regtest.sigtx'.format(rtFee[1][:-1], x='' if cfg.debug_utf8 else '')
txfile = self.get_file_with_ext(ext, delete=False, no_dot=True)
return self.user_txbump('bob', self.tmpdir, txfile, rtFee[2], add_args=['--send'])
return self.user_txbump('bob', self.tmpdir, txfile, fee, add_args=add_args)
def generate(self, num_blocks=1, add_opts=[]):
int(num_blocks)
@ -1203,6 +1213,9 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
def get_mempool2(self):
return self._get_mempool_compare_txid('rbf_txid1', 'rbf_txid2')
def get_mempool3(self):
return self._get_mempool_compare_txid('rbf_txid2', 'rbf_txid3')
def _get_mempool(self, do_msg=False):
if do_msg:
self.spawn('', msg_only=True)
@ -1243,19 +1256,19 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
return self._bob_rbf_status(rtFee[1])
def bob_rbf_status2(self):
return self._bob_rbf_status(rtFee[1], txid='rbf_txid2')
return self._bob_rbf_status(rtFee[1], txid='rbf_txid3')
def bob_rbf_status3(self):
return self._bob_rbf_status(rtFee[2])
def bob_rbf_status4(self):
return self._bob_rbf_status(rtFee[1], txid='rbf_txid2', confirmations=1, exit_val=0)
return self._bob_rbf_status(rtFee[1], txid='rbf_txid3', confirmations=1, exit_val=0)
def bob_rbf_status5(self):
return self._bob_rbf_status(rtFee[2], confirmations=1, exit_val=0)
def bob_rbf_status6(self):
return self._bob_rbf_status(rtFee[1], txid='rbf_txid2', confirmations=2, exit_val=0)
return self._bob_rbf_status(rtFee[1], txid='rbf_txid3', confirmations=2, exit_val=0)
def _gen_pairs(self, n):
from mmgen.tool.api import tool_api