From be17d06708192fae170e9a91029973491c6727ab Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Thu, 27 Nov 2025 11:34:10 +0000 Subject: [PATCH] proto.xmr.tw.view: add age sort, locked balance display NOTE: Age sort is meaningful only for addresses with 10 or fewer confirmations. --- mmgen/data/version | 2 +- mmgen/proto/xmr/tw/unspent.py | 32 ++++++++++++++++------- mmgen/proto/xmr/tw/view.py | 31 +++++++++++++++++----- mmgen/tw/view.py | 8 +++++- test/cmdtest_d/xmr_autosign.py | 48 ++++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 19 deletions(-) diff --git a/mmgen/data/version b/mmgen/data/version index 238282e6..760b9382 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -16.1.dev16 +16.1.dev17 diff --git a/mmgen/proto/xmr/tw/unspent.py b/mmgen/proto/xmr/tw/unspent.py index fcf6a679..30247039 100755 --- a/mmgen/proto/xmr/tw/unspent.py +++ b/mmgen/proto/xmr/tw/unspent.py @@ -31,12 +31,13 @@ class MoneroTwUnspentOutputs(MoneroTwView, TwUnspentOutputs): nice_addr_w = {'addr': 20} prompt_fs_in = [ - 'Sort options: [a]mount, a[d]dr, [M]mgen addr, [r]everse', + 'Sort options: [a]mount, [A]ge, a[d]dr, [M]mgen addr, [r]everse', 'Display options: r[e]draw screen', 'View/Print: pager [v]iew, [w]ide pager view, [p]rint to file{s}', 'Actions: [q]uit menu, add [l]abel, [R]efresh balances:'] extra_key_mappings = { - 'R': 'a_sync_wallets'} + 'R': 'a_sync_wallets', + 'A': 's_age'} sort_disp = { 'addr': 'Addr', @@ -47,7 +48,7 @@ class MoneroTwUnspentOutputs(MoneroTwView, TwUnspentOutputs): # 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: i.twmmid.sort_key, # dummy (age sort not supported) + '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 @@ -63,32 +64,36 @@ class MoneroTwUnspentOutputs(MoneroTwView, TwUnspentOutputs): addr = data['addr'], confs = data['confs'], comment = data['lbl'].comment, - amt = data['amt']) + 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', 'data']) + 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, total, d_acc = (None, None, 0, {}) + 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, d_acc) + 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, d_acc) + yield ad(idx, acct_idx, total, unlocked_total, d_acc) self.accts_data = tuple(gen_accts_data()) return super().get_disp_data() @@ -129,7 +134,10 @@ class MoneroTwUnspentOutputs(MoneroTwView, TwUnspentOutputs): str(n + 1) + ')', d.idx.fmt(6, color=color), d.acct_idx.fmt(7, color=color), - d.total.hl(color=color)).ljust(self.term_width) + d.total.hl2( + color = color, + color_override = None if d.total == d.unlocked_total else 'orange' + )).ljust(self.term_width) for v in d.data.values(): yield fmt_method(None, v.data, cw, fs, color, None, None) @@ -138,7 +146,11 @@ class MoneroTwUnspentOutputs(MoneroTwView, TwUnspentOutputs): 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)) + 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): diff --git a/mmgen/proto/xmr/tw/view.py b/mmgen/proto/xmr/tw/view.py index 63cd41fc..d8f83fb6 100755 --- a/mmgen/proto/xmr/tw/view.py +++ b/mmgen/proto/xmr/tw/view.py @@ -12,6 +12,8 @@ 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 ....seed import SeedID from ....tw.view import TwView @@ -32,33 +34,48 @@ class MoneroTwView: if wallets_data: self.sid = SeedID(sid=wallets_data[0]['seed_id']) - self.total = self.proto.coin_amt('0') + self.total = self.unlocked_total = self.proto.coin_amt('0') def gen_addrs(): + bd = namedtuple('address_balance_data', ['bal', 'unlocked_bal', 'blocks_to_unlock']) 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']}) + bals_data[d['account_index']].update({ + d['address_index']: bd( + d['balance'], + d['unlocked_balance'], + d['blocks_to_unlock'])}) 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: + addr_bals = bals_data[acct_idx].get(addr_idx) + bal = self.proto.coin_amt( + addr_bals.bal if addr_bals else 0, + from_unit = 'atomic') + unlocked_bal = self.proto.coin_amt( + addr_bals.unlocked_bal if addr_bals else 0, + from_unit = 'atomic') + if bal or self.include_empty: + self.total += bal + self.unlocked_total += unlocked_bal mmid = '{}:M:{}-{}/{}'.format( wdata['seed_id'], wdata['wallet_num'], acct_idx, addr_idx) + btu = addr_bals.blocks_to_unlock if addr_bals else 0 + if not btu and bal != unlocked_bal: + btu = 12 yield (TwMMGenID(self.proto, mmid), { 'addr': addr_data['address'], 'amt': bal, + 'unlocked_amt': unlocked_bal, 'recvd': bal, 'is_used': addr_data['used'], - 'confs': 1, + 'confs': 11 - btu, 'lbl': TwLabel(self.proto, mmid + ' ' + addr_data['label'])}) return dict(gen_addrs()) diff --git a/mmgen/tw/view.py b/mmgen/tw/view.py index cee12d23..50cbad40 100755 --- a/mmgen/tw/view.py +++ b/mmgen/tw/view.py @@ -458,7 +458,13 @@ class TwView(MMGenObject, metaclass=AsyncInit): make_timestr(self.rpc.cur_date)) if hasattr(self, 'total'): - yield 'Total {}: {}'.format(self.proto.dcoin, self.total.hl(color=color)) + if hasattr(self, 'unlocked_total') and self.total != self.unlocked_total: + yield 'Total {}: {} {}'.format( + self.proto.dcoin, + self.unlocked_total.hl(color=color), + self.total.hl3(color_override='orange', encl='[]')) + else: + yield 'Total {}: {}'.format(self.proto.dcoin, self.total.hl(color=color)) yield from getattr(self, dt.subhdr_fmt_method)(cw, color) diff --git a/test/cmdtest_d/xmr_autosign.py b/test/cmdtest_d/xmr_autosign.py index f69f6a65..b41f2faa 100755 --- a/test/cmdtest_d/xmr_autosign.py +++ b/test/cmdtest_d/xmr_autosign.py @@ -518,6 +518,17 @@ class CmdTestXMRCompat(CmdTestXMRAutosign): ('fund_alice1b', 'sending funds to Alice (wallet #1)'), ('mine_blocks_10', 'mining some blocks'), ('alice_twview1', 'adding label to Alice’s tracking wallets (twview)'), + ('new_account_alice', 'adding an account to Alice’s wallet'), + ('new_address_alice', 'adding an address to Alice’s wallet'), + ('new_address_alice_label', 'adding an address to Alice’s wallet (with label)'), + ('alice_dump', 'dumping alice’s wallets to JSON format'), + ('fund_alice_sub1', 'sending funds to Alice’s subaddress #1 (wallet #2)'), + ('mine_blocks_1', 'mining a block'), + ('fund_alice_sub2', 'sending funds to Alice’s subaddress #2 (wallet #2)'), + ('mine_blocks_1', 'mining a block'), + ('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)'), ) def __init__(self, cfg, trunner, cfgs, spawn): @@ -537,6 +548,9 @@ class CmdTestXMRCompat(CmdTestXMRAutosign): def create_watchonly_wallets(self): return self._create_wallets() + async def mine_blocks_1(self): + return await self._mine_blocks(1) + async def mine_blocks_10(self): return await self._mine_blocks(10) @@ -544,6 +558,29 @@ class CmdTestXMRCompat(CmdTestXMRAutosign): self.spawn(msg_only=True) return await self.mine(n) + def _new_addr_alice(self, *args): + return self.new_addr_alice(*args, do_autosign=True) + + async def alice_dump(self): + t = self._xmr_autosign_op('dump') + t.read() + self.remove_device_online() # device was inserted by _xmr_autosign_op() + return t + + async def fund_alice_sub1(self): + return await self._fund_alice(1, 9876543210) + + async def fund_alice_sub2(self): + return await self._fund_alice(2, 8765432109) + + async def fund_alice_sub3(self): + return await self._fund_alice(3, 7654321098) + + async def _fund_alice(self, addr_num, amt): + data = json.loads(read_from_file(self.alice_dump_file)) + addr_data = data['MoneroMMGenWalletDumpFile']['data']['wallet_metadata'][1]['addresses'] + return await self.fund_alice(addr=addr_data[addr_num-1]['address'], amt=amt) + def alice_listaddresses1(self): return self._alice_twops( 'listaddresses', @@ -563,6 +600,17 @@ class CmdTestXMRCompat(CmdTestXMRAutosign): menu = 'R', expect_str = r'New Label.*2\.469135782468') + def alice_twview2(self): + return self._alice_twops('twview', menu='RaAdMraAdMe') + + def alice_twview3(self): + return self._alice_twops( + 'twview', + expect_arr = [ + 'Total XMR: 3.722345649021 [3.729999970119]', + '1 0.026296296417', + '0.007654321098']) + def _alice_twops( self, op,