XMR compat: sweep transactions

Example:

    # Invoking `mmgen-txcreate` without arguments activates the sweep function-
    # ality.  Type ‘S’ (account sweep) or ‘s’ (address sweep) to begin creating
    # the transaction:
    $ mmgen-txcreate --coin=xmr

Testing/demo:

    $ test/cmdtest.py --coin=xmr -e -X alice_twview_chk4 xmr_compat
This commit is contained in:
The MMGen Project 2026-01-26 10:56:21 +00:00
commit 907bdc2bdf
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
11 changed files with 117 additions and 14 deletions

View file

@ -1 +1 @@
16.1.dev25
16.1.dev26

View file

@ -23,7 +23,7 @@ class help_notes:
case 'Bitcoin':
return '[ADDR,AMT ... | DATA_SPEC] ADDR [addr file ...]'
case 'Monero':
return 'ADDR,AMT'
return '[ADDR,AMT]'
case _:
return 'ADDR,AMT [addr file ...]'

View file

@ -22,6 +22,7 @@ def help(proto, cfg):
addr = t.privhex2addr('bead' * 16)
sample_addr = addr.views[addr.view_pref]
cmd_base = gc.prog_name + ('' if proto.coin == 'BTC' else f' --coin={proto.coin.lower()}')
action = 'Create' if gc.prog_name == 'mmgen-txcreate' else 'Execute'
match proto.base_proto:
case 'Bitcoin':
@ -64,6 +65,10 @@ EXAMPLES:
Send 0.123 {proto.coin} to an external {proto.name} address:
$ {cmd_base} {sample_addr},0.123
{action} a sweep transaction:
$ {cmd_base}
"""
case _:

View file

@ -130,7 +130,7 @@ if cfg.list_assets:
Msg('AVAILABLE SWAP ASSETS:\n' + sp.SwapAsset('BTC', 'send').fmt_assets_data(indent=' '))
sys.exit(0)
if not (cfg.info or cfg.contract_data):
if not (cfg.info or cfg.contract_data or cfg.coin == 'XMR'):
if len(cfg._args) < {'tx': 1, 'swaptx': 2}[target]:
cfg._usage()

View file

@ -30,4 +30,40 @@ class MoneroTwUnspentOutputs(MoneroTwView, TwUnspentOutputs):
self.extra_key_mappings = {
'R': 'a_sync_wallets',
'A': 's_age'}
if tx and tx.is_sweep:
self.prompt_fs_in[-1] = 'Actions: [q]uit, add [l]abel, r[e]draw, [R]efresh balances:'
self.prompt_fs_in.insert(-1, 'Transaction ops: [s]weep to address, [S]weep to account')
self.extra_key_mappings.update({
's': 'i_addr_sweep',
'S': 'i_acct_sweep'})
await super().__init__(cfg, proto, minconf=minconf, addrs=addrs, tx=tx)
async def get_idx_from_user(self, method_name):
if method_name in ('i_acct_sweep', 'i_addr_sweep'):
from collections import namedtuple
ret = []
for acct_desc in {
'i_acct_sweep': ['source', 'destination'],
'i_addr_sweep': ['source']}[method_name]:
if res := await self.get_idx(f'{acct_desc} account number', self.accts_data):
ret.append(res)
else:
return None
return namedtuple('usr_idx_data', 'idx acct_addr_idx', defaults=[None])(*ret)
else:
return await super().get_idx_from_user(method_name)
class item_action(TwUnspentOutputs.item_action):
acct_methods = ('i_acct_sweep', 'i_addr_sweep')
async def i_acct_sweep(self, parent, idx, acct_addr_idx=None):
d = parent.accts_data
d1 = d[idx.idx - 1]
d2 = d[acct_addr_idx.idx - 1]
parent.tx.sweep_spec = f'{d1.idx}:{d1.acct_idx},{d2.idx}:{d2.acct_idx}'
return 'quit_view'
async def i_addr_sweep(self, parent, idx, acct_addr_idx=None):
d = parent.accts_data[idx.idx - 1]
parent.tx.sweep_spec = f'{d.idx}:{d.acct_idx}'
return 'quit_view'

View file

@ -18,6 +18,11 @@ from .base import Base
class New(Base, TxNew):
async def create(self, cmd_args, **kwargs):
self.is_sweep = not cmd_args
self.sweep_spec = None
return await super().create(cmd_args, **kwargs)
async def get_input_addrs_from_inputs_opt(self):
return [] # TODO
@ -42,7 +47,20 @@ class New(Base, TxNew):
async def compat_create(self):
if True:
if self.is_sweep:
if not self.sweep_spec:
from ....util import ymsg
ymsg('No transaction operation specified. Exiting')
return None
from ....ui import item_chooser
from ....color import pink
op = item_chooser(
self.cfg,
'Choose the sweep operation type',
('sweep', 'sweep_all'),
lambda s: pink(s.upper())).item
spec = self.sweep_spec
else:
op = 'transfer'
i = self.inputs[0]
o = self.outputs[0]

View file

@ -95,7 +95,8 @@ class TwUnspentOutputs(TwView):
self.__dict__['proto'] = proto
MMGenListItem.__init__(self, **kwargs)
async def __init__(self, cfg, proto, *, minconf=1, addrs=[]):
async def __init__(self, cfg, proto, *, minconf=1, addrs=[], tx=None):
self.tx = tx
await super().__init__(cfg, proto)
self.minconf = NonNegativeInt(minconf)
self.addrs = addrs

View file

@ -639,7 +639,9 @@ class TwView(MMGenObject, metaclass=AsyncInit):
case ch if ch in key_mappings:
func = action_classes[ch].run
arg = action_methods[ch]
await func(self, arg) if isAsync(func) else func(self, arg)
ret = await func(self, arg) if isAsync(func) else func(self, arg)
if ret == 'quit_view':
return cleanup()
case 'q':
return cleanup(add_nl=True)
case _:
@ -696,6 +698,8 @@ class TwView(MMGenObject, metaclass=AsyncInit):
await do_error_msg()
async def post_action_cleanup(self, ret):
if ret == 'quit_view':
return ret
if self.scroll and (ret is False or ret in ('redraw', 'erase')):
# error messages could leave screen in messy state, so do complete redraw:
msg_r(

View file

@ -79,6 +79,7 @@ def parse_fee_spec(proto, fee_arg):
class New(Base):
fee_is_approximate = False
is_sweep = False
msg_wallet_low_coin = 'Wallet has insufficient funds for this transaction ({} {} needed)'
msg_no_change_output = """
ERROR: No change address specified. If you wish to create a transaction with
@ -455,7 +456,7 @@ class New(Base):
if self.cfg.comment_file:
self.add_comment(infile=self.cfg.comment_file)
if not do_info:
if cmd_args and not do_info:
cmd_args, addrfile_args = self.get_addrfiles_from_cmdline(cmd_args)
if self.is_swap:
cmd_args = await self.process_swap_cmdline_args(cmd_args, addrfile_args)
@ -475,7 +476,8 @@ class New(Base):
self.cfg,
self.proto,
minconf = self.cfg.minconf,
addrs = await self.get_input_addrs_from_inputs_opt())
addrs = await self.get_input_addrs_from_inputs_opt(),
tx = self if self.is_sweep else None)
await self.twuo.get_data()
self.twctl = self.twuo.twctl
@ -485,6 +487,11 @@ class New(Base):
if not (self.is_bump or self.cfg.inputs):
await self.twuo.view_filter_and_sort()
if self.is_sweep:
del self.twctl
del self.twuo.twctl
return await self.compat_create()
if not self.is_bump:
self.twuo.display_total()

View file

@ -169,10 +169,9 @@ class OpSweep(OpMixinSpec, OpWallet):
die(2, f'{self.account}: requested account index out of bounds (>{max_acct})')
async def main(self):
if not self.compat_call:
gmsg(
f'\n{self.stem.capitalize()}ing account #{self.account}'
f' of wallet {self.source.idx}{self.add_desc}')
gmsg(
f'\n{self.stem.capitalize()}ing account #{self.account}'
f' of wallet {self.source.idx}{self.add_desc}')
h = MoneroWalletRPC(self, self.source)

View file

@ -548,6 +548,16 @@ class CmdTestXMRCompat(CmdTestXMRAutosign):
('alice_txcreate3', 'recreating the transaction'),
('wait_loop_start_ltc', 'starting autosign wait loop in XMR compat mode [--coins=ltc,xmr]'),
('alice_txsend1', 'sending the transaction'),
('mine_blocks_10', 'mining some blocks'),
('alice_twview_chk2', 'viewing Alice’s tracking wallets (check balances)'),
('alice_txcreate_sweep1', 'creating a sweep transaction (account sweep)'),
('alice_txsend2', 'sending the transaction'),
('mine_blocks_10', 'mining some blocks'),
('alice_twview_chk3', 'viewing Alice’s tracking wallets (check balances)'),
('alice_txcreate_sweep2', 'creating a sweep transaction (address sweep)'),
('alice_txsend3', 'sending the transaction'),
('mine_blocks_10', 'mining some blocks'),
('alice_twview_chk4', 'viewing Alice’s tracking wallets (check balances)'),
('wait_loop_kill', 'stopping autosign wait loop'),
('alice_newacct1', 'adding account to Alice’s tracking wallet (dfl label)'),
('alice_newacct2', 'adding account to Alice’s tracking wallet (no timestr)'),
@ -686,6 +696,15 @@ class CmdTestXMRCompat(CmdTestXMRAutosign):
'1 0.026296296417',
'0.007654321098'])
def alice_twview_chk2(self):
return self._alice_twview_chk(['Total XMR: 3.715053370119'], sync=True)
def alice_twview_chk3(self):
return self._alice_twview_chk(['Total XMR: 3.713242570119', '1.232757091234'], sync=True)
def alice_twview_chk4(self):
return self._alice_twview_chk(['Total XMR: 3.709050970119', '1.254861787651'], sync=True)
def _alice_twview_chk(self, expect_arr, sync=False):
return self._alice_twops(
'twview',
@ -756,6 +775,12 @@ class CmdTestXMRCompat(CmdTestXMRAutosign):
def alice_txcreate1(self):
return self._alice_txops('txcreate', [f'{self.burn_addr},0.012345'], acct_num=1)
def alice_txcreate_sweep1(self):
return self._alice_txops('txcreate', menu='S', sweep_menu='23', sweep_type='sweep')
def alice_txcreate_sweep2(self):
return self._alice_txops('txcreate', menu='s', sweep_menu='2', sweep_type='sweep_all')
alice_txcreate3 = alice_txcreate2 = alice_txcreate1
def _alice_txabort(self):
@ -770,7 +795,7 @@ class CmdTestXMRCompat(CmdTestXMRAutosign):
add_opts = self.alice_daemon_opts,
wait_signed = True)
alice_txsend1 = _alice_txsend
alice_txsend1 = alice_txsend2 = alice_txsend3 = _alice_txsend
def wait_signed1(self):
self.spawn(msg_only=True)
@ -788,6 +813,8 @@ class CmdTestXMRCompat(CmdTestXMRAutosign):
menu = '',
acct_num = None,
wait_signed = False,
sweep_type = None,
sweep_menu = '',
signable_desc = 'transaction'):
if wait_signed:
self._wait_signed(signable_desc)
@ -796,7 +823,13 @@ class CmdTestXMRCompat(CmdTestXMRAutosign):
if '--abort' in opts:
t.expect('(y/N): ', 'y')
elif op == 'txcreate':
if True:
if sweep_type:
t.expect(self.menu_prompt, menu)
for ch in sweep_menu:
t.expect('main menu): ', ch)
t.expect('number> ', {'sweep': '1', 'sweep_all': '2'}[sweep_type])
t.expect('(y/N): ', 'y') # create new address?
else:
for ch in menu + 'q':
t.expect(self.menu_prompt, ch)
t.expect('to spend from: ', f'{acct_num}\n')