Browse Source

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

Example:

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

Testing/demo:

    $ test/cmdtest.py -ne xmr_compat
The MMGen Project 1 week ago
parent
commit
1132d0ff6b

+ 5 - 2
mmgen/addr.py

@@ -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

+ 1 - 1
mmgen/data/version

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

+ 9 - 0
mmgen/proto/xmr/tw/addresses.py

@@ -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'}

+ 113 - 3
mmgen/proto/xmr/tw/unspent.py

@@ -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))

+ 0 - 9
mmgen/proto/xmr/tw/view.py

@@ -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
 

+ 3 - 1
mmgen/tw/unspent.py

@@ -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

+ 35 - 17
mmgen/tw/view.py

@@ -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)'))

+ 6 - 2
test/cmdtest_d/xmr_autosign.py

@@ -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')