Browse Source

mmgen.tw.{ctl,common,txhistory,unspent}: cleanups

The MMGen Project 2 years ago
parent
commit
b21864fd08

+ 5 - 1
mmgen/addrdata.py

@@ -97,4 +97,8 @@ class TwAddrData(AddrData,metaclass=AsyncInit):
 		vmsg(f'{i} {g.proj_name} addresses found, {len(twd)} accounts total')
 
 		for al_id in out:
-			self.add(AddrList(self.proto,al_id=al_id,adata=AddrListData(sorted(out[al_id],key=lambda a: a.idx))))
+			self.add(AddrList(
+				self.proto,
+				al_id = al_id,
+				adata = AddrListData(sorted( out[al_id], key=lambda a: a.idx ))
+			))

+ 2 - 2
mmgen/obj.py

@@ -46,8 +46,8 @@ def get_obj(objname,*args,**kwargs):
 		ret = objname(**kwargs)
 	except Exception as e:
 		if not silent:
-			from .util import msg
-			msg(f'{e!s}')
+			from .util import rmsg
+			rmsg(f'{e!s}')
 		return False
 	else:
 		return True if return_bool else ret

+ 2 - 1
mmgen/proto/btc/tw/bal.py

@@ -22,13 +22,14 @@ class BitcoinTwGetBalance(TwGetBalance):
 	async def create_data(self):
 		# 0: unconfirmed, 1: below minconf, 2: confirmed, 3: spendable (privkey in wallet)
 		lbl_id = ('account','label')['label_api' in self.rpc.caps]
+		amt0 = self.proto.coin_amt('0')
 		for d in await self.rpc.call('listunspent',0):
 			lbl = get_tw_label(self.proto,d[lbl_id])
 			if lbl:
 				if lbl.mmid.type == 'mmgen':
 					key = lbl.mmid.obj.sid
 					if key not in self.data:
-						self.data[key] = [self.proto.coin_amt('0')] * 4
+						self.data[key] = [amt0] * 4
 				else:
 					key = 'Non-MMGen'
 			else:

+ 4 - 3
mmgen/proto/btc/tw/common.py

@@ -9,7 +9,7 @@
 #   https://gitlab.com/mmgen/mmgen
 
 """
-proto.btc.tw: Bitcoin base protocol tracking wallet dependency classes
+proto.btc.tw.common: Bitcoin base protocol tracking wallet dependency classes
 """
 
 from ....addr import CoinAddr
@@ -26,7 +26,7 @@ class BitcoinTwCommon:
 		"""
 		def check_dup_mmid(acct_labels):
 			mmid_prev,err = None,False
-			for mmid in sorted(a.mmid for a in acct_labels if a):
+			for mmid in sorted(label.mmid for label in acct_labels if label):
 				if mmid == mmid_prev:
 					err = True
 					msg(f'Duplicate MMGen ID ({mmid}) discovered in tracking wallet!\n')
@@ -72,6 +72,7 @@ class BitcoinTwCommon:
 		"""
 		data = {}
 		lbl_id = ('account','label')['label_api' in self.rpc.caps]
+		amt0 = self.proto.coin_amt('0')
 
 		for d in await self.rpc.call('listunspent',0):
 
@@ -99,7 +100,7 @@ class BitcoinTwCommon:
 					lm.vout = d['vout']
 					lm.date = None
 					data[lm] = {
-						'amt': self.proto.coin_amt('0'),
+						'amt': amt0,
 						'lbl': label,
 						'addr': CoinAddr(self.proto,d['address']) }
 				amt = self.proto.coin_amt(d['amount'])

+ 4 - 3
mmgen/proto/btc/tw/ctl.py

@@ -40,10 +40,11 @@ class BitcoinTrackingWallet(TrackingWallet):
 		raise NotImplementedError(f'address removal not implemented for coin {self.proto.coin}')
 
 	@write_mode
-	async def set_comment(self,coinaddr,lbl):
+	async def set_label(self,coinaddr,lbl):
 		args = self.rpc.daemon.set_comment_args( self.rpc, coinaddr, lbl )
 		try:
-			return await self.rpc.call(*args)
+			await self.rpc.call(*args)
+			return True
 		except Exception as e:
 			rmsg(e.args[0])
 			return False
@@ -91,7 +92,7 @@ class BitcoinTrackingWallet(TrackingWallet):
 
 	@write_mode
 	async def rescan_address(self,addrspec):
-		res = await self.resolve_address(addrspec,None)
+		res = await self.resolve_address(addrspec)
 		if not res:
 			return False
 		return await self.rescan_addresses([res.coinaddr])

+ 2 - 1
mmgen/proto/eth/tw/bal.py

@@ -33,11 +33,12 @@ class EthereumTwGetBalance(TwGetBalance):
 
 	async def create_data(self):
 		data = self.wallet.mmid_ordered_dict
+		amt0 = self.proto.coin_amt('0')
 		for d in data:
 			if d.type == 'mmgen':
 				key = d.obj.sid
 				if key not in self.data:
-					self.data[key] = [self.proto.coin_amt('0')] * 4
+					self.data[key] = [amt0] * 4
 			else:
 				key = 'Non-MMGen'
 

+ 15 - 2
mmgen/proto/eth/tw/ctl.py

@@ -127,12 +127,12 @@ class EthereumTrackingWallet(TrackingWallet):
 			return None
 
 	@write_mode
-	async def set_comment(self,coinaddr,lbl):
+	async def set_label(self,coinaddr,lbl):
 		for addr,d in list(self.data_root.items()):
 			if addr == coinaddr:
 				d['comment'] = lbl.comment
 				self.write()
-				return None
+				return True
 		else:
 			msg(f'Address {coinaddr!r} not found in {self.data_root_desc!r} section of tracking wallet')
 			return False
@@ -156,6 +156,19 @@ class EthereumTrackingWallet(TrackingWallet):
 			return self.data['tokens'][token]['params'].get(param)
 		return None
 
+	@property
+	def sorted_list(self):
+		return sorted(
+			[ { 'addr':x[0],
+				'mmid':x[1]['mmid'],
+				'comment':x[1]['comment'] }
+					for x in self.data_root.items() if x[0] not in ('params','coin') ],
+			key=lambda x: x['mmid'].sort_key+x['addr'] )
+
+	@property
+	def mmid_ordered_dict(self):
+		return dict((x['mmid'],{'addr':x['addr'],'comment':x['comment']}) for x in self.sorted_list)
+
 class EthereumTokenTrackingWallet(EthereumTrackingWallet):
 
 	desc = 'Ethereum token tracking wallet'

+ 1 - 1
mmgen/proto/eth/tw/unspent.py

@@ -42,7 +42,7 @@ class EthereumTwUnspentOutputs(TwUnspentOutputs):
 Sort options:    [a]mount, a[d]dress, [r]everse, [M]mgen addr
 Display options: show [m]mgen addr, r[e]draw screen
 Actions:         [q]uit view, [p]rint to file, pager [v]iew, [w]ide view,
-                 add [l]abel, [D]elete address, [R]efresh balance:
+                 [D]elete address, add [l]abel, [R]efresh balance:
 """
 	key_mappings = {
 		'a':'s_amt',

+ 3 - 4
mmgen/tool/rpc.py

@@ -110,7 +110,7 @@ class tool_cmd(tool_cmd_base):
 		await obj.get_data(sort_key=sort,reverse_sort=reverse)
 
 		if interactive:
-			await obj.view_and_sort()
+			await obj.view_filter_and_sort()
 			return True
 		else:
 			return await obj.format('detail' if detail else 'squeezed')
@@ -151,8 +151,7 @@ class tool_cmd(tool_cmd_base):
 	async def add_label(self,mmgen_or_coin_addr:str,label:str):
 		"add descriptive label for address in tracking wallet"
 		from ..tw.ctl import TrackingWallet
-		await (await TrackingWallet(self.proto,mode='w')).add_comment( mmgen_or_coin_addr, label, on_fail='raise' )
-		return True
+		return await (await TrackingWallet(self.proto,mode='w')).set_comment(mmgen_or_coin_addr,label)
 
 	async def remove_label(self,mmgen_or_coin_addr:str):
 		"remove descriptive label for address in tracking wallet"
@@ -176,7 +175,7 @@ class tool_cmd(tool_cmd_base):
 		if ret:
 			from ..util import Msg
 			from ..addr import is_coin_addr
-			return ret.mmaddr if is_coin_addr(self.proto,mmgen_or_coin_addr) else ret.coinaddr
+			return ret.twmmid if is_coin_addr(self.proto,mmgen_or_coin_addr) else ret.coinaddr
 		else:
 			return False
 

+ 90 - 39
mmgen/tw/common.py

@@ -92,9 +92,9 @@ class TwCommon:
 
 	def age_disp(self,o,age_fmt):
 		if age_fmt == 'confs':
-			return o.confs
+			return o.confs or '-'
 		elif age_fmt == 'block':
-			return self.rpc.blockcount - (o.confs - 1)
+			return self.rpc.blockcount + 1 - o.confs if o.confs else '-'
 		else:
 			return self.date_formatter[age_fmt](self.rpc,o.date)
 
@@ -109,21 +109,13 @@ class TwCommon:
 
 		res = self.gen_data(rpc_data,lbl_id)
 		self.data = MMGenList(await res if type(res).__name__ == 'coroutine' else res)
+		self.disp_data = list(self.filter_data())
 
 		if not self.data:
 			die(1,self.no_data_errmsg)
 
 		self.do_sort(key=sort_key,reverse=reverse_sort)
 
-	async def set_dates(self,us):
-		if not self.dates_set:
-			# 'blocktime' differs from 'time', is same as getblockheader['time']
-			dates = [ o.get('blocktime',0)
-				for o in await self.rpc.gathered_icall('gettransaction',[(o.txid,True,False) for o in us]) ]
-			for idx,o in enumerate(us):
-				o.date = dates[idx]
-			self.dates_set = True
-
 	@property
 	def age_w(self):
 		return self.age_col_params[self.age_fmt][0]
@@ -258,10 +250,10 @@ class TwCommon:
 	def header(self,color):
 
 		Blue,Green = (blue,green) if color else (nocolor,nocolor)
-		yes,no = green('yes'),red('no') if color else ('yes','no')
+		Yes,No = (green('yes'),red('no')) if color else ('yes','no')
 
 		def fmt_filter(k):
-			return '{}:{}'.format(k,yes if getattr(self,k) else no)
+			return '{}:{}'.format(k,{0:No,1:Yes}[getattr(self,k)])
 
 		return '{h} (sort order: {s}){f}\nNetwork: {n}\nBlock {b} [{d}]\n{t}'.format(
 			h = self.hdr_lbl.upper(),
@@ -277,29 +269,26 @@ class TwCommon:
 		return ''
 
 	def filter_data(self):
-		return self.data
+		return self.data.copy()
 
 	async def format(self,display_type,color=True,cached=False,interactive=False):
 
 		if not cached:
 
-			data = list(self.filter_data()) # method could be a generator
-
-			if data:
+			dt = getattr(self.display_type,display_type)
 
-				dt = getattr(self.display_type,display_type)
+			if self.has_age and (self.age_fmt in self.age_fmts_date_dependent or dt.detail):
+				await self.set_dates(self.data)
 
-				cw = self.get_column_widths(data,wide=dt.detail) if dt.need_column_widths else None
+			data = self.disp_data = list(self.filter_data()) # method could be a generator
 
-				if self.has_age and (self.age_fmt in self.age_fmts_date_dependent or dt.detail):
-					await self.set_dates(data)
+			cw = self.get_column_widths(data,wide=dt.detail) if data and dt.need_column_widths else None
 
-			self._display_data[display_type] = (
-				self.header(color) + self.subheader(color) + '\n'
-				+ (
-					dt.item_separator.join(getattr(self,dt.fmt_method)(data,cw,color=color)) + '\n'
-					if data else (nocolor,yellow)[color]('[no data for requested parameters]') + '\n'
-				)
+			self._display_data[display_type] = '{a}{b}\n{c}\n'.format(
+				a = self.header(color),
+				b = self.subheader(color),
+				c = dt.item_separator.join(getattr(self,dt.fmt_method)(data,cw,color=color))
+					if data else (nocolor,yellow)[color]('[no data for requested parameters]')
 			)
 
 		return self._display_data[display_type] + ('' if interactive else self.footer(color))
@@ -310,7 +299,7 @@ class TwCommon:
 			self.proto.dcoin
 		) if hasattr(self,'total') else ''
 
-	async def view_and_sort(self):
+	async def view_filter_and_sort(self):
 		from ..opts import opt
 		from ..term import get_char
 		prompt = self.prompt.strip() + '\b'
@@ -351,7 +340,7 @@ class TwCommon:
 				await self.item_action().run(self,action)
 			elif action == 'a_quit':
 				msg('')
-				return self.data
+				return self.disp_data
 
 	class action:
 
@@ -421,15 +410,75 @@ class TwCommon:
 			msg('')
 			from ..ui import line_input
 			while True:
-				ret = line_input(f'Enter {parent.item_desc} number (or RETURN to return to main menu): ')
+				ret = line_input(f'Enter {parent.item_desc} number (or ENTER to return to main menu): ')
 				if ret == '':
 					return None
 				idx = get_obj(MMGenIdx,n=ret,silent=True)
-				if not idx or idx < 1 or idx > len(parent.data):
-					msg(f'Choice must be a single number between 1 and {len(parent.data)}')
+				if not idx or idx < 1 or idx > len(parent.disp_data):
+					msg(f'Choice must be a single number between 1 and {len(parent.disp_data)}')
 				elif (await getattr(self,action)(parent,idx)) != 'redo':
 					break
 
+		async def a_balance_refresh(self,parent,idx):
+			from ..ui import keypress_confirm
+			if not keypress_confirm(
+					f'Refreshing tracking wallet {parent.item_desc} #{idx}.  Is this what you want?'):
+				return 'redo'
+			await parent.wallet.get_balance( parent.disp_data[idx-1].addr, force_rpc=True )
+			await parent.get_data()
+			parent.oneshot_msg = yellow(f'{parent.proto.dcoin} balance for account #{idx} refreshed\n\n')
+
+		async def a_addr_delete(self,parent,idx):
+			from ..ui import keypress_confirm
+			if not keypress_confirm(
+					'Removing {} {} from tracking wallet.  Is this what you want?'.format(
+						parent.item_desc, red(f'#{idx}') )):
+				return 'redo'
+			if await parent.wallet.remove_address( parent.disp_data[idx-1].addr ):
+				await parent.get_data()
+				parent.oneshot_msg = yellow(f'{capfirst(parent.item_desc)} #{idx} removed\n\n')
+			else:
+				await asyncio.sleep(3)
+				parent.oneshot_msg = red('Address could not be removed\n\n')
+
+		async def a_comment_add(self,parent,idx):
+
+			async def do_comment_add(comment):
+				if await parent.wallet.set_comment( entry.twmmid, comment, entry.addr ):
+					await parent.get_data()
+					parent.oneshot_msg = yellow('Label {a} {b}{c}\n\n'.format(
+						a = 'for' if cur_comment and comment else 'added to' if comment else 'removed from',
+						b = desc,
+						c = ' edited' if cur_comment and comment else '' ))
+					return True
+				else:
+					await asyncio.sleep(3)
+					parent.oneshot_msg = red('Label for {desc} could not be {action}\n\n'.format(
+						desc = desc,
+						action = 'edited' if cur_comment and comment else 'added' if comment else 'removed'
+					))
+					return False
+
+			entry = parent.disp_data[idx-1]
+			desc = f'{parent.item_desc} #{idx}'
+			cur_comment = parent.disp_data[idx-1].comment
+			msg('Current label: {}'.format(cur_comment.hl() if cur_comment else '(none)'))
+
+			from ..ui import line_input
+			res = line_input(
+				'Enter label text for {} {}: '.format(parent.item_desc,red(f'#{idx}')),
+				insert_txt = cur_comment )
+
+			if res == cur_comment:
+				parent.oneshot_msg = green(f'Label for {desc} unchanged\n\n')
+				return None
+			elif res == '':
+				from ..ui import keypress_confirm
+				if not keypress_confirm(f'Removing label for {desc}.  Is this what you want?'):
+					return None
+
+			return await do_comment_add(res)
+
 class TwMMGenID(str,Hilite,InitErrors,MMGenObject):
 	color = 'orange'
 	width = 0
@@ -437,22 +486,24 @@ class TwMMGenID(str,Hilite,InitErrors,MMGenObject):
 	def __new__(cls,proto,id_str):
 		if type(id_str) == cls:
 			return id_str
-		ret = None
 		try:
-			ret = MMGenID(proto,id_str)
-			sort_key,idtype = ret.sort_key,'mmgen'
+			ret = addr = disp = MMGenID(proto,id_str)
+			sort_key,idtype = (ret.sort_key,'mmgen')
 		except Exception as e:
 			try:
-				assert id_str.split(':',1)[0] == proto.base_coin.lower(),(
+				coin,addr = id_str.split(':',1)
+				assert coin == proto.base_coin.lower(),(
 					f'not a string beginning with the prefix {proto.base_coin.lower()!r}:' )
-				assert id_str.isascii() and id_str[4:].isalnum(), 'not an ASCII alphanumeric string'
-				assert len(id_str) > 4,'not more that four characters long'
-				ret,sort_key,idtype = str(id_str),'z_'+id_str,'non-mmgen'
+				assert addr.isascii() and addr.isalnum(), 'not an ASCII alphanumeric string'
+				ret,sort_key,idtype,disp = (id_str,'z_'+id_str,'non-mmgen','non-MMGen')
+				addr = proto.coin_addr(addr)
 			except Exception as e2:
 				return cls.init_fail(e,id_str,e2=e2)
 
 		me = str.__new__(cls,ret)
 		me.obj = ret
+		me.disp = disp
+		me.addr = addr
 		me.sort_key = sort_key
 		me.type = idtype
 		me.proto = proto

+ 26 - 33
mmgen/tw/ctl.py

@@ -38,6 +38,8 @@ from ..addr import CoinAddr,is_mmgen_id,is_coin_addr
 from ..rpc import rpc_init
 from .common import TwMMGenID,TwLabel
 
+addr_info = namedtuple('addr_info',['twmmid','coinaddr'])
+
 # decorator for TrackingWallet
 def write_mode(orig_func):
 	def f(self,*args,**kwargs):
@@ -179,19 +181,6 @@ class TrackingWallet(MMGenObject,metaclass=AsyncInit):
 			self.cache_balance(addr,ret,self.cur_balances,self.data_root)
 		return ret
 
-	@property
-	def sorted_list(self):
-		return sorted(
-			[ { 'addr':x[0],
-				'mmid':x[1]['mmid'],
-				'comment':x[1]['comment'] }
-					for x in self.data_root.items() if x[0] not in ('params','coin') ],
-			key=lambda x: x['mmid'].sort_key+x['addr'] )
-
-	@property
-	def mmid_ordered_dict(self):
-		return dict((x['mmid'],{'addr':x['addr'],'comment':x['comment']}) for x in self.sorted_list)
-
 	def force_write(self):
 		mode_save = self.mode
 		self.mode = 'w'
@@ -261,45 +250,49 @@ class TrackingWallet(MMGenObject,metaclass=AsyncInit):
 		if not mmaddr:
 			mmaddr = f'{self.proto.base_coin.lower()}:{coinaddr}'
 
-		return namedtuple('addr_info',['mmaddr','coinaddr'])(
-			TwMMGenID(self.proto,mmaddr),
-			coinaddr )
+		return addr_info( TwMMGenID(self.proto,mmaddr), coinaddr )
 
 	# returns on failure
 	@write_mode
-	async def add_comment(self,addrspec,comment='',coinaddr=None,silent=False,on_fail='return'):
-		assert on_fail in ('return','raise'), 'add_comment_chk1'
+	async def set_comment(self,addrspec,comment='',trusted_coinaddr=None,silent=False):
+
+		res = (
+			addr_info(addrspec,trusted_coinaddr) if trusted_coinaddr
+			else await self.resolve_address(addrspec) )
 
-		res = await self.resolve_address(addrspec,coinaddr)
 		if not res:
 			return False
 
-		cmt = TwComment(comment) if on_fail=='raise' else get_obj(TwComment,s=comment)
-		if cmt in (False,None):
+		comment = get_obj(TwComment,s=comment)
+
+		if comment == False:
 			return False
 
-		lbl_txt = res.mmaddr + (' ' + cmt if cmt else '')
-		lbl = (
-			TwLabel(self.proto,lbl_txt) if on_fail == 'raise' else
-			get_obj(TwLabel,proto=self.proto,text=lbl_txt) )
+		lbl = get_obj(
+			TwLabel,
+			proto = self.proto,
+			text = res.twmmid + (' ' + comment if comment else ''))
 
-		if await self.set_comment(res.coinaddr,lbl) == False:
-			if not silent:
-				msg( 'Label could not be {}'.format('added' if comment else 'removed') )
+		if lbl == False:
 			return False
-		else:
+
+		if await self.set_label(res.coinaddr,lbl):
 			desc = '{} address {} in tracking wallet'.format(
-				res.mmaddr.type.replace('mmgen','MMGen'),
-				res.mmaddr.replace(self.proto.base_coin.lower()+':','') )
+				res.twmmid.type.replace('mmgen','MMGen'),
+				res.twmmid.addr.hl() )
 			if comment:
-				msg(f'Added label {comment!r} to {desc}')
+				msg('Added label {} to {}'.format(comment.hl(encl="''"),desc))
 			else:
 				msg(f'Removed label from {desc}')
 			return True
+		else:
+			if not silent:
+				msg( 'Label could not be {}'.format('added' if comment else 'removed') )
+			return False
 
 	@write_mode
 	async def remove_comment(self,mmaddr):
-		await self.add_comment(mmaddr,'')
+		await self.set_comment(mmaddr,'')
 
 	async def import_address_common(self,data,batch=False,gather=False):
 

+ 27 - 31
mmgen/tw/txhistory.py

@@ -43,7 +43,6 @@ class TwTxHistory(MMGenObject,TwCommon,metaclass=AsyncInit):
 
 	async def __init__(self,proto,sinceblock=0):
 		self.proto        = proto
-		self.data         = MMGenList()
 		self.rpc          = await rpc_init(proto)
 		self.sinceblock   = Int( sinceblock if sinceblock >= 0 else self.rpc.blockcount + sinceblock )
 
@@ -52,6 +51,9 @@ class TwTxHistory(MMGenObject,TwCommon,metaclass=AsyncInit):
 		return 'No transaction history {}found!'.format(
 			f'from block {self.sinceblock} ' if self.sinceblock else '')
 
+	def filter_data(self):
+		return (d for d in self.data if d.confirmations > 0 or self.show_unconfirmed)
+
 	def get_column_widths(self,data,wide=False):
 
 		# var cols: addr1 addr2 comment [txid]
@@ -116,18 +118,15 @@ class TwTxHistory(MMGenObject,TwCommon,metaclass=AsyncInit):
 			a2 = 'Outputs',
 			l  = 'Comment' ).rstrip()
 
-		n = 0
-		for d in data:
-			if d.confirmations > 0 or self.show_unconfirmed:
-				n += 1
-				yield fs.format(
-					n  = str(n) + ')',
-					i  = d.txid_disp( width=cw.txid, color=color ) if hasattr(cw,'txid') else None,
-					d  = d.age_disp( self.age_fmt, width=self.age_w, color=color ),
-					a1 = d.vouts_disp( 'inputs', width=cw.addr1, color=color ),
-					A  = d.amt_disp(self.show_total_amt).fmt( prec=self.disp_prec, color=color ),
-					a2 = d.vouts_disp( 'outputs', width=cw.addr2, color=color ),
-					l  = d.comment.fmt( width=cw.comment, color=color ) ).rstrip()
+		for n,d in enumerate(data,1):
+			yield fs.format(
+				n  = str(n) + ')',
+				i  = d.txid_disp( width=cw.txid, color=color ) if hasattr(cw,'txid') else None,
+				d  = d.age_disp( self.age_fmt, width=self.age_w, color=color ),
+				a1 = d.vouts_disp( 'inputs', width=cw.addr1, color=color ),
+				A  = d.amt_disp(self.show_total_amt).fmt( prec=self.disp_prec, color=color ),
+				a2 = d.vouts_disp( 'outputs', width=cw.addr2, color=color ),
+				l  = d.comment.fmt( width=cw.comment, color=color ) ).rstrip()
 
 	def gen_detail_display(self,data,cw,color):
 
@@ -150,23 +149,20 @@ class TwTxHistory(MMGenObject,TwCommon,metaclass=AsyncInit):
 		        {a2}
 		""",strip_char='\t').strip()
 
-		n = 0
-		for d in data:
-			if d.confirmations > 0 or self.show_unconfirmed:
-				n += 1
-				yield fs.format(
-					n  = str(n) + ')',
-					d  = d.age_disp( 'date_time', width=None, color=None ),
-					b  = d.blockheight_disp(color=color),
-					D  = d.txdate_disp( 'date_time' ),
-					i  = d.txid_disp( width=None, color=color ),
-					A1 = d.amt_disp(True).hl( color=color ),
-					A2 = d.amt_disp(False).hl( color=color ),
-					f  = d.fee_disp( color=color ),
-					a1 = d.vouts_list_disp( 'inputs', color=color, indent=' '*8 ),
-					oc = d.nOutputs,
-					a2 = d.vouts_list_disp( 'outputs', color=color, indent=' '*8 ),
-				)
+		for n,d in enumerate(data,1):
+			yield fs.format(
+				n  = str(n) + ')',
+				d  = d.age_disp( 'date_time', width=None, color=None ),
+				b  = d.blockheight_disp(color=color),
+				D  = d.txdate_disp( 'date_time' ),
+				i  = d.txid_disp( width=None, color=color ),
+				A1 = d.amt_disp(True).hl( color=color ),
+				A2 = d.amt_disp(False).hl( color=color ),
+				f  = d.fee_disp( color=color ),
+				a1 = d.vouts_list_disp( 'inputs', color=color, indent=' '*8 ),
+				oc = d.nOutputs,
+				a2 = d.vouts_list_disp( 'outputs', color=color, indent=' '*8 ),
+			)
 
 	sort_disp = {
 		'age':         'Age',
@@ -184,7 +180,7 @@ class TwTxHistory(MMGenObject,TwCommon,metaclass=AsyncInit):
 		'txid':        lambda i: i.txid,
 	}
 
-	async def set_dates(self,us):
+	async def set_dates(self,foo):
 		pass
 
 	@property

+ 11 - 64
mmgen/tw/unspent.py

@@ -71,7 +71,6 @@ class TwUnspentOutputs(MMGenObject,TwCommon,metaclass=AsyncInit):
 
 	async def __init__(self,proto,minconf=1,addrs=[]):
 		self.proto        = proto
-		self.data         = MMGenList()
 		self.show_mmid    = True
 		self.minconf      = minconf
 		self.addrs        = addrs
@@ -104,7 +103,7 @@ class TwUnspentOutputs(MMGenObject,TwCommon,metaclass=AsyncInit):
 
 	def filter_data(self):
 
-		data = self.data
+		data = self.data.copy()
 
 		for d in data:
 			d.skip = ''
@@ -220,10 +219,7 @@ class TwUnspentOutputs(MMGenObject,TwCommon,metaclass=AsyncInit):
 				a  = (
 					'|'+'.' * addr_w if i.skip == 'addr' and self.group else
 					i.addr.fmt(color=color,width=addr_w) ),
-				m  = MMGenID.fmtc(
-						(i.twmmid if i.twmmid.type == 'mmgen' else f'Non-{g.proj_name}'),
-						width = mmid_w,
-						color = color ),
+				m  = MMGenID.fmtc( i.twmmid.disp, width=mmid_w, color=color ),
 				A  = i.amt.fmt(color=color),
 				A2 = ( i.amt2.fmt(color=color) if i.amt2 is not None else '' ),
 				c  = i.confs,
@@ -244,6 +240,15 @@ class TwUnspentOutputs(MMGenObject,TwCommon,metaclass=AsyncInit):
 			len(self.data),
 			suf(self.data) ))
 
+	async def set_dates(self,us):
+		if not self.dates_set:
+			# 'blocktime' differs from 'time', is same as getblockheader['time']
+			dates = [ o.get('blocktime',0)
+				for o in await self.rpc.gathered_icall('gettransaction',[(o.txid,True,False) for o in us]) ]
+			for idx,o in enumerate(us):
+				o.date = dates[idx]
+			self.dates_set = True
+
 	class action(TwCommon.action):
 
 		def s_twmmid(self,parent):
@@ -256,61 +261,3 @@ class TwUnspentOutputs(MMGenObject,TwCommon,metaclass=AsyncInit):
 		def d_group(self,parent):
 			if parent.can_group:
 				parent.group = not parent.group
-
-	class item_action(TwCommon.item_action):
-
-		async def a_balance_refresh(self,uo,idx):
-			from ..ui import keypress_confirm
-			if not keypress_confirm(
-					f'Refreshing tracking wallet {uo.item_desc} #{idx}.  Is this what you want?'):
-				return 'redo'
-			await uo.wallet.get_balance( uo.data[idx-1].addr, force_rpc=True )
-			await uo.get_data()
-			uo.oneshot_msg = yellow(f'{uo.proto.dcoin} balance for account #{idx} refreshed\n\n')
-
-		async def a_addr_delete(self,uo,idx):
-			from ..ui import keypress_confirm
-			if not keypress_confirm(
-					f'Removing {uo.item_desc} #{idx} from tracking wallet.  Is this what you want?'):
-				return 'redo'
-			if await uo.wallet.remove_address( uo.data[idx-1].addr ):
-				await uo.get_data()
-				uo.oneshot_msg = yellow(f'{capfirst(uo.item_desc)} #{idx} removed\n\n')
-			else:
-				await asyncio.sleep(3)
-				uo.oneshot_msg = red('Address could not be removed\n\n')
-
-		async def a_comment_add(self,uo,idx):
-
-			async def do_comment_add(comment):
-				e = uo.data[idx-1]
-				if await uo.wallet.add_comment( e.twmmid, comment, coinaddr=e.addr ):
-					await uo.get_data()
-					uo.oneshot_msg = yellow('Label {a} {b}{c}\n\n'.format(
-						a = 'to' if cur_comment and comment else 'added to' if comment else 'removed from',
-						b = desc,
-						c = ' edited' if cur_comment and comment else '' ))
-				else:
-					await asyncio.sleep(3)
-					uo.oneshot_msg = red('Label could not be {}\n\n'.format(
-						'edited' if cur_comment and comment else
-						'added' if comment else
-						'removed' ))
-
-			desc = f'{uo.item_desc} #{idx}'
-			cur_comment = uo.data[idx-1].comment
-			msg('Current label: {}'.format(cur_comment.hl() if cur_comment else '(none)'))
-
-			from ..ui import line_input
-			res = line_input(
-				"Enter label text (or ENTER to return to main menu): ",
-				insert_txt = cur_comment )
-
-			if res == cur_comment:
-				return None
-			elif res == '':
-				from ..ui import keypress_confirm
-				return (await do_comment_add('')) if keypress_confirm(
-					f'Removing label for {desc}.  Is this what you want?') else 'redo'
-			else:
-				return (await do_comment_add(res)) if get_obj(TwComment,s=res) else 'redo'

+ 1 - 1
mmgen/tx/new.py

@@ -340,7 +340,7 @@ class New(Base):
 		do_license_msg()
 
 		if not opt.inputs:
-			await self.twuo.view_and_sort()
+			await self.twuo.view_filter_and_sort()
 
 		self.twuo.display_total()
 

+ 5 - 2
test/objtest_py_d/ot_btc_mainnet.py

@@ -18,6 +18,7 @@ from .ot_common import *
 from mmgen.protocol import init_proto
 proto = init_proto('btc',need_amt=True)
 tw_pfx = proto.base_coin.lower() + ':'
+zero_addr = '1111111111111111111114oLvT2'
 
 ssm = str(SeedShareCount.max_val)
 privkey = PrivKey(proto=proto,s=bytes.fromhex('deadbeef'*8),compressed=True,pubkey_type='std')
@@ -162,9 +163,10 @@ tests = {
 			{'id_str':'F00BAA12:Z:99', 'proto':proto},
 			{'id_str':tw_pfx,          'proto':proto},
 			{'id_str':tw_pfx+'я',      'proto':proto},
+			{'id_str':tw_pfx+'x',      'proto':proto},
 		),
 		'good':  (
-			{'id_str':tw_pfx+'x',           'proto':proto},
+			{'id_str':tw_pfx+zero_addr,     'proto':proto},
 			{'id_str':'F00BAA12:99',        'proto':proto, 'ret':'F00BAA12:L:99'},
 			{'id_str':'F00BAA12:L:99',      'proto':proto},
 			{'id_str':'F00BAA12:S:9999999', 'proto':proto},
@@ -188,13 +190,14 @@ tests = {
 			{'text':tw_pfx+'я x',    'proto':proto},
 			{'text':utf8_ctrl[:40],  'proto':proto},
 			{'text':'F00BAA12:S:1 ' + utf8_ctrl[:40], 'proto':proto, 'exc_name': 'BadTwComment'},
+			{'text':tw_pfx+'x comment','proto':proto},
 		),
 		'good':  (
 			{'text':'F00BAA12:99 a comment',            'proto':proto, 'ret':'F00BAA12:L:99 a comment'},
 			{'text':'F00BAA12:L:99 a comment',          'proto':proto},
 			{'text': 'F00BAA12:L:99 comment (UTF-8) α', 'proto':proto},
 			{'text':'F00BAA12:S:9999999 comment',       'proto':proto},
-			{'text':tw_pfx+'x comment',                 'proto':proto},
+			{'text':tw_pfx+zero_addr+' comment',        'proto':proto},
 		),
 	},
 	'MMGenTxID': {

+ 0 - 8
test/overlay/fakemods/mmgen/tw/common.py

@@ -15,13 +15,5 @@ if overlay_fake_os.getenv('MMGEN_TEST_SUITE_DETERMINISTIC'):
 
 if overlay_fake_os.getenv('MMGEN_BOGUS_UNSPENT_DATA'):
 
-	class overlay_fake_data2:
-
-		async def set_dates(foo,us):
-			for o in us:
-				o.date = 1831006505 - int(9.7 * 60 * (o.confs - 1))
-
-	TwCommon.set_dates = overlay_fake_data2.set_dates
-
 	# 1831006505 (09 Jan 2028) = projected time of block 1000000
 	TwCommon.date_formatter['days'] = lambda rpc,secs: (1831006505 - secs) // 86400

+ 12 - 0
test/overlay/fakemods/mmgen/tw/unspent.py

@@ -0,0 +1,12 @@
+import os as overlay_fake_os
+from .unspent_orig import *
+
+if overlay_fake_os.getenv('MMGEN_BOGUS_UNSPENT_DATA'):
+
+	class overlay_fake_data:
+
+		async def set_dates(foo,us):
+			for o in us:
+				o.date = 1831006505 - int(9.7 * 60 * (o.confs - 1))
+
+	TwUnspentOutputs.set_dates = overlay_fake_data.set_dates

+ 28 - 16
test/test_py_d/ts_ethdev.py

@@ -71,26 +71,26 @@ bals = {
 			('98831F3A:E:2','23.45495'),
 			('98831F3A:E:11','1.234'),
 			('98831F3A:E:21','2.345'),
-			(burn_addr + r'\s+Non-MMGen',amt1)],
+			(burn_addr + r'\s+non-MMGen',amt1)],
 	'8': [  ('98831F3A:E:1','0'),
 			('98831F3A:E:2','23.45495'),
 			('98831F3A:E:11',vbal1,'a1'),
 			('98831F3A:E:12','99.99895'),
 			('98831F3A:E:21','2.345'),
-			(burn_addr + r'\s+Non-MMGen',amt1)],
+			(burn_addr + r'\s+non-MMGen',amt1)],
 	'9': [  ('98831F3A:E:1','0'),
 			('98831F3A:E:2','23.45495'),
 			('98831F3A:E:11',vbal1,'a1'),
 			('98831F3A:E:12',vbal2),
 			('98831F3A:E:21','2.345'),
-			(burn_addr + r'\s+Non-MMGen',amt1)],
+			(burn_addr + r'\s+non-MMGen',amt1)],
 	'10': [ ('98831F3A:E:1','0'),
 			('98831F3A:E:2','23.0218'),
 			('98831F3A:E:3','0.4321'),
 			('98831F3A:E:11',vbal1,'a1'),
 			('98831F3A:E:12',vbal2),
 			('98831F3A:E:21','2.345'),
-			(burn_addr + r'\s+Non-MMGen',amt1)]
+			(burn_addr + r'\s+non-MMGen',amt1)]
 }
 
 token_bals = {
@@ -101,18 +101,18 @@ token_bals = {
 			('98831F3A:E:12','1.23456','0')],
 	'4': [  ('98831F3A:E:11','110.654317776666555545',vbal1,'a1'),
 			('98831F3A:E:12','1.23456','0'),
-			(burn_addr + r'\s+Non-MMGen',amt2,amt1)],
+			(burn_addr + r'\s+non-MMGen',amt2,amt1)],
 	'5': [  ('98831F3A:E:11','110.654317776666555545',vbal1,'a1'),
 			('98831F3A:E:12','1.23456','99.99895'),
-			(burn_addr + r'\s+Non-MMGen',amt2,amt1)],
+			(burn_addr + r'\s+non-MMGen',amt2,amt1)],
 	'6': [  ('98831F3A:E:11','110.654317776666555545',vbal1,'a1'),
 			('98831F3A:E:12','0',vbal2),
 			('98831F3A:E:13','1.23456','0'),
-			(burn_addr + r'\s+Non-MMGen',amt2,amt1)],
+			(burn_addr + r'\s+non-MMGen',amt2,amt1)],
 	'7': [  ('98831F3A:E:11','67.444317776666555545',vbal9,'a2'),
 			('98831F3A:E:12','43.21',vbal2),
 			('98831F3A:E:13','1.23456','0'),
-			(burn_addr + r'\s+Non-MMGen',amt2,amt1)]
+			(burn_addr + r'\s+non-MMGen',amt2,amt1)]
 }
 token_bals_getbalance = {
 	'1': (vbal4,'999999.12345689012345678'),
@@ -1260,20 +1260,32 @@ class TestSuiteEthdev(TestSuiteBase,TestSuiteShared):
 
 	def edit_comment(self,out_num,args=[],action='l',comment_text=None,changed=False,pexpect_spawn=None):
 		t = self.spawn('mmgen-txcreate', self.eth_args + args + ['-B','-i'],pexpect_spawn=pexpect_spawn)
-		p1,p2 = ('efresh balance:\b','return to main menu): ')
-		p3,r3 = (p2,comment_text+'\n') if comment_text is not None else ('(y/N): ','y')
-		p4,r4 = (('(y/N): ',),('y',)) if comment_text == Ctrl_U else ((),())
-		for p,r in zip((p1,p1,p2,p3)+p4,('M',action,out_num+'\n',r3)+r4):
-			t.expect(p,r)
+
+		menu_prompt = 'efresh balance:\b'
+
+		t.expect(menu_prompt,'M')
+		t.expect(menu_prompt,action)
+		t.expect(r'return to main menu): ',out_num+'\n')
+
+		for p,r in (
+			('Enter label text.*: ',comment_text+'\n') if comment_text is not None else (r'\(y/N\): ','y'),
+			(r'\(y/N\): ','y') if comment_text == Ctrl_U else (None,None),
+		):
+			if p:
+				t.expect(p,r,regex=True)
+
 		m = (
-			'Label to account #{} edited' if changed else
+			'Label for account #{} edited' if changed else
 			'Account #{} removed' if action == 'D' else
 			'Label added to account #{}' if comment_text and comment_text != Ctrl_U else
 			'Label removed from account #{}' )
+
 		t.expect(m.format(out_num))
-		for p,r in zip((p1,p1),('M','q')):
-			t.expect(p,r)
+		t.expect(menu_prompt,'M')
+		t.expect(menu_prompt,'q')
+
 		t.expect('Total unspent:')
+
 		return t
 
 	def edit_comment1(self):

+ 9 - 8
test/test_py_d/ts_regtest.py

@@ -683,7 +683,7 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 		t = self.spawn('mmgen-tool',['--'+user,'txhist'] + args)
 		res = strip_ansi_escapes(t.read()).replace('\r','')
 		m = re.search(expect,res,re.DOTALL)
-		assert m, m
+		assert m, f'Expected: {expect}'
 		return t
 
 	def bob_txhist1(self):
@@ -699,7 +699,7 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 	def bob_txhist3(self):
 		return self.user_txhist('bob',
 			args = ['sort=blockheight','sinceblock=-7','age_fmt=block'],
-			expect = fr'Displaying transactions since block 399.*\s6\)\s+405.*:C:3\s.*\s{rtBals[9]}\s.*:L:5.*\s7\)'
+			expect = fr'Displaying transactions since block 399.*\s6\)\s+405\s.*\s{rtBals[9]}\s.*:L:5.*\s7\)'
 		)
 
 	def bob_txhist4(self):
@@ -1147,28 +1147,29 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 		mmid = self._user_sid('alice') + (':S:1',':L:1')[self.proto.coin=='BCH']
 		return self._user_chk_comment('alice',mmid,'Label added using coin address of MMGen address')
 
-	def alice_add_comment_badaddr(self,addr,reply):
+	def alice_add_comment_badaddr(self,addr,reply,exit_val):
 		if os.getenv('PYTHONOPTIMIZE'):
 			omsg(yellow(f'PYTHONOPTIMIZE set, skipping test {self.test_name!r}'))
 			return 'skip'
 		t = self.spawn('mmgen-tool',['--alice','add_label',addr,'(none)'])
 		t.expect(reply,regex=True)
+		t.req_exit_val = exit_val
 		return t
 
 	def alice_add_comment_badaddr1(self):
-		return self.alice_add_comment_badaddr( rt_pw,'Invalid coin address for this chain: ')
+		return self.alice_add_comment_badaddr( rt_pw,'Invalid coin address for this chain: ', 2)
 
 	def alice_add_comment_badaddr2(self):
 		addr = init_proto(self.proto.coin,network='mainnet').pubhash2addr(bytes(20),False) # mainnet zero address
-		return self.alice_add_comment_badaddr( addr, f'Invalid coin address for this chain: {addr}' )
+		return self.alice_add_comment_badaddr( addr, f'Invalid coin address for this chain: {addr}', 2 )
 
 	def alice_add_comment_badaddr3(self):
 		addr = self._user_sid('alice') + ':C:123'
-		return self.alice_add_comment_badaddr( addr, f'MMGen address {addr!r} not found in tracking wallet' )
+		return self.alice_add_comment_badaddr( addr, f'MMGen address {addr!r} not found in tracking wallet', 2 )
 
 	def alice_add_comment_badaddr4(self):
 		addr = self.proto.pubhash2addr(bytes(20),False) # regtest (testnet) zero address
-		return self.alice_add_comment_badaddr( addr, f'Address {addr!r} not found in tracking wallet' )
+		return self.alice_add_comment_badaddr( addr, f'Address {addr!r} not found in tracking wallet', 2 )
 
 	def alice_remove_comment1(self):
 		sid = self._user_sid('alice')
@@ -1201,7 +1202,7 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 		t.expect(r'add \[l\]abel:.','M',regex=True)
 		t.expect(r'add \[l\]abel:.','l',regex=True)
 		t.expect(r"Enter unspent.*return to main menu\):.",output+'\n',regex=True)
-		t.expect(r"Enter label text.*return to main menu\):.",comment+'\n',regex=True)
+		t.expect(r"Enter label text.*:.",comment+'\n',regex=True)
 		t.expect(r'\[q\]uit view, .*?:.','q',regex=True)
 		return t