proto.xmr.tw.unspent: implement account-based view

Example:

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

Testing/demo:

    $ test/cmdtest.py -ne xmr_compat
This commit is contained in:
The MMGen Project 2025-11-24 12:48:48 +00:00
commit 1132d0ff6b
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
8 changed files with 172 additions and 35 deletions

View file

@ -146,6 +146,7 @@ class MMGenID(HiliteStr, InitErrors, MMGenObject):
me = str.__new__(cls, id_str)
idx, ext = idx.split('-', 1)
me.acct_idx, me.addr_idx = [MoneroIdx(e) for e in ext.split('/', 1)]
me.acct_id = f'{sid}:{mmtype}:{idx}:{me.acct_idx}'
else:
ext = None
me = str.__new__(cls, f'{sid}:{mmtype}:{idx}')
@ -154,14 +155,16 @@ class MMGenID(HiliteStr, InitErrors, MMGenObject):
me.idx = AddrIdx(idx)
me.al_id = str.__new__(AddrListID, me.sid + ':' + me.mmtype) # checks already done
if ext:
me.sort_key = '{}:{}:{:0{w1}}:{:0{w2}}:{:0{w2}}'.format(
me.acct_sort_key = '{}:{}:{:0{w1}}:{:0{w2}}'.format(
me.sid,
me.mmtype,
me.idx,
me.acct_idx,
me.addr_idx,
w1 = me.idx.max_digits,
w2 = MoneroIdx.max_digits)
me.sort_key = me.acct_sort_key + ':{:0{w2}}'.format(
me.addr_idx,
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

View file

@ -1 +1 @@
16.1.dev15
16.1.dev16

View file

@ -20,3 +20,12 @@ class MoneroTwAddresses(MoneroTwView, TwAddresses):
include_empty = True
has_used = True
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'}

View file

@ -12,12 +12,122 @@
proto.xmr.tw.unspent: Monero protocol tracking wallet unspent outputs class
"""
from ....tw.addresses import TwAddresses
from collections import namedtuple
from ....tw.unspent import TwUnspentOutputs
from ....addr import MMGenID, MoneroIdx
from ....color import red, green
from .view import MoneroTwView
class MoneroTwUnspentOutputs(MoneroTwView, TwAddresses):
class MoneroTwUnspentOutputs(MoneroTwView, TwUnspentOutputs):
hdr_lbl = 'spendable accounts'
desc = 'address balances'
desc = 'spendable accounts'
item_desc = 'account'
account_based = True
include_empty = False
total = None
nice_addr_w = {'addr': 20}
prompt_fs_repl = {'XMR': (
(1, 'Display options: r[e]draw screen'),
(3, 'Actions: [q]uit menu, add [l]abel, [R]efresh balances:'))}
extra_key_mappings = {
'R': 'a_sync_wallets'}
sort_disp = {
'addr': 'Addr',
'age': 'Age',
'amt': 'Amt',
'twmmid': 'MMGenID'}
sort_funcs = {
'addr': lambda i: '{}:{}'.format(i.twmmid.obj.acct_sort_key, i.addr),
'age': lambda i: i.twmmid.sort_key, # dummy (age sort not supported)
'amt': lambda i: '{}:{:050}'.format(i.twmmid.obj.acct_sort_key, i.amt.to_unit('atomic')),
'twmmid': lambda i: i.twmmid.sort_key}
def gen_data(self, rpc_data, lbl_id):
return (
self.MMGenTwUnspentOutput(
self.proto,
twmmid = twmmid,
addr = data['addr'],
confs = data['confs'],
comment = data['lbl'].comment,
amt = data['amt'])
for twmmid, data in rpc_data.items())
def get_disp_data(self):
ad = namedtuple('accts_data', ['total', 'data'])
bd = namedtuple('accts_data_data', ['disp_data_idx', 'data'])
def gen_accts_data():
acct_id_save, total, d_acc = (None, 0, {})
for n, d in enumerate(self.data):
m = d.twmmid.obj
if acct_id_save != m.acct_id:
if acct_id_save:
yield (acct_id_save, ad(total, d_acc))
acct_id_save = m.acct_id
total = d.amt
d_acc = {m.addr_idx: bd(n, d)}
else:
total += d.amt
d_acc[m.addr_idx] = bd(n, d)
if acct_id_save:
yield (acct_id_save, ad(total, d_acc))
self.accts_data = dict(gen_accts_data())
return super().get_disp_data()
class display_type(TwUnspentOutputs.display_type):
class squeezed(TwUnspentOutputs.display_type.squeezed):
cols = ('addr_idx', 'addr', 'comment', 'amt')
colhdr_fmt_method = None
fmt_method = 'gen_display'
class detail(TwUnspentOutputs.display_type.detail):
cols = ('addr_idx', 'addr', 'amt', 'comment')
colhdr_fmt_method = None
fmt_method = 'gen_display'
line_fmt_method = 'squeezed_format_line'
def get_column_widths(self, data, *, wide, interactive):
return self.compute_column_widths(
widths = { # fixed cols
'addr_idx': MoneroIdx.max_digits,
'amt': self.amt_widths['amt'],
'spc': 4}, # 1 leading space plus 3 spaces between 4 cols
maxws = { # expandable cols
'addr': max(len(d.addr) for d in data),
'comment': max(d.comment.screen_width for d in data)},
minws = {
'addr': 16,
'comment': len('Comment')},
maxws_nice = self.nice_addr_w,
wide = wide,
interactive = interactive)
def gen_display(self, data, cw, fs, color, fmt_method):
yes, no = (red('Yes '), green('No ')) if color else ('Yes ', 'No ')
fs_acct = '{:>4} {} {:%s} {}' % MoneroIdx.max_digits
wallet_wid = 15
yield ' Wallet Account Balance'.ljust(self.term_width)
for n, (k, v) in enumerate(self.accts_data.items()):
mmid, acct_idx = k.rsplit(':', 1)
m = list(v.data.values())[0].data.twmmid.obj
yield fs_acct.format(
str(n + 1) + ')',
MMGenID.hlc(mmid.ljust(wallet_wid), color=color),
m.acct_idx.fmt(MoneroIdx.max_digits, color=color),
v.total.hl(color=color)).ljust(self.term_width)
for v in v.data.values():
yield fmt_method(None, v.data, cw, fs, color, yes, no)
def squeezed_format_line(self, n, d, cw, fs, color, yes, no):
return fs.format(
I = d.twmmid.obj.addr_idx.fmt(cw.addr_idx, color=color),
a = d.addr.fmt(self.addr_view_pref, cw.addr, color=color),
c = d.comment.fmt2(cw.comment, color=color, nullrepl='-'),
A = d.amt.fmt(cw.iwidth, color=color, prec=self.disp_prec))

View file

@ -21,15 +21,6 @@ class MoneroTwView:
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

View file

@ -28,7 +28,7 @@ from ..obj import (
TwComment,
CoinTxID,
NonNegativeInt)
from ..addr import CoinAddr
from ..addr import CoinAddr, MoneroIdx
from ..amt import CoinAmtChk
from .shared import TwMMGenID, TwLabel, get_tw_label
from .view import TwView
@ -166,6 +166,8 @@ class TwUnspentOutputs(TwView):
'amt2': self.amt_widths.get('amt2', 0),
'block': self.age_col_params['block'][0] if wide else 0,
'date_time': self.age_col_params['date_time'][0] if wide else 0,
'addr_idx': MoneroIdx.max_digits,
'acct_idx': MoneroIdx.max_digits,
'date': self.age_w,
'spc': self.disp_spc + (2 * show_mmid) + self.has_amt2},
maxws = { # expandable cols

View file

@ -27,7 +27,7 @@ from ..cfg import gv
from ..objmethods import MMGenObject
from ..obj import get_obj, MMGenIdx, MMGenList
from ..color import nocolor, yellow, orange, green, red, blue
from ..util import msg, msg_r, fmt, die, capfirst, suf, make_timestr, isAsync
from ..util import msg, msg_r, fmt, die, capfirst, suf, make_timestr, isAsync, is_int
from ..rpc import rpc_init
from ..base_obj import AsyncInit
@ -80,6 +80,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
def do(method, data, cw, fs, color, fmt_method):
return [l.rstrip() for l in method(data, cw, fs, color, fmt_method)]
account_based = False
has_wallet = True
has_amt2 = False
dates_set = False
@ -119,6 +120,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
'comment': fp('c', True, False, ' {c:%s}', ' {c}'),
'amt': fp('A', True, False, ' {A:%s}', ' {A}'),
'amt2': fp('B', True, False, ' {B:%s}', ' {B}'),
'addr_idx': fp('I', True, False, ' {I:%s}', ' {I}'),
'date': fp('d', True, True, ' {d:%s}', ' {d:<%s}'),
'date_time': fp('D', True, True, ' {D:%s}', ' {D:%s}'),
'block': fp('b', True, True, ' {b:%s}', ' {b:<%s}'),
@ -700,18 +702,19 @@ class TwView(MMGenObject, metaclass=AsyncInit):
if not parent.disp_data:
return
async def do_error_msg(data):
async def do_error_msg(data, is_addr_idx):
msg_r(
'Choice must be a single number between {n} and {m} inclusive{s}'.format(
n = 1,
m = len(data),
n = list(data.keys())[0] if is_addr_idx else 1,
m = list(data.keys())[-1] if is_addr_idx else len(data),
s = ' ' if parent.scroll else ''))
if parent.scroll:
await asyncio.sleep(1.5)
msg_r(CUR_UP(1) + '\r' + ERASE_ALL)
async def get_idx(desc, data):
async def get_idx(desc, data, *, is_addr_idx=False):
from ..ui import line_input
ur = namedtuple('usr_idx_data', ['idx', 'addr_idx'])
while True:
msg_r(parent.blank_prompt if parent.scroll else '\n')
usr_ret = line_input(
@ -721,13 +724,24 @@ class TwView(MMGenObject, metaclass=AsyncInit):
if parent.scroll:
msg_r(CUR_UP(1) + '\r' + ''.ljust(parent.term_width))
return None
idx = get_obj(MMGenIdx, n=usr_ret, silent=True)
if idx and idx <= len(data):
return idx
await do_error_msg(data)
if is_addr_idx:
if is_int(usr_ret) and int(usr_ret) in data:
return ur(MMGenIdx(data[int(usr_ret)].disp_data_idx + 1), int(usr_ret))
else:
idx = get_obj(MMGenIdx, n=usr_ret, silent=True)
if idx and idx <= len(data):
return ur(idx, None)
await do_error_msg(data, is_addr_idx)
async def get_idx_from_user():
return await get_idx(f'{parent.item_desc} number', parent.disp_data)
if parent.account_based:
if res := await get_idx(f'{parent.item_desc} number', parent.accts_data):
return await get_idx(
'address index',
list(parent.accts_data.values())[res.idx - 1].data,
is_addr_idx = True)
else:
return await get_idx(f'{parent.item_desc} number', parent.disp_data)
while True:
# action_method return values:
@ -736,8 +750,8 @@ class TwView(MMGenObject, metaclass=AsyncInit):
# None: action aborted by user or no action performed
# 'redo': user will be re-prompted for item number
# 'redraw': action successfully performed, screen will be redrawn
if idx := await get_idx_from_user():
ret = await action_method(parent, idx)
if usr_ret := await get_idx_from_user():
ret = await action_method(parent, usr_ret.idx, usr_ret.addr_idx)
else:
ret = None
if ret != 'redo':
@ -750,7 +764,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
CUR_HOME + ERASE_ALL +
await parent.format(display_type='squeezed', interactive=True, scroll=True))
async def i_balance_refresh(self, parent, idx):
async def i_balance_refresh(self, parent, idx, addr_idx=None):
if not parent.keypress_confirm(
f'Refreshing tracking wallet {parent.item_desc} #{idx}. OK?'):
return 'redo'
@ -767,7 +781,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
if res == 0:
return 'redraw' # zeroing balance may mess up display
async def i_addr_delete(self, parent, idx):
async def i_addr_delete(self, parent, idx, addr_idx=None):
if not parent.keypress_confirm(
'Removing {} {} from tracking wallet. OK?'.format(
parent.item_desc, red(f'#{idx}'))):
@ -781,7 +795,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
parent.oneshot_msg = red('Address could not be removed')
return False
async def i_comment_add(self, parent, idx):
async def i_comment_add(self, parent, idx, addr_idx=None):
async def do_comment_add(comment_in):
from ..obj import TwComment
@ -810,8 +824,12 @@ class TwView(MMGenObject, metaclass=AsyncInit):
return False
entry = parent.disp_data[idx-1]
desc = f'{parent.item_desc} #{idx}'
color_desc = f'{parent.item_desc} {red("#" + str(idx))}'
if addr_idx is None:
desc = f'{parent.item_desc} #{idx}'
color_desc = f'{parent.item_desc} {red("#" + str(idx))}'
else:
desc = f'address #{addr_idx}'
color_desc = f'address {red("#" + str(addr_idx))}'
cur_comment = parent.disp_data[idx-1].comment
msg('Current label: {}'.format(cur_comment.hl() if cur_comment else '(none)'))

View file

@ -508,6 +508,8 @@ class CmdTestXMRCompat(CmdTestXMRAutosign):
('gen_kafile_miner', 'generating key-address file for Miner'),
('create_wallet_miner', 'creating Monero wallet for Miner'),
('mine_initial_coins', 'mining initial coins'),
('fund_alice2', 'sending funds to Alice (wallet #2)'),
('check_bal_alice2', 'mining, checking balance (wallet #2)'),
('fund_alice1', 'sending funds to Alice (wallet #1)'),
('mine_blocks', 'mining some blocks'),
('alice_listaddresses', 'performing operations on Alice’s tracking wallets (listaddresses)'),
@ -537,13 +539,15 @@ class CmdTestXMRCompat(CmdTestXMRAutosign):
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')
return self._alice_twops('twview', 1, 'n', r'New Label.*2\.469135782468', addr_idx_num=0)
def _alice_twops(self, op, addr_num, add_timestr_resp, expect_str):
def _alice_twops(self, op, addr_num, add_timestr_resp, expect_str, *, addr_idx_num=None):
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))
if addr_idx_num is not None:
t.expect('main menu): ', str(addr_idx_num))
t.expect(': ', 'New Label\n')
t.expect('(y/N): ', add_timestr_resp)
t.expect(self.menu_prompt, 'R')