Browse Source

mmgen-tool listaddresses: fully reimplement

- reimplemented using the new tracking wallet display framework
- command is now interactive, with the same UI as 'twview' and 'txhist'
- the new 'Used' column shows whether an address has received funds
- the new tristate 'showused' filter allows display of only unused, used
  or all used addresses
- adding, removal and editing of labels is supported

Testing/demo:

    # Run the regtest test partially, leaving coin daemon running:
    $ test/test.py -De regtest.label

    # Try out the interactive sorting, filtering and label editing features:
    $ PYTHONPATH=. MMGEN_TEST_SUITE=1 cmds/mmgen-tool --bob listaddresses interactive=1

    # When finished, gracefully shut down the daemon:
    $ test/stop-coin-daemons.py btc_rt
The MMGen Project 2 years ago
parent
commit
1d392f1731

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-13.3.dev16
+13.3.dev17

+ 83 - 0
mmgen/proto/btc/tw/addresses.py

@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
+# Copyright (C)2013-2022 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
+#   https://gitlab.com/mmgen/mmgen
+
+"""
+proto.btc.tw.addresses: Bitcoin base protocol tracking wallet address list class
+"""
+
+from ....tw.addresses import TwAddresses
+from ....tw.common import TwLabel,get_obj
+from ....util import msg,msg_r
+from ....addr import CoinAddr
+from ....obj import NonNegativeInt
+from .common import BitcoinTwCommon
+
+class BitcoinTwAddresses(TwAddresses,BitcoinTwCommon):
+
+	has_age = True
+	prompt = """
+Sort options: [a]mt, [A]ge, [M]mid, [r]everse
+Column options: toggle [D]ays/date/confs/block
+Filters: show [E]mpty addrs, [u]sed addrs, all [L]abels
+View/Print: pager [v]iew, [w]ide view, [p]rint
+Actions: [q]uit, r[e]draw, add [l]abel:
+"""
+	key_mappings = {
+		'a':'s_amt',
+		'A':'s_age',
+		'M':'s_twmmid',
+		'r':'d_reverse',
+		'D':'d_days',
+		'e':'d_redraw',
+		'E':'d_showempty',
+		'u':'d_showused',
+		'L':'d_all_labels',
+		'q':'a_quit',
+		'v':'a_view',
+		'w':'a_view_detail',
+		'p':'a_print_detail',
+		'l':'a_comment_add' }
+
+	squeezed_fs_fs     = ' {{n:>{nw}}} {{m:}} {{u:}}%s {{c:}} {{b:}} {{d:}}'
+	squeezed_hdr_fs_fs = ' {{n:>{nw}}} {{m:{mw}}} {{u:{uw}}}%s {{c:{cw}}} {{b:{bw}}} {{d:}}'
+	wide_fs_fs         = ' {{n:>{nw}}} {{m:}} {{u:}} {{a:}} {{c:}} {{b:}} {{B:<{Bw}}} {{d:}}'
+	wide_hdr_fs_fs     = ' {{n:>{nw}}} {{m:{mw}}} {{u:{uw}}} {{a:{aw}}} {{c:{cw}}} {{b:{bw}}} {{B:{Bw}}} {{d:}}'
+
+	async def get_rpc_data(self):
+
+		msg_r('Getting unspent outputs...')
+		addrs = await self.get_unspent_by_mmid(self.minconf)
+		msg('done')
+
+		amt0 = self.proto.coin_amt('0')
+		self.total = sum((v['amt'] for v in addrs.values()), start=amt0 )
+
+		msg_r('Getting labels and associated addresses...')
+		for label,addr in await self.get_addr_label_pairs():
+			if label and label.mmid not in addrs:
+				addrs[label.mmid] = {
+					'addr':   addr,
+					'amt':    amt0,
+					'recvd':  amt0,
+					'confs':  0,
+					'lbl':    label }
+		msg('done')
+
+		msg_r('Getting received funds data...')
+		# args: 1:minconf, 2:include_empty, 3:include_watchonly, 4:include_immature_coinbase
+		for d in await self.rpc.call( 'listreceivedbylabel', 1, False, True ):
+			label = get_obj( TwLabel, proto=self.proto, text=d['label'] )
+			if label:
+				assert label.mmid in addrs, f'{label.mmid!r} not found in addrlist!'
+				addrs[label.mmid]['recvd'] = d['amount']
+				addrs[label.mmid]['confs'] = d['confirmations']
+		msg('done')
+
+		return addrs

+ 0 - 46
mmgen/proto/btc/tw/addrs.py

@@ -1,46 +0,0 @@
-#!/usr/bin/env python3
-#
-# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
-# Copyright (C)2013-2022 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
-#   https://gitlab.com/mmgen/mmgen
-
-"""
-proto.btc.twaddrs: Bitcoin base protocol tracking wallet address list class
-"""
-
-from ....util import msg,die
-from ....obj import MMGenList
-from ....addr import CoinAddr
-from ....rpc import rpc_init
-from ....tw.addrs import TwAddrList
-from ....tw.common import get_tw_label
-from .common import BitcoinTwCommon
-
-class BitcoinTwAddrList(TwAddrList,BitcoinTwCommon):
-
-	has_age = True
-
-	async def __init__(self,proto,usr_addr_list,minconf,showempty,showcoinaddrs,all_labels,wallet=None):
-
-		self.rpc   = await rpc_init(proto)
-		self.proto = proto
-
-		# get balances with 'listunspent'
-		self.update( await self.get_unspent_by_mmid(minconf,usr_addr_list) )
-		self.total = sum(v['amt'] for v in self.values()) or proto.coin_amt('0')
-
-		# use 'listaccounts' only for empty addresses, as it shows false positive balances
-		if showempty or all_labels:
-			for label,addr in await self.get_addr_label_pairs():
-				if (not label
-					or (all_labels and not showempty and not label.comment)
-					or (usr_addr_list and (label.mmid not in usr_addr_list)) ):
-					continue
-				if label.mmid not in self:
-					self[label.mmid] = { 'amt':proto.coin_amt('0'), 'lbl':label, 'addr':'' }
-					if showcoinaddrs:
-						self[label.mmid]['addr'] = CoinAddr(proto,addr)

+ 5 - 11
mmgen/proto/btc/tw/json.py

@@ -66,20 +66,14 @@ class BitcoinTwJSON(TwJSON):
 		@property
 		@property
 		async def addrlist(self):
 		async def addrlist(self):
 			if not hasattr(self,'_addrlist'):
 			if not hasattr(self,'_addrlist'):
-				from .addrs import TwAddrList
-				self._addrlist = await TwAddrList(
-					proto         = self.proto,
-					usr_addr_list = None,
-					minconf       = 0,
-					showempty     = True,
-					showcoinaddrs = True,
-					all_labels    = False )
+				from .addresses import TwAddresses
+				self._addrlist = await TwAddresses(self.proto,get_data=True)
 			return self._addrlist
 			return self._addrlist
 
 
-		async def get_entries(self):
+		async def get_entries(self): # TODO: include 'received' field
 			return sorted(
 			return sorted(
-				[self.entry_tuple(v['lbl'].mmid, v['addr'], v['amt'], v['lbl'].comment)
-					for v in (await self.addrlist).values()],
+				[self.entry_tuple(d.twmmid.obj, d.addr, d.amt, d.comment)
+					for d in (await self.addrlist).data],
 				key = lambda x: x.mmgen_id.sort_key )
 				key = lambda x: x.mmgen_id.sort_key )
 
 
 		@property
 		@property

+ 68 - 0
mmgen/proto/eth/tw/addresses.py

@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
+# Copyright (C)2013-2022 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
+#   https://gitlab.com/mmgen/mmgen
+
+"""
+proto.eth.tw.addresses: Ethereum base protocol tracking wallet address list class
+"""
+
+from ....tw.addresses import TwAddresses
+from ....tw.ctl import TrackingWallet
+from ....addr import CoinAddr
+from .common import EthereumTwCommon
+
+class EthereumTwAddresses(TwAddresses,EthereumTwCommon):
+
+	has_age = False
+	prompt = """
+Sort options: [a]mt, [M]mid, [r]everse
+Filters: show [E]mpty addrs, all [L]abels
+View/Print: pager [v]iew, [w]ide view, [p]rint
+Actions: [q]uit, r[e]draw, [D]elete address, add [l]abel:
+"""
+	key_mappings = {
+		'a':'s_amt',
+		'M':'s_twmmid',
+		'r':'d_reverse',
+		'e':'d_redraw',
+		'E':'d_showempty',
+		'L':'d_all_labels',
+		'q':'a_quit',
+		'l':'a_comment_add',
+		'D':'a_addr_delete',
+		'v':'a_view',
+		'w':'a_view_detail',
+		'p':'a_print_detail' }
+
+	squeezed_fs_fs     = ' {{n:>{nw}}} {{m:}}%s {{c:}} {{b:}}'
+	squeezed_hdr_fs_fs = ' {{n:>{nw}}} {{m:{mw}}}%s {{c:{cw}}} {{b:{bw}}}'
+	wide_fs_fs         = ' {{n:>{nw}}} {{m:}} {{a:}} {{c:}} {{b:}}'
+	wide_hdr_fs_fs     = ' {{n:>{nw}}} {{m:{mw}}} {{a:{aw}}} {{c:{cw}}} {{b:{bw}}}'
+
+	async def get_rpc_data(self):
+
+		amt0 = self.proto.coin_amt('0')
+		self.total = amt0
+		self.minconf = None
+		addrs = {}
+
+		for label,addr in await self.get_addr_label_pairs():
+			bal = await self.wallet.get_balance(addr)
+			addrs[label.mmid] = {
+				'addr':  addr,
+				'amt':   bal,
+				'recvd': amt0,
+				'confs': 0,
+				'lbl':   label }
+			self.total += bal
+
+		return addrs
+
+class EthereumTokenTwAddresses(EthereumTwAddresses):
+	pass

+ 0 - 59
mmgen/proto/eth/tw/addrs.py

@@ -1,59 +0,0 @@
-#!/usr/bin/env python3
-#
-# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
-# Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""
-proto.eth.twaddrs: Ethereum tracking wallet address list class
-"""
-
-from ....tw.addrs import TwAddrList
-
-class EthereumTwAddrList(TwAddrList):
-
-	has_age = False
-
-	async def __init__(self,proto,usr_addr_list,minconf,showempty,showcoinaddrs,all_labels,wallet=None):
-
-		from ....tw.common import TwLabel
-		from ....tw.ctl import TrackingWallet
-		from ....addr import CoinAddr
-
-		self.proto = proto
-		self.wallet = wallet or await TrackingWallet(self.proto,mode='w')
-		tw_dict = self.wallet.mmid_ordered_dict
-		self.total = self.proto.coin_amt('0')
-
-		for mmid,d in list(tw_dict.items()):
-#			if d['confirmations'] < minconf: continue # cannot get confirmations for eth account
-			label = TwLabel(self.proto,mmid+' '+d['comment'])
-			if usr_addr_list and (label.mmid not in usr_addr_list):
-				continue
-			bal = await self.wallet.get_balance(d['addr'])
-			if bal == 0 and not showempty:
-				if not label.comment or not all_labels:
-					continue
-			self[label.mmid] = {'amt': self.proto.coin_amt('0'), 'lbl':  label }
-			if showcoinaddrs:
-				self[label.mmid]['addr'] = CoinAddr(self.proto,d['addr'])
-			self[label.mmid]['lbl'].mmid.confs = None
-			self[label.mmid]['amt'] += bal
-			self.total += bal
-
-		del self.wallet
-
-class EthereumTokenTwAddrList(EthereumTwAddrList):
-	pass

+ 41 - 52
mmgen/tool/rpc.py

@@ -44,58 +44,6 @@ class tool_cmd(tool_cmd_base):
 		from ..tw.bal import TwGetBalance
 		from ..tw.bal import TwGetBalance
 		return (await TwGetBalance(self.proto,minconf,quiet)).format()
 		return (await TwGetBalance(self.proto,minconf,quiet)).format()
 
 
-	async def listaddress(self,
-			mmgen_addr:str,
-			minconf:      'minimum number of confirmations' = 1,
-			showcoinaddr: 'display coin address in addition to MMGen ID' = True,
-			age_fmt:      'format for the Age/Date column ' + options_annot_str(TwCommon.age_fmts) = 'confs' ):
-		"list the specified MMGen address in the tracking wallet and its balance"
-
-		return await self.listaddresses(
-			mmgen_addrs   = mmgen_addr,
-			minconf       = minconf,
-			showcoinaddrs = showcoinaddr,
-			age_fmt       = age_fmt )
-
-	async def listaddresses(self,
-			mmgen_addrs:  'hyphenated range or comma-separated list of addresses' = '',
-			minconf:      'minimum number of confirmations' = 1,
-			pager:        'send output to pager' = False,
-			showcoinaddrs:'display coin addresses in addition to MMGen IDs' = True,
-			showempty:    'show addresses with no balances' = True,
-			all_labels:   'show all addresses with labels' = False,
-			age_fmt:      'format for the Age/Date column ' + options_annot_str(TwCommon.age_fmts) = 'confs',
-			sort:         'address sort order ' + options_annot_str(['reverse','age']) = '' ):
-		"list MMGen addresses in the tracking wallet and their balances"
-
-		show_age = bool(age_fmt)
-
-		if sort:
-			sort = set(sort.split(','))
-			sort_params = {'reverse','age'}
-			if not sort.issubset( sort_params ):
-				from ..util import die
-				die(1,"The sort option takes the following parameters: '{}'".format( "','".join(sort_params) ))
-
-		usr_addr_list = []
-		if mmgen_addrs:
-			a = mmgen_addrs.rsplit(':',1)
-			if len(a) != 2:
-				from ..util import die
-				die(1,
-					f'{mmgen_addrs}: invalid address list argument ' +
-					'(must be in form <seed ID>:[<type>:]<idx list>)' )
-			from ..addr import MMGenID
-			from ..addrlist import AddrIdxList
-			usr_addr_list = [MMGenID(self.proto,f'{a[0]}:{i}') for i in AddrIdxList(a[1])]
-
-		from ..tw.addrs import TwAddrList
-		al = await TwAddrList( self.proto, usr_addr_list, minconf, showempty, showcoinaddrs, all_labels )
-		if not al:
-			from ..util import die
-			die(0,('No tracked addresses with balances!','No tracked addresses!')[showempty])
-		return await al.format( showcoinaddrs, sort, show_age, age_fmt or 'confs' )
-
 	async def twops(self,
 	async def twops(self,
 			obj,pager,reverse,detail,sort,age_fmt,interactive,
 			obj,pager,reverse,detail,sort,age_fmt,interactive,
 			**kwargs ):
 			**kwargs ):
@@ -148,6 +96,47 @@ class tool_cmd(tool_cmd_base):
 		return await self.twops(
 		return await self.twops(
 			obj,pager,reverse,detail,sort,age_fmt,interactive )
 			obj,pager,reverse,detail,sort,age_fmt,interactive )
 
 
+	async def listaddress(self,
+			mmgen_addr:str,
+			wide:         'display data in wide tabular format' = False,
+			minconf:      'minimum number of confirmations' = 1,
+			showcoinaddr: 'display coin address in addition to MMGen ID' = True,
+			age_fmt:      'format for the Age/Date column ' + options_annot_str(TwCommon.age_fmts) = 'confs' ):
+		"list the specified MMGen address in the tracking wallet and its balance"
+
+		return await self.listaddresses(
+			mmgen_addrs   = mmgen_addr,
+			wide          = wide,
+			minconf       = minconf,
+			showcoinaddrs = showcoinaddr,
+			age_fmt       = age_fmt )
+
+	async def listaddresses(self,
+			pager:        'send output to pager' = False,
+			reverse:      'reverse order of unspent outputs' = False,
+			wide:         'display data in wide tabular format' = False,
+			minconf:      'minimum number of confirmations' = 1,
+			sort:         'address sort order ' + options_annot_str(['reverse','mmid','addr','amt']) = '',
+			age_fmt:      'format for the Age/Date column ' + options_annot_str(TwCommon.age_fmts) = 'confs',
+			interactive:  'enable interactive operation' = False,
+			mmgen_addrs:  'hyphenated range or comma-separated list of addresses' = '',
+			showcoinaddrs:'display coin addresses in addition to MMGen IDs' = True,
+			showempty:    'show addresses with no balances' = True,
+			showused:     'show used addresses (tristate: 0=no, 1=yes, 2=all)' = 1,
+			all_labels:   'show all addresses with labels' = False ):
+		"list MMGen addresses in the tracking wallet and their balances"
+
+		assert showused in (0,1,2), f"‘showused’ must have a value of 0, 1 or 2"
+
+		from ..tw.addresses import TwAddresses
+		obj = await TwAddresses(self.proto,minconf=minconf,mmgen_addrs=mmgen_addrs)
+		return await self.twops(
+			obj,pager,reverse,wide,sort,age_fmt,interactive,
+			showcoinaddrs = showcoinaddrs,
+			showempty     = showempty,
+			showused      = showused,
+			all_labels    = all_labels )
+
 	async def add_label(self,mmgen_or_coin_addr:str,label:str):
 	async def add_label(self,mmgen_or_coin_addr:str,label:str):
 		"add descriptive label for address in tracking wallet"
 		"add descriptive label for address in tracking wallet"
 		from ..tw.ctl import TrackingWallet
 		from ..tw.ctl import TrackingWallet

+ 284 - 0
mmgen/tw/addresses.py

@@ -0,0 +1,284 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
+# Copyright (C)2013-2022 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
+#   https://gitlab.com/mmgen/mmgen
+
+"""
+tw.addresses: Tracking wallet listaddresses class for the MMGen suite
+"""
+
+from collections import namedtuple
+
+from ..util import suf
+from ..base_obj import AsyncInit
+from ..objmethods import MMGenObject
+from ..obj import MMGenList,MMGenListItem,ImmutableAttr,ListItemAttr,TwComment,NonNegativeInt
+from ..rpc import rpc_init
+from ..addr import CoinAddr,MMGenID
+from ..color import red,green
+from .common import TwCommon,TwMMGenID
+
+class TwAddresses(MMGenObject,TwCommon,metaclass=AsyncInit):
+
+	hdr_lbl = 'tracking wallet addresses'
+	desc = 'address list'
+	item_desc = 'address'
+	txid_w = 64
+	sort_key = 'twmmid'
+	age_fmts_interactive = ('confs','block','days','date','date_time')
+	update_widths_on_age_toggle = True
+	print_output_types = ('detail',)
+	filters = ('showempty','showused','all_labels')
+	showcoinaddrs = True
+	showempty = True
+	showused = 1 # tristate: 0:no, 1:yes, 2:all
+	all_labels = False
+	no_data_errmsg = 'No addresses in tracking wallet!'
+
+	class TwAddress(MMGenListItem):
+		valid_attrs = {'twmmid','addr','al_id','confs','comment','amt','recvd','date','skip'}
+		invalid_attrs = {'proto'}
+
+		twmmid  = ImmutableAttr(TwMMGenID,include_proto=True) # contains confs,txid(unused),date(unused),al_id
+		addr    = ImmutableAttr(CoinAddr,include_proto=True)
+		al_id   = ImmutableAttr(str)                          # set to '_' for non-MMGen addresses
+		confs   = ImmutableAttr(int,typeconv=False)
+		comment = ListItemAttr(TwComment,reassign_ok=True)
+		amt     = ImmutableAttr(None)
+		recvd   = ImmutableAttr(None)
+		date    = ListItemAttr(int,typeconv=False,reassign_ok=True)
+		skip    = ListItemAttr(str,typeconv=False,reassign_ok=True)
+
+		def __init__(self,proto,**kwargs):
+			self.__dict__['proto'] = proto
+			MMGenListItem.__init__(self,**kwargs)
+
+		class conv_funcs:
+			def amt(self,value):
+				return self.proto.coin_amt(value)
+			def recvd(self,value):
+				return self.proto.coin_amt(value)
+
+	@property
+	def coinaddr_list(self):
+		return [d.addr for d in self.data]
+
+	def __new__(cls,proto,*args,**kwargs):
+		return MMGenObject.__new__(proto.base_proto_subclass(cls,'tw','addresses'))
+
+	async def __init__(self,proto,minconf=1,mmgen_addrs='',wallet=None,get_data=False):
+
+		self.proto         = proto
+		self.minconf       = NonNegativeInt(minconf)
+		self.usr_addr_list = []
+		self.rpc           = await rpc_init(proto)
+
+		from .ctl import TrackingWallet
+		self.wallet = wallet or await TrackingWallet(proto,mode='w')
+
+		if mmgen_addrs:
+			a = mmgen_addrs.rsplit(':',1)
+			if len(a) != 2:
+				from ..util import die
+				die(1,
+					f'{mmgen_addrs}: invalid address list argument ' +
+					'(must be in form <seed ID>:[<type>:]<idx list>)' )
+			from ..addrlist import AddrIdxList
+			self.usr_addr_list = [MMGenID(self.proto,f'{a[0]}:{i}') for i in AddrIdxList(a[1])]
+
+		if get_data:
+			await self.get_data()
+
+	@property
+	def no_rpcdata_errmsg(self):
+		return 'No addresses {}found!'.format(
+			f'with {self.minconf} confirmations ' if self.minconf else '')
+
+	async def gen_data(self,rpc_data,lbl_id):
+		return (
+			self.TwAddress(
+					self.proto,
+					twmmid  = twmmid,
+					addr    = data['addr'],
+					al_id   = getattr(twmmid.obj,'al_id','_'),
+					confs   = data['confs'],
+					comment = data['lbl'].comment,
+					amt     = data['amt'],
+					recvd   = data['recvd'],
+					date    = 0,
+					skip    = '' )
+				for twmmid,data in rpc_data.items()
+		)
+
+	def filter_data(self):
+		if self.usr_addr_list:
+			return (d for d in self.data if d.twmmid.obj in self.usr_addr_list)
+		else:
+			return (d for d in self.data if
+				(self.all_labels and d.comment) or
+				(self.showused == 2 and d.recvd) or
+				(not (d.recvd and not self.showused) and (d.amt or self.showempty))
+			)
+
+	def get_column_widths(self,data,wide=False):
+
+		return self.compute_column_widths(
+			widths = { # fixed cols
+				'num':  max(2,len(str(len(data)))+1),
+				'mmid': max(len(d.twmmid.disp) for d in data),
+				'used': 4,
+				'amt':  self.disp_prec + 5,
+				'date': self.age_w,
+				'spc':  7, # 6 spaces between cols + 1 leading space in fs
+			},
+			maxws = { # expandable cols
+				'addr':    max(len(d.addr) for d in data),
+				'comment': max(d.comment.screen_width for d in data),
+			},
+			minws = {
+				'addr': 12,
+				'comment': len('Comment'),
+			},
+			maxws_nice = {'addr': 18},
+			wide = wide,
+		)
+
+	def subheader(self,color):
+		if self.minconf:
+			return f'Displaying balances with at least {self.minconf} confirmation{suf(self.minconf)}\n'
+		else:
+			return ''
+
+	def gen_squeezed_display(self,data,cw,color):
+
+		fs_parms = {
+			'nw': cw.num,
+			'mw': cw.mmid,
+			'uw': cw.used,
+			'aw': cw.addr,
+			'cw': cw.comment,
+			'bw': cw.amt,
+			'dw': cw.date
+		}
+
+		hdr_fs = (self.squeezed_hdr_fs_fs % ('',' {{a:{aw}}}')[self.showcoinaddrs]).format(**fs_parms)
+		fs = (self.squeezed_fs_fs % ('',' {{a:}}')[self.showcoinaddrs]).format(**fs_parms)
+
+		yield hdr_fs.format(
+			n  = '',
+			m  = 'MMGenID',
+			u  = 'Used',
+			a  = 'Address',
+			c  = 'Comment',
+			b  = 'Balance',
+			d  = self.age_hdr )
+
+		yes,no = (red('Yes '),green('No  ')) if color else ('Yes ','No  ')
+		id_save = data[0].al_id
+
+		for n,d in enumerate(data,1):
+			if id_save != d.al_id:
+				id_save = d.al_id
+				yield ''
+			yield fs.format(
+				n = str(n) + ')',
+				m = MMGenID.fmtc(d.twmmid.disp,width=cw.mmid,color=True),
+				u = yes if d.recvd else no,
+				a = d.addr.fmt(color=True,width=cw.addr),
+				c = d.comment.fmt(width=cw.comment,color=True,nullrepl='-'),
+				b = d.amt.fmt(color=True),
+				d = self.age_disp( d, self.age_fmt )
+			)
+
+	def gen_detail_display(self,data,cw,color):
+
+		fs_parms = {
+			'nw': cw.num,
+			'mw': cw.mmid,
+			'uw': cw.used,
+			'aw': cw.addr,
+			'cw': cw.comment,
+			'bw': cw.amt,
+			'Bw': self.age_col_params['block'][0],
+			'dw': self.age_col_params['date_time'][0],
+		}
+
+		hdr_fs = self.wide_hdr_fs_fs.format(**fs_parms)
+		fs = self.wide_fs_fs.format(**fs_parms)
+
+		yield hdr_fs.format(
+			n  = '',
+			m  = 'MMGenID',
+			u  = 'Used',
+			a  = 'Address',
+			c  = 'Comment',
+			b  = 'Balance',
+			B  = 'Block',
+			d  = 'Date' )
+
+		yes,no = (red('Yes '),green('No  ')) if color else ('Yes ','No  ')
+		id_save = data[0].al_id
+
+		for n,d in enumerate(data,1):
+			if id_save != d.al_id:
+				id_save = d.al_id
+				yield ''
+			yield fs.format(
+				n = str(n) + ')',
+				m = MMGenID.fmtc(d.twmmid.disp,width=fs_parms['mw'],color=color),
+				u = yes if d.recvd else no,
+				a = d.addr.fmt(color=color,width=fs_parms['aw']),
+				c = d.comment.fmt(width=fs_parms['cw'],color=color,nullrepl='-'),
+				b = d.amt.fmt(color=color),
+				B = self.age_disp( d, 'block' ),
+				d = self.age_disp( d, 'date_time' ),
+			)
+
+	async def set_dates(self,addrs):
+		if not self.dates_set:
+			bc = self.rpc.blockcount + 1
+			caddrs = [addr for addr in addrs if addr.confs]
+			hashes = await self.rpc.gathered_call('getblockhash',[(n,) for n in [bc - a.confs for a in caddrs]])
+			dates = [d['time'] for d in await self.rpc.gathered_call('getblockheader',[(h,) for h in hashes])]
+			for idx,addr in enumerate(caddrs):
+				addr.date = dates[idx]
+			self.dates_set = True
+
+	sort_disp = {
+		'age': 'AddrListID+Age',
+		'amt': 'AddrListID+Amt',
+		'twmmid': 'MMGenID',
+	}
+
+	sort_funcs = {
+		'age': lambda d: '{}_{}_{}'.format(
+			d.al_id,
+			# Hack, but OK for the foreseeable future:
+			('{:>012}'.format(1_000_000_000 - d.confs) if d.confs else '_'),
+			d.twmmid.sort_key),
+		'amt': lambda d: '{}_{}'.format(d.al_id,d.amt),
+		'twmmid': lambda d: d.twmmid.sort_key,
+	}
+
+	@property
+	def dump_fn_pfx(self):
+		return 'listaddresses' + (f'-minconf-{self.minconf}' if self.minconf else '')
+
+	class action(TwCommon.action):
+
+		def s_amt(self,parent):
+			parent.do_sort('amt')
+
+		def d_showempty(self,parent):
+			parent.showempty = not parent.showempty
+
+		def d_showused(self,parent):
+			parent.showused = (parent.showused + 1) % 3
+
+		def d_all_labels(self,parent):
+			parent.all_labels = not parent.all_labels

+ 0 - 106
mmgen/tw/addrs.py

@@ -1,106 +0,0 @@
-#!/usr/bin/env python3
-#
-# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
-# Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""
-twaddrs: Tracking wallet listaddresses class for the MMGen suite
-"""
-
-from ..color import green
-from ..util import msg,die
-from ..base_obj import AsyncInit
-from ..obj import MMGenDict,TwComment
-from ..addr import CoinAddr,MMGenID
-from .common import TwCommon
-
-class TwAddrList(MMGenDict,TwCommon,metaclass=AsyncInit):
-
-	def __new__(cls,proto,*args,**kwargs):
-		return MMGenDict.__new__(proto.base_proto_subclass(cls,'tw','addrs'),*args,**kwargs)
-
-	def raw_list(self):
-		return [((k if k.type == 'mmgen' else 'Non-MMGen'),self[k]['addr'],self[k]['amt']) for k in self]
-
-	def coinaddr_list(self):
-		return [self[k]['addr'] for k in self]
-
-	async def format(self,showcoinaddrs,sort,show_age,age_fmt):
-		if not self.has_age:
-			show_age = False
-		if age_fmt not in self.age_fmts:
-			die( 'BadAgeFormat', f'{age_fmt!r}: invalid age format (must be one of {self.age_fmts!r})' )
-		fs = '{mid}' + ('',' {addr}')[showcoinaddrs] + ' {cmt} {amt}' + ('',' {age}')[show_age]
-		mmaddrs = [k for k in self.keys() if k.type == 'mmgen']
-		max_mmid_len = max(len(k) for k in mmaddrs) + 2 if mmaddrs else 10
-		max_cmt_width = max(max(v['lbl'].comment.screen_width for v in self.values()),7)
-		addr_width = max(len(self[mmid]['addr']) for mmid in self)
-
-		max_fp_len = max([len(a.split('.')[1]) for a in [str(v['amt']) for v in self.values()] if '.' in a] or [1])
-
-		def sort_algo(j):
-			if sort and 'age' in sort:
-				return '{}_{:>012}_{}'.format(
-					j.obj.rsplit(':',1)[0],
-					# Hack, but OK for the foreseeable future:
-					(1000000000-(j.confs or 0) if hasattr(j,'confs') else 0),
-					j.sort_key)
-			else:
-				return j.sort_key
-
-		mmids = sorted(self,key=sort_algo,reverse=bool(sort and 'reverse' in sort))
-		if show_age:
-			await self.set_dates( [o for o in mmids if hasattr(o,'confs')] )
-
-		def gen_output():
-
-			if self.proto.chain_name != 'mainnet':
-				yield 'Chain: '+green(self.proto.chain_name.upper())
-
-			yield fs.format(
-					mid=MMGenID.fmtc('MMGenID',width=max_mmid_len),
-					addr=(CoinAddr.fmtc('ADDRESS',width=addr_width) if showcoinaddrs else None),
-					cmt=TwComment.fmtc('COMMENT',width=max_cmt_width+1),
-					amt='BALANCE'.ljust(max_fp_len+4),
-					age=age_fmt.upper(),
-				).rstrip()
-
-			al_id_save = None
-			for mmid in mmids:
-				if mmid.type == 'mmgen':
-					if al_id_save and al_id_save != mmid.obj.al_id:
-						yield ''
-					al_id_save = mmid.obj.al_id
-					mmid_disp = mmid
-				else:
-					if al_id_save:
-						yield ''
-						al_id_save = None
-					mmid_disp = 'Non-MMGen'
-				e = self[mmid]
-				yield fs.format(
-					mid=MMGenID.fmtc(mmid_disp,width=max_mmid_len,color=True),
-					addr=(e['addr'].fmt(color=True,width=addr_width) if showcoinaddrs else None),
-					cmt=e['lbl'].comment.fmt(width=max_cmt_width,color=True,nullrepl='-'),
-					amt=e['amt'].fmt('4.{}'.format(max(max_fp_len,3)),color=True),
-					age=self.age_disp(mmid,age_fmt) if show_age and hasattr(mmid,'confs') else '-'
-					).rstrip()
-
-			yield '\nTOTAL: {} {}'.format(
-				self.total.hl(color=True),
-				self.proto.dcoin )
-
-		return '\n'.join(gen_output())

+ 3 - 3
mmgen/tw/common.py

@@ -30,7 +30,7 @@ from ..color import nocolor,yellow,green,red,blue
 from ..util import msg,msg_r,fmt,die,capfirst,make_timestr
 from ..util import msg,msg_r,fmt,die,capfirst,make_timestr
 from ..addr import MMGenID
 from ..addr import MMGenID
 
 
-# mixin class for TwUnspentOutputs,TwAddrList,TwTxHistory:
+# mixin class for TwUnspentOutputs,TwAddresses,TwTxHistory:
 class TwCommon:
 class TwCommon:
 
 
 	dates_set   = False
 	dates_set   = False
@@ -250,10 +250,10 @@ class TwCommon:
 	def header(self,color):
 	def header(self,color):
 
 
 		Blue,Green = (blue,green) if color else (nocolor,nocolor)
 		Blue,Green = (blue,green) if color else (nocolor,nocolor)
-		Yes,No = (green('yes'),red('no')) if color else ('yes','no')
+		Yes,No,All = (green('yes'),red('no'),yellow('all')) if color else ('yes','no','all')
 
 
 		def fmt_filter(k):
 		def fmt_filter(k):
-			return '{}:{}'.format(k,{0:No,1:Yes}[getattr(self,k)])
+			return '{}:{}'.format(k,{0:No,1:Yes,2:All}[getattr(self,k)])
 
 
 		return '{h} (sort order: {s}){f}\nNetwork: {n}\nBlock {b} [{d}]\n{t}'.format(
 		return '{h} (sort order: {s}){f}\nNetwork: {n}\nBlock {b} [{d}]\n{t}'.format(
 			h = self.hdr_lbl.upper(),
 			h = self.hdr_lbl.upper(),

+ 7 - 7
test/test_py_d/ts_ethdev.py

@@ -858,13 +858,13 @@ class TestSuiteEthdev(TestSuiteBase,TestSuiteShared):
 
 
 	def chk_comment(self,comment_pat,addr='98831F3A:E:3'):
 	def chk_comment(self,comment_pat,addr='98831F3A:E:3'):
 		t = self.spawn('mmgen-tool', self.eth_args + ['listaddresses','all_labels=1'])
 		t = self.spawn('mmgen-tool', self.eth_args + ['listaddresses','all_labels=1'])
-		t.expect(fr'{addr}\b.*\S{{30}}\b.*{comment_pat}\b',regex=True)
+		t.expect(fr'{addr}\b.*{comment_pat}',regex=True)
 		return t
 		return t
 
 
 	def add_comment1(self): return self.add_comment(comment=tw_comment_zh)
 	def add_comment1(self): return self.add_comment(comment=tw_comment_zh)
-	def chk_comment1(self): return self.chk_comment(comment_pat=tw_comment_zh)
+	def chk_comment1(self): return self.chk_comment(comment_pat=tw_comment_zh[:3])
 	def add_comment2(self): return self.add_comment(comment=tw_comment_lat_cyr_gr)
 	def add_comment2(self): return self.add_comment(comment=tw_comment_lat_cyr_gr)
-	def chk_comment2(self): return self.chk_comment(comment_pat=tw_comment_lat_cyr_gr)
+	def chk_comment2(self): return self.chk_comment(comment_pat=tw_comment_lat_cyr_gr[:3])
 
 
 	def remove_comment(self,addr='98831F3A:E:3'):
 	def remove_comment(self,addr='98831F3A:E:3'):
 		t = self.spawn('mmgen-tool', self.eth_args + ['remove_label',addr])
 		t = self.spawn('mmgen-tool', self.eth_args + ['remove_label',addr])
@@ -1161,18 +1161,18 @@ class TestSuiteEthdev(TestSuiteBase,TestSuiteShared):
 	def listaddresses2(self):
 	def listaddresses2(self):
 		return self.listaddresses(tool_args=['minconf=999999999'])
 		return self.listaddresses(tool_args=['minconf=999999999'])
 	def listaddresses3(self):
 	def listaddresses3(self):
-		return self.listaddresses(tool_args=['sort=age'])
+		return self.listaddresses(tool_args=['sort=amt','reverse=1'])
 	def listaddresses4(self):
 	def listaddresses4(self):
-		return self.listaddresses(tool_args=['sort=age','showempty=1'])
+		return self.listaddresses(tool_args=['sort=age','showempty=0'])
 
 
 	def token_listaddresses1(self):
 	def token_listaddresses1(self):
 		return self.listaddresses(args=['--token=mm1'])
 		return self.listaddresses(args=['--token=mm1'])
 	def token_listaddresses2(self):
 	def token_listaddresses2(self):
 		return self.listaddresses(args=['--token=mm1'],tool_args=['showempty=1'])
 		return self.listaddresses(args=['--token=mm1'],tool_args=['showempty=1'])
 	def token_listaddresses3(self):
 	def token_listaddresses3(self):
-		return self.listaddresses(args=['--token=mm1'],tool_args=['showempty=1'])
+		return self.listaddresses(args=['--token=mm1'],tool_args=['showempty=0'])
 	def token_listaddresses4(self):
 	def token_listaddresses4(self):
-		return self.listaddresses(args=['--token=mm2'],tool_args=['showempty=1'])
+		return self.listaddresses(args=['--token=mm2'],tool_args=['sort=age','reverse=1'])
 
 
 	def twview_cached_balances(self):
 	def twview_cached_balances(self):
 		return self.twview(args=['--cached-balances'])
 		return self.twview(args=['--cached-balances'])

+ 15 - 13
test/test_py_d/ts_regtest.py

@@ -604,10 +604,10 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 		return self.user_bal('bob',rtBals[0],args=['minconf=2'],skip_check=True)
 		return self.user_bal('bob',rtBals[0],args=['minconf=2'],skip_check=True)
 
 
 	def bob_bal2e(self):
 	def bob_bal2e(self):
-		return self.user_bal('bob',rtBals[0],args=['showempty=1','sort=age'])
+		return self.user_bal('bob',rtBals[0],args=['showempty=1','sort=amt'])
 
 
 	def bob_bal2f(self):
 	def bob_bal2f(self):
-		return self.user_bal('bob',rtBals[0],args=['showempty=1','sort=age,reverse'])
+		return self.user_bal('bob',rtBals[0],args=['showempty=0','sort=twmmid','reverse=1'])
 
 
 	def bob_bal3(self):
 	def bob_bal3(self):
 		return self.user_bal('bob',rtBals[1])
 		return self.user_bal('bob',rtBals[1])
@@ -1131,16 +1131,18 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 		sid = self._user_sid('alice')
 		sid = self._user_sid('alice')
 		return self.user_add_comment('alice',sid+':C:1','Replacement Label')
 		return self.user_add_comment('alice',sid+':C:1','Replacement Label')
 
 
-	def _user_chk_comment(self,user,addr,comment):
-		t = self.spawn('mmgen-tool',['--'+user,'listaddresses','all_labels=1'])
-		ret = strip_ansi_escapes(t.expect_getend(addr)).strip().split(None,1)[1]
-		cmp_or_die(ret[:len(comment)],comment)
+	def _user_chk_comment(self,user,addr,comment,extra_args=[]):
+		t = self.spawn('mmgen-tool',['--'+user,'listaddresses','all_labels=1']+extra_args)
+		ret = strip_ansi_escapes(t.expect_getend(addr)).strip().split(None,2)[2]
+		cmp_or_die( # squeezed display, double-width chars, so truncate to min field width
+			ret[:3].strip(),
+			comment[:3].strip())
 		return t
 		return t
 
 
 	def alice_add_comment_coinaddr(self):
 	def alice_add_comment_coinaddr(self):
 		mmid = self._user_sid('alice') + (':S:1',':L:1')[self.proto.coin=='BCH']
 		mmid = self._user_sid('alice') + (':S:1',':L:1')[self.proto.coin=='BCH']
-		t = self.spawn('mmgen-tool',['--alice','listaddress',mmid],no_msg=True)
-		addr = [i for i in strip_ansi_escapes(t.read()).splitlines() if i.startswith(mmid)][0].split()[1]
+		t = self.spawn('mmgen-tool',['--alice','listaddress',mmid,'wide=true'],no_msg=True)
+		addr = [i for i in strip_ansi_escapes(t.read()).splitlines() if re.search(rf'\b{mmid}\b',i)][0].split()[3]
 		return self.user_add_comment('alice',addr,'Label added using coin address of MMGen address')
 		return self.user_add_comment('alice',addr,'Label added using coin address of MMGen address')
 
 
 	def alice_chk_comment_coinaddr(self):
 	def alice_chk_comment_coinaddr(self):
@@ -1182,7 +1184,7 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 
 
 	def alice_chk_comment2(self):
 	def alice_chk_comment2(self):
 		sid = self._user_sid('alice')
 		sid = self._user_sid('alice')
-		return self._user_chk_comment('alice',sid+':C:1','Replacement Label')
+		return self._user_chk_comment('alice',sid+':C:1','Replacement Label',extra_args=['age_fmt=block'])
 
 
 	def alice_edit_comment1(self): return self.user_edit_comment('alice','4',tw_comment_lat_cyr_gr)
 	def alice_edit_comment1(self): return self.user_edit_comment('alice','4',tw_comment_lat_cyr_gr)
 	def alice_edit_comment2(self): return self.user_edit_comment('alice','3',tw_comment_zh)
 	def alice_edit_comment2(self): return self.user_edit_comment('alice','3',tw_comment_zh)
@@ -1190,12 +1192,12 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 	def alice_chk_comment3(self):
 	def alice_chk_comment3(self):
 		sid = self._user_sid('alice')
 		sid = self._user_sid('alice')
 		mmid = sid + (':S:3',':L:3')[self.proto.coin=='BCH']
 		mmid = sid + (':S:3',':L:3')[self.proto.coin=='BCH']
-		return self._user_chk_comment('alice',mmid,tw_comment_lat_cyr_gr)
+		return self._user_chk_comment('alice',mmid,tw_comment_lat_cyr_gr,extra_args=['age_fmt=date'])
 
 
 	def alice_chk_comment4(self):
 	def alice_chk_comment4(self):
 		sid = self._user_sid('alice')
 		sid = self._user_sid('alice')
 		mmid = sid + (':S:3',':L:3')[self.proto.coin=='BCH']
 		mmid = sid + (':S:3',':L:3')[self.proto.coin=='BCH']
-		return self._user_chk_comment('alice',mmid,'-')
+		return self._user_chk_comment('alice',mmid,'-',extra_args=['age_fmt=date_time'])
 
 
 	def user_edit_comment(self,user,output,comment):
 	def user_edit_comment(self,user,output,comment):
 		t = self.spawn('mmgen-txcreate',['-B','--'+user,'-i'])
 		t = self.spawn('mmgen-txcreate',['-B','--'+user,'-i'])
@@ -1368,10 +1370,10 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 	def bob_msgverify_export_single(self):
 	def bob_msgverify_export_single(self):
 		sid = self._user_sid('bob')
 		sid = self._user_sid('bob')
 		mmid = f'{sid}:{self.dfl_mmtype}:1'
 		mmid = f'{sid}:{self.dfl_mmtype}:1'
-		args = [ '--bob', '--color=0', 'listaddress', mmid ]
+		args = [ '--bob', '--color=0', 'listaddress', mmid, 'wide=true' ]
 		imsg(f'Running mmgen-tool {fmt_list(args,fmt="bare")}')
 		imsg(f'Running mmgen-tool {fmt_list(args,fmt="bare")}')
 		t = self.spawn('mmgen-tool', args, no_msg=True)
 		t = self.spawn('mmgen-tool', args, no_msg=True)
-		addr = t.expect_getend(mmid).split()[0]
+		addr = t.expect_getend(mmid).split()[1]
 		t.close()
 		t.close()
 		return self.bob_msgverify(
 		return self.bob_msgverify(
 			addr = addr,
 			addr = addr,