From acee3606af18326937b450c0a73413ee30b31e8b Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Sat, 29 Nov 2025 09:12:49 +0000 Subject: [PATCH] proto.xmr.tw.addresses: implement account-based display --- mmgen/proto/xmr/tw/addresses.py | 11 +++ mmgen/proto/xmr/tw/unspent.py | 132 +----------------------------- mmgen/proto/xmr/tw/view.py | 140 +++++++++++++++++++++++++++++++- test/cmdtest_d/xmr_autosign.py | 7 +- 4 files changed, 157 insertions(+), 133 deletions(-) diff --git a/mmgen/proto/xmr/tw/addresses.py b/mmgen/proto/xmr/tw/addresses.py index b453d115..b41f9bc2 100755 --- a/mmgen/proto/xmr/tw/addresses.py +++ b/mmgen/proto/xmr/tw/addresses.py @@ -29,3 +29,14 @@ class MoneroTwAddresses(MoneroTwView, TwAddresses): 'R': 'a_sync_wallets'} removed_key_mappings = { 'D': 'i_addr_delete'} + + class display_type: + + class squeezed(MoneroTwView.display_type.squeezed): + cols = ('addr_idx', 'addr', 'used', 'comment', 'amt') + + class detail(MoneroTwView.display_type.detail): + cols = ('addr_idx', 'addr', 'used', 'amt', 'comment') + + def get_disp_data(self): + return MoneroTwView.get_disp_data(self, input_data=tuple(TwAddresses.get_disp_data(self))) diff --git a/mmgen/proto/xmr/tw/unspent.py b/mmgen/proto/xmr/tw/unspent.py index 31f45db5..105df792 100755 --- a/mmgen/proto/xmr/tw/unspent.py +++ b/mmgen/proto/xmr/tw/unspent.py @@ -12,11 +12,6 @@ proto.xmr.tw.unspent: Monero protocol tracking wallet unspent outputs class """ -from collections import namedtuple - -from ....obj import ImmutableAttr -from ....addr import MoneroIdx -from ....amt import CoinAmtChk from ....tw.unspent import TwUnspentOutputs from .view import MoneroTwView @@ -25,10 +20,8 @@ class MoneroTwUnspentOutputs(MoneroTwView, TwUnspentOutputs): hdr_lbl = 'spendable accounts' desc = 'spendable accounts' - item_desc = 'account' include_empty = False - total = None - nice_addr_w = {'addr': 20} + has_used = False prompt_fs_in = [ 'Sort options: [a]mount, [A]ge, a[d]dr, [M]mgen addr, [r]everse', @@ -38,126 +31,3 @@ class MoneroTwUnspentOutputs(MoneroTwView, TwUnspentOutputs): extra_key_mappings = { 'R': 'a_sync_wallets', 'A': 's_age'} - - sort_disp = { - 'addr': 'Addr', - 'age': 'Age', - 'amt': 'Amt', - 'twmmid': 'MMGenID'} - - # NB: For account-based views, ALL sort keys MUST begin with acct_sort_key! - sort_funcs = { - 'addr': lambda i: '{}:{}'.format(i.twmmid.obj.acct_sort_key, i.addr), - 'age': lambda i: '{}:{:020}'.format(i.twmmid.obj.acct_sort_key, 0 - i.confs), - 'amt': lambda i: '{}:{:050}'.format(i.twmmid.obj.acct_sort_key, i.amt.to_unit('atomic')), - 'twmmid': lambda i: i.twmmid.sort_key} # sort_key begins with acct_sort_key - - class MoneroMMGenTwUnspentOutput(TwUnspentOutputs.MMGenTwUnspentOutput): - valid_attrs = {'amt', 'unlocked_amt', 'comment', 'twmmid', 'addr', 'confs', 'skip'} - unlocked_amt = ImmutableAttr(CoinAmtChk, include_proto=True) - - def gen_data(self, rpc_data, lbl_id): - return ( - self.MoneroMMGenTwUnspentOutput( - self.proto, - twmmid = twmmid, - addr = data['addr'], - confs = data['confs'], - comment = data['lbl'].comment, - amt = data['amt'], - unlocked_amt = data['unlocked_amt']) - for twmmid, data in rpc_data.items()) - - def get_disp_data(self): - chk_fail_msg = 'For account-based views, ALL sort keys MUST begin with acct_sort_key!' - ad = namedtuple('accts_data', ['idx', 'acct_idx', 'total', 'unlocked_total', 'data']) - bd = namedtuple('accts_data_data', ['disp_data_idx', 'data']) - def gen_accts_data(): - idx, acct_idx = (None, None) - total, unlocked_total, d_acc = (0, 0, {}) - chk_acc = [] # check for out-of-order accounts (developer idiot-proofing) - for n, d in enumerate(self.data): - m = d.twmmid.obj - if idx != m.idx or acct_idx != m.acct_idx: - if idx: - yield ad(idx, acct_idx, total, unlocked_total, d_acc) - chk_acc.append((m.idx, m.acct_idx)) - idx = m.idx - acct_idx = m.acct_idx - total = d.amt - unlocked_total = d.unlocked_amt - d_acc = {m.addr_idx: bd(n, d)} - else: - total += d.amt - unlocked_total += d.unlocked_amt - d_acc[m.addr_idx] = bd(n, d) - if idx: - assert len(set(chk_acc)) == len(chk_acc), chk_fail_msg - yield ad(idx, acct_idx, total, unlocked_total, d_acc) - self.accts_data = tuple(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): - fs_acct = '{:>4} {:6} {:7} {}' - # 30 = 4(col1) + 6(col2) + 7(col3) + 8(iwidth) + 1(len('.')) + 4(spc) - rfill = ' ' * (self.term_width - self.proto.coin_amt.max_prec - 30) - yield fs_acct.format('', 'Wallet', 'Account', ' Balance').ljust(self.term_width) - for n, d in enumerate(self.accts_data): - yield fs_acct.format( - str(n + 1) + ')', - d.idx.fmt(6, color=color), - d.acct_idx.fmt(7, color=color), - d.total.fmt2( - 8, # iwidth - color = color, - color_override = None if d.total == d.unlocked_total else 'orange' - )) + rfill - for v in d.data.values(): - yield fmt_method(None, v.data, cw, fs, color, None, None) - - 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.fmt2( - cw.iwidth, - color = color, - color_override = None if d.amt == d.unlocked_amt else 'orange', - prec = self.disp_prec)) - - async def get_idx_from_user(self): - if res := await self.get_idx(f'{self.item_desc} number', self.accts_data): - return await self.get_idx( - 'address index', - self.accts_data[res.idx - 1].data, - is_addr_idx = True) diff --git a/mmgen/proto/xmr/tw/view.py b/mmgen/proto/xmr/tw/view.py index a4dcfa52..b1877b1e 100755 --- a/mmgen/proto/xmr/tw/view.py +++ b/mmgen/proto/xmr/tw/view.py @@ -14,12 +14,39 @@ proto.xmr.tw.view: Monero protocol base class for tracking wallet view classes from collections import namedtuple -from ....xmrwallet import op as xmrwallet_op +from ....obj import ImmutableAttr +from ....color import red, green +from ....addr import MoneroIdx +from ....amt import CoinAmtChk from ....seed import SeedID +from ....xmrwallet import op as xmrwallet_op from ....tw.view import TwView +from ....tw.unspent import TwUnspentOutputs class MoneroTwView: + item_desc = 'account' + nice_addr_w = {'addr': 20} + total = None + + sort_disp = { + 'addr': 'Addr', + 'age': 'Age', + 'amt': 'Amt', + 'twmmid': 'MMGenID'} + + # NB: For account-based views, ALL sort keys MUST begin with acct_sort_key! + sort_funcs = { + 'addr': lambda i: '{}:{}'.format(i.twmmid.obj.acct_sort_key, i.addr), + 'age': lambda i: '{}:{:020}'.format(i.twmmid.obj.acct_sort_key, 0 - i.confs), + 'amt': lambda i: '{}:{:050}'.format(i.twmmid.obj.acct_sort_key, i.amt.to_unit('atomic')), + 'twmmid': lambda i: i.twmmid.sort_key} # sort_key begins with acct_sort_key + + class MoneroTwViewItem(TwUnspentOutputs.MMGenTwUnspentOutput): + valid_attrs = {'amt', 'unlocked_amt', 'comment', 'twmmid', 'addr', 'confs', 'is_used', 'skip'} + unlocked_amt = ImmutableAttr(CoinAmtChk, include_proto=True) + is_used = ImmutableAttr(bool) + class rpc: caps = () is_remote = False @@ -80,6 +107,117 @@ class MoneroTwView: return dict(gen_addrs()) + def gen_data(self, rpc_data, lbl_id): + return ( + self.MoneroTwViewItem( + self.proto, + twmmid = twmmid, + addr = data['addr'], + confs = data['confs'], + is_used = data['is_used'], + comment = data['lbl'].comment, + amt = data['amt'], + unlocked_amt = data['unlocked_amt']) + for twmmid, data in rpc_data.items()) + + def get_disp_data(self, input_data=None): + data = self.data if input_data is None else input_data + chk_fail_msg = 'For account-based views, ALL sort keys MUST begin with acct_sort_key!' + ad = namedtuple('accts_data', ['idx', 'acct_idx', 'total', 'unlocked_total', 'data']) + bd = namedtuple('accts_data_data', ['disp_data_idx', 'data']) + def gen_accts_data(): + idx, acct_idx = (None, None) + total, unlocked_total, d_acc = (0, 0, {}) + chk_acc = [] # check for out-of-order accounts (developer idiot-proofing) + for n, d in enumerate(data): + m = d.twmmid.obj + if idx != m.idx or acct_idx != m.acct_idx: + if idx: + yield ad(idx, acct_idx, total, unlocked_total, d_acc) + idx = m.idx + acct_idx = m.acct_idx + total = d.amt + unlocked_total = d.unlocked_amt + d_acc = {m.addr_idx: bd(n, d)} + chk_acc.append((idx, acct_idx)) + else: + total += d.amt + unlocked_total += d.unlocked_amt + d_acc[m.addr_idx] = bd(n, d) + if idx: + assert len(set(chk_acc)) == len(chk_acc), chk_fail_msg + yield ad(idx, acct_idx, total, unlocked_total, d_acc) + self.accts_data = tuple(gen_accts_data()) + return data + + class 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, + 'used': 4 if 'used' in self.display_type.squeezed.cols else 0, + 'amt': self.amt_widths['amt'], + 'spc': len(self.display_type.squeezed.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('Used'), green('New ')) if color else ('Used', 'New ') + fs_acct = '{:>4} {:6} {:7} {}' + # 30 = 4(col1) + 6(col2) + 7(col3) + 8(iwidth) + 1(len('.')) + 4(spc) + rfill = ' ' * (self.term_width - self.proto.coin_amt.max_prec - 30) + yield fs_acct.format('', 'Wallet', 'Account', ' Balance').ljust(self.term_width) + for n, d in enumerate(self.accts_data): + yield fs_acct.format( + str(n + 1) + ')', + d.idx.fmt(6, color=color), + d.acct_idx.fmt(7, color=color), + d.total.fmt2( + 8, # iwidth + color = color, + color_override = None if d.total == d.unlocked_total else 'orange' + )) + rfill + for v in d.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), + u = yes if d.is_used else no, + c = d.comment.fmt2(cw.comment, color=color, nullrepl='-'), + A = d.amt.fmt2( + cw.iwidth, + color = color, + color_override = None if d.amt == d.unlocked_amt else 'orange', + prec = self.disp_prec)) + + async def get_idx_from_user(self): + if res := await self.get_idx(f'{self.item_desc} number', self.accts_data): + return await self.get_idx( + 'address index', + self.accts_data[res.idx - 1].data, + is_addr_idx = True) + class action(TwView.action): async def a_sync_wallets(self, parent): diff --git a/test/cmdtest_d/xmr_autosign.py b/test/cmdtest_d/xmr_autosign.py index b41f2faa..4c04fa17 100755 --- a/test/cmdtest_d/xmr_autosign.py +++ b/test/cmdtest_d/xmr_autosign.py @@ -529,6 +529,7 @@ class CmdTestXMRCompat(CmdTestXMRAutosign): ('fund_alice_sub3', 'sending funds to Alice’s subaddress #3 (wallet #2)'), ('alice_twview2', 'viewing Alice’s tracking wallets (reload, sort options)'), ('alice_twview3', 'viewing Alice’s tracking wallets (check balances)'), + ('alice_listaddresses2', 'listing Alice’s addresses (sort options)'), ) def __init__(self, cfg, trunner, cfgs, spawn): @@ -585,6 +586,7 @@ class CmdTestXMRCompat(CmdTestXMRAutosign): return self._alice_twops( 'listaddresses', lbl_addr_num = 2, + lbl_addr_idx_num = 0, lbl_add_timestr = True, menu = 'R', expect_str = r'Primary account.*1\.234567891234') @@ -608,9 +610,12 @@ class CmdTestXMRCompat(CmdTestXMRAutosign): 'twview', expect_arr = [ 'Total XMR: 3.722345649021 [3.729999970119]', - '1 0.026296296417', + '1 0.026296296417', '0.007654321098']) + def alice_listaddresses2(self): + return self._alice_twops('listaddresses', menu='aAdMELLuuuraAdMeEuu') + def _alice_twops( self, op,