XMR compat: support tracking wallet views

- all operations, including label editing and balance refresh, supported
- requires --autosign
- enables --xmrwallet-compat, meaning Monero watch-only wallets must be
  located in ~/.mmgen/altcoins/xmr/tracking-wallets

This is a work in progress, UI will undergo changes.

Examples:

    $ mmgen-tool --coin=xmr listaddresses interactive=1
    $ mmgen-tool --coin=xmr twview interactive=1

Testing:

    $ test/cmdtest.py -ne xmr_compat
This commit is contained in:
The MMGen Project 2025-11-22 09:04:09 +00:00
commit bdd7dd3393
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
18 changed files with 272 additions and 32 deletions

View file

@ -147,12 +147,23 @@ class MMGenID(HiliteStr, InitErrors, MMGenObject):
idx, ext = idx.split('-', 1)
me.acct_idx, me.addr_idx = [MoneroIdx(e) for e in ext.split('/', 1)]
else:
ext = None
me = str.__new__(cls, f'{sid}:{mmtype}:{idx}')
me.sid = SeedID(sid=sid)
me.mmtype = proto.addr_type(mmtype)
me.idx = AddrIdx(idx)
me.al_id = str.__new__(AddrListID, me.sid + ':' + me.mmtype) # checks already done
me.sort_key = f'{me.sid}:{me.mmtype}:{me.idx:0{me.idx.max_digits}}'
if ext:
me.sort_key = '{}:{}:{:0{w1}}:{:0{w2}}:{:0{w2}}'.format(
me.sid,
me.mmtype,
me.idx,
me.acct_idx,
me.addr_idx,
w1 = me.idx.max_digits,
w2 = MoneroIdx.max_digits)
else:
me.sort_key = '{}:{}:{:0{w}}'.format(me.sid, me.mmtype, me.idx, w=me.idx.max_digits)
me.proto = proto
return me
except Exception as e:

View file

@ -240,6 +240,7 @@ class Config(Lockable):
# Monero:
monero_wallet_rpc_user = 'monero'
monero_wallet_rpc_password = ''
monero_daemon = ''
xmrwallet_compat = False
priority = 0

View file

@ -1 +1 @@
16.1.dev14
16.1.dev15

View file

@ -34,6 +34,7 @@ opts_data = {
-- -d, --outdir= d Specify an alternate directory 'd' for output
-- -h, --help Print this help message
-- --, --longhelp Print help message for long (global) options
x- -a, --autosign Operate on an autosigned transaction
-- -e, --echo-passphrase Echo passphrase or mnemonic to screen upon entry
-- -k, --use-internal-keccak-module Force use of the internal keccak module
-- -K, --keygen-backend=n Use backend 'n' for public key generation. Options

View file

@ -308,6 +308,9 @@ class UserOpts(Opts):
br --rpc-user=USER Authenticate to coin daemon using username USER
br --rpc-password=PASS Authenticate to coin daemon using password PASS
Rr --rpc-backend=backend Use backend 'backend' for JSON-RPC communications
mr --monero-wallet-rpc-user=USER Monero wallet RPC username
mr --monero-wallet-rpc-password=USER Monero wallet RPC password
mr --monero-daemon=HOST:PORT Connect to the monerod at HOST:PORT
Rr --aiohttp-rpc-queue-len=N Use N simultaneous RPC connections with aiohttp
-p --regtest=0|1 Disable or enable regtest mode
-- --testnet=0|1 Disable or enable testnet
@ -361,6 +364,7 @@ class UserOpts(Opts):
'r' - local RPC coin
'X' - remote RPC coin
'x' - local or remote RPC coin
'm' - Monero
'-' - any coin
Cmd codes:
'p' - proto required
@ -378,6 +382,7 @@ class UserOpts(Opts):
['-', 'r', 'R', 'b', 'h', 'x'] if coin == 'bch' else
['-', 'r', 'R', 'b', 'x'] if coin in gc.btc_fork_rpc_coins else
['-', 'r', 'R', 'e', 'x'] if coin in gc.eth_fork_coins else
['-', 'r', 'x', 'm'] if coin == 'xmr' else
['-', 'r', 'x'] if coin in gc.local_rpc_coins else
['-', 'X', 'x'] if coin in gc.remote_rpc_coins else
['-']),

22
mmgen/proto/xmr/tw/addresses.py Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python3
#
# MMGen Wallet, a terminal-based cryptocurrency wallet
# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
# 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
"""
proto.xmr.tw.addresses: Monero protocol tracking wallet address list class
"""
from ....tw.addresses import TwAddresses
from .view import MoneroTwView
class MoneroTwAddresses(MoneroTwView, TwAddresses):
include_empty = True
has_used = True

View file

@ -12,8 +12,34 @@
proto.xmr.tw.ctl: Monero tracking wallet control class
"""
from ....tw.ctl import write_mode
from ....tw.store import TwCtlWithStore
class MoneroTwCtl(TwCtlWithStore):
tw_subdir = 'tracking-wallets'
use_cached_balances = True
@write_mode
async def set_comment(
self,
addrspec,
comment = '',
*,
trusted_pair = None,
silent = False):
from ....ui import keypress_confirm
add_timestr = keypress_confirm(self.cfg, 'Add timestamp to label?')
m = trusted_pair[0].obj
from ....xmrwallet import op as xmrwallet_op
op = xmrwallet_op(
'label',
self.cfg,
None,
None,
spec = f'{m.idx}:{m.acct_idx}:{m.addr_idx},{comment}',
compat_call = True)
await op.restart_wallet_daemon()
return await op.main(add_timestr=add_timestr, auto=True)

23
mmgen/proto/xmr/tw/unspent.py Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env python3
#
# MMGen Wallet, a terminal-based cryptocurrency wallet
# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
# 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
"""
proto.xmr.tw.unspent: Monero protocol tracking wallet unspent outputs class
"""
from ....tw.addresses import TwAddresses
from .view import MoneroTwView
class MoneroTwUnspentOutputs(MoneroTwView, TwAddresses):
hdr_lbl = 'spendable accounts'
desc = 'address balances'
include_empty = False

81
mmgen/proto/xmr/tw/view.py Executable file
View file

@ -0,0 +1,81 @@
#!/usr/bin/env python3
#
# MMGen Wallet, a terminal-based cryptocurrency wallet
# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
# 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
"""
proto.xmr.tw.view: Monero protocol base class for tracking wallet view classes
"""
from ....xmrwallet import op as xmrwallet_op
from ....tw.view import TwView
class MoneroTwView:
class rpc:
caps = ()
is_remote = False
prompt_fs_repl = {'XMR': (
(1, 'Filters: show [E]mpty addrs, [u]sed addrs, all [L]abels'),
(3, 'Actions: [q]uit menu, add [l]abel, [R]efresh balances:'))}
extra_key_mappings = {
'u': 'd_showused',
'R': 'a_sync_wallets'}
removed_key_mappings = {
'D': 'i_addr_delete'}
async def get_rpc_data(self):
from mmgen.tw.shared import TwMMGenID, TwLabel
op = xmrwallet_op('dump_data', self.cfg, None, None, compat_call=True)
await op.restart_wallet_daemon()
wallets_data = await op.main()
self.total = self.proto.coin_amt('0')
def gen_addrs():
for wdata in wallets_data:
bals_data = {i: {} for i in range(len(wdata['data'].accts_data['subaddress_accounts']))}
for d in wdata['data'].bals_data.get('per_subaddress', []):
bals_data[d['account_index']].update({d['address_index']: d['unlocked_balance']})
for acct_idx, acct_data in enumerate(wdata['data'].addrs_data):
for addr_data in acct_data['addresses']:
addr_idx = addr_data['address_index']
self.total += (bal := self.proto.coin_amt(
bals_data[acct_idx].get(addr_idx, 0),
from_unit = 'atomic'))
if self.include_empty or bal:
mmid = '{}:M:{}-{}/{}'.format(
wdata['seed_id'],
wdata['wallet_num'],
acct_idx,
addr_idx)
yield (TwMMGenID(self.proto, mmid), {
'addr': addr_data['address'],
'amt': bal,
'recvd': bal,
'is_used': addr_data['used'],
'confs': 1,
'lbl': TwLabel(self.proto, mmid + ' ' + addr_data['label'])})
return dict(gen_addrs())
class action(TwView.action):
async def a_sync_wallets(self, parent):
from ....util import msg, msg_r
from ....tw.view import CUR_HOME, ERASE_ALL
msg('')
op = xmrwallet_op('sync', parent.cfg, None, None, compat_call=True)
await op.restart_wallet_daemon()
await op.main()
await parent.get_data()
msg_r(CUR_HOME + ERASE_ALL)

View file

@ -192,10 +192,11 @@ class TwView(MMGenObject, metaclass=AsyncInit):
async def __init__(self, cfg, proto):
self.cfg = cfg
self.proto = proto
if have_rpc := 'rpc_init' in proto.mmcaps:
self.rpc = await rpc_init(cfg, proto)
if self.has_wallet:
from .ctl import TwCtl
self.twctl = await TwCtl(cfg, proto, mode='w')
self.twctl = await TwCtl(cfg, proto, mode='w', no_rpc=not have_rpc)
self.amt_keys = {'amt':'iwidth', 'amt2':'iwidth2'} if self.has_amt2 else {'amt':'iwidth'}
if repl_data := self.prompt_fs_repl.get(self.proto.coin):
for repl in [repl_data] if isinstance(repl_data[0], int) else repl_data:
@ -441,7 +442,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
yield 'Network: {}'.format(Green(
self.proto.coin + ' ' + self.proto.chain_name.upper()))
if not self.rpc.is_remote:
if hasattr(self.rpc, 'blockcount'):
yield 'Block {} [{}]'.format(
self.rpc.blockcount.hl(color=color),
make_timestr(self.rpc.cur_date))
@ -778,7 +779,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
a = 'for' if edited else 'added to' if comment else 'removed from',
b = desc,
c = ' edited' if edited else ''))
return True
return 'redraw' if parent.cfg.coin == 'XMR' else True
else:
await asyncio.sleep(3)
parent.oneshot_msg = red('Label for {desc} could not be {action}'.format(

View file

@ -116,8 +116,8 @@ def op_cls(op_name):
cls.name = op_name
return cls
def op(op, cfg, infile, wallets, *, spec=None):
if cfg.compat if cfg.compat is not None else cfg.xmrwallet_compat:
def op(op, cfg, infile, wallets, *, spec=None, compat_call=False):
if compat_call or (cfg.compat if cfg.compat is not None else cfg.xmrwallet_compat):
if cfg.wallet_dir:
die(1, '--wallet-dir can not be specified in xmrwallet compatibility mode')
from ..tw.ctl import TwCtl
@ -126,5 +126,8 @@ def op(op, cfg, infile, wallets, *, spec=None):
cfg = Config({
'_clone': cfg,
'compat': True,
'no_start_wallet_daemon': cfg.no_start_wallet_daemon or compat_call,
'daemon': cfg.daemon or cfg.monero_daemon,
'watch_only': cfg.watch_only or cfg.autosign or bool(cfg.autosign_mountpoint),
'wallet_dir': twctl_cls.get_tw_dir(cfg, cfg._proto)})
return op_cls(op)(cfg, uargs(infile, wallets, spec))

View file

@ -111,12 +111,13 @@ class OpBase:
Proxy: {blue(m[2] or 'None')}
""", strip_char='\t', indent=indent))
def mount_removable_device(self):
def mount_removable_device(self, registered=[]):
if self.cfg.autosign:
if not self.asi.device_inserted:
die(1, 'Removable device not present!')
if self.do_umount:
if self.do_umount and not registered:
atexit.register(lambda: self.asi.do_umount())
registered.append(None)
self.asi.do_mount()
self.post_mount_action()

View file

@ -29,7 +29,7 @@ class OpLabel(OpMixinSpec, OpWallet):
opts = ()
wallet_offline = True
async def main(self, add_timestr='ask'):
async def main(self, add_timestr='ask', auto=False):
gmsg('\n{a} label for wallet {b}, account #{c}, address #{d}'.format(
a = 'Setting' if self.label else 'Removing',
@ -40,13 +40,13 @@ class OpLabel(OpMixinSpec, OpWallet):
h = MoneroWalletRPC(self, self.source)
h.open_wallet('source')
wallet_data = h.get_wallet_data()
wallet_data = h.get_wallet_data(print=not auto)
max_acct = len(wallet_data.accts_data['subaddress_accounts']) - 1
if self.account > max_acct:
die(2, f'{self.account}: requested account index out of bounds (>{max_acct})')
ret = h.print_acct_addrs(wallet_data, self.account)
ret = h.print_acct_addrs(wallet_data, self.account, silent=auto)
if self.address_idx > len(ret) - 1:
die(2, '{}: requested address index out of bounds (>{})'.format(
@ -60,6 +60,7 @@ class OpLabel(OpMixinSpec, OpWallet):
(self.label + (f' [{make_timestr()}]' if add_timestr else '')) if self.label
else '')
if not auto:
ca = CoinAddr(self.proto, addr['address'])
from . import addr_width
msg('\n {a} {b}\n {c} {d}\n {e} {f}'.format(
@ -74,7 +75,7 @@ class OpLabel(OpMixinSpec, OpWallet):
if addr['label'] == new_label:
ymsg('\nLabel is unchanged, operation cancelled')
elif keypress_confirm(self.cfg, f' {op.capitalize()} label?'):
elif auto or keypress_confirm(self.cfg, f' {op.capitalize()} label?'):
h.set_label(self.account, self.address_idx, new_label)
ret = h.print_acct_addrs(h.get_wallet_data(print=False), self.account)
label_chk = ret[self.address_idx]['label']
@ -83,5 +84,6 @@ class OpLabel(OpMixinSpec, OpWallet):
return False
else:
msg(cyan('\nLabel successfully {}'.format('set' if op == 'set' else op+'d')))
return new_label
else:
ymsg('\nOperation cancelled by user request')

View file

@ -14,7 +14,7 @@ xmrwallet.ops.new: Monero wallet ops for the MMGen Suite
from ...color import red, pink
from ...util import msg, ymsg, make_timestr
from ...obj import TwComment
from ...ui import keypress_confirm
from ..rpc import MoneroWalletRPC
@ -32,7 +32,7 @@ class OpNew(OpMixinSpec, OpWallet):
h.open_wallet('Monero')
desc = 'account' if self.account is None else 'address'
label = (
label = TwComment(
None if self.label == '' else
'{} [{}]'.format(self.label or f'xmrwallet new {desc}', make_timestr()))

View file

@ -173,8 +173,10 @@ class OpWallet(OpBase):
else:
self.addr_data = self.kal.data
async def restart_wallet_daemon(self):
async def restart_wallet_daemon(self, registered=[]):
if not registered:
atexit.register(lambda: asyncio.run(self.stop_wallet_daemon()))
registered.append(None)
await self.c.restart_daemon()
async def stop_wallet_daemon(self):

View file

@ -110,7 +110,8 @@ class MoneroWalletRPC:
msg(' Address: {}'.format(cyan(ret['base_address'])))
return (ret['account_index'], ret['base_address'])
def print_acct_addrs(self, wallet_data, account):
def print_acct_addrs(self, wallet_data, account, silent=False):
if not silent:
msg('\n Addresses of account #{} ({}):'.format(
account,
wallet_data.accts_data['subaddress_accounts'][account]['label']))

View file

@ -49,6 +49,7 @@ cmd_groups_dfl = {
'runeswap': gd('CmdTestRuneSwap', {}),
'xmrwallet': gd('CmdTestXMRWallet', {}),
'xmr_autosign': gd('CmdTestXMRAutosign', {}),
'xmr_compat': gd('CmdTestXMRCompat', {'modname': 'xmr_autosign'}),
}
cmd_groups_extra = {

View file

@ -230,6 +230,8 @@ class CmdTestXMRAutosign(CmdTestXMRWallet, CmdTestAutosignThreaded):
async def fund_alice1(self):
return await self.fund_alice(wallet=1)
fund_alice1b = fund_alice1
async def check_bal_alice1(self):
return await self.check_bal_alice(wallet=1)
@ -492,3 +494,60 @@ class CmdTestXMRAutosignNoCompat(CmdTestXMRAutosign):
Monero autosigning operations (non-xmrwallet compat mode)
"""
compat = False
class CmdTestXMRCompat(CmdTestXMRAutosign):
"""
Monero autosigning operations (compat mode)
"""
menu_prompt = 'efresh balances:\b'
cmd_group = (
('autosign_setup', 'autosign setup with Alice’s seed'),
('autosign_xmr_setup', 'autosign setup (creation of Monero signing wallets)'),
('create_watchonly_wallets', 'creating Alice’s watch-only wallets'),
('gen_kafile_miner', 'generating key-address file for Miner'),
('create_wallet_miner', 'creating Monero wallet for Miner'),
('mine_initial_coins', 'mining initial coins'),
('fund_alice1', 'sending funds to Alice (wallet #1)'),
('mine_blocks', 'mining some blocks'),
('alice_listaddresses', 'performing operations on Alice’s tracking wallets (listaddresses)'),
('fund_alice1b', 'sending funds to Alice (wallet #1)'),
('mine_blocks', 'mining some blocks'),
('alice_twview', 'performing operations on Alice’s tracking wallets (twview)'),
)
def __init__(self, cfg, trunner, cfgs, spawn):
super().__init__(cfg, trunner, cfgs, spawn)
if trunner is None:
return
self.alice_opts = [
'--alice',
'--coin=xmr',
'--monero-wallet-rpc-password=passwOrd',
f'--monero-daemon=localhost:{self.users["alice"].md.rpc_port}']
def create_watchonly_wallets(self):
return self._create_wallets()
async def mine_blocks(self):
self.spawn(msg_only=True)
return await self.mine(10)
def alice_listaddresses(self):
return self._alice_twops('listaddresses', 2, 'y', r'Primary account.*1\.234567891234')
def alice_twview(self):
return self._alice_twops('twview', 1, 'n', r'New Label.*2\.469135782468')
def _alice_twops(self, op, addr_num, add_timestr_resp, expect_str):
self.insert_device_online()
t = self.spawn('mmgen-tool', self.alice_opts + self.autosign_opts + [op, 'interactive=1'])
t.expect(self.menu_prompt, 'l')
t.expect('main menu): ', str(addr_num))
t.expect(': ', 'New Label\n')
t.expect('(y/N): ', add_timestr_resp)
t.expect(self.menu_prompt, 'R')
t.expect(expect_str, regex=True)
t.expect(self.menu_prompt, 'q')
self.remove_device_online()
return t