Browse Source

txcreate,twview,listaddresses: display transaction date

- The `mmgen-tool` `listaddresses` and `twview` commands, as well as the
  unspent outputs menu of `mmgen-txcreate`, can now display the date/time of
  each output in addition to its number of confirmations, block, or age in
  days.  Two date display precisions are available: “approx”, an estimate based
  on the historical average block confirmation interval, and “exact”, the
  timestamp of the block containing the output.  Since display of the exact
  date requires an extra RPC call per output, precision defaults to “approx”.
  The setting also affects the precision of the age-in-days output.

Usage:

    $ mmgen-txcreate -i # 'D' to cycle through display opts, 'x' for exact age
    $ mmgen-tool listaddresses age_fmt=date exact_age=1 # (or age_fmt=date_time)
    $ mmgen-tool twview age_fmt=date exact_age=1        # (or age_fmt=date_time)

Testing:

    $ test/test.py -ne regtest
    $ test/test.py -ne --coin=eth ethdev
The MMGen Project 5 years ago
parent
commit
b671453c

+ 4 - 2
mmgen/altcoins/eth/tw.py

@@ -304,9 +304,11 @@ Actions:         [q]uit view, [p]rint to file, pager [v]iew, [w]ide view,
 		twmmid = MMGenImmutableAttr('twmmid','TwMMGenID')
 		addr   = MMGenImmutableAttr('addr','CoinAddr')
 		confs  = MMGenImmutableAttr('confs',int,typeconv=False)
-		days   = MMGenListItemAttr('days',int,typeconv=False)
 		skip   = MMGenListItemAttr('skip',str,typeconv=False,reassign_ok=True)
 
+	def age_disp(self,o,age_fmt): # TODO
+		return None
+
 class EthereumTokenTwUnspentOutputs(EthereumTwUnspentOutputs):
 
 	disp_type = 'token'
@@ -322,7 +324,7 @@ class EthereumTokenTwUnspentOutputs(EthereumTwUnspentOutputs):
 
 class EthereumTwAddrList(TwAddrList):
 
-	def __init__(self,usr_addr_list,minconf,showempty,showbtcaddrs,all_labels,wallet=None):
+	def __init__(self,usr_addr_list,minconf,showempty,showbtcaddrs,all_labels,exact_age,wallet=None):
 
 		self.wallet = wallet or TrackingWallet(mode='w')
 		tw_dict = self.wallet.mmid_ordered_dict

+ 2 - 0
mmgen/protocol.py

@@ -103,6 +103,7 @@ class BitcoinProtocol(MMGenObject):
 	sign_mode          = 'daemon'
 	secp256k1_ge       = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141
 	privkey_len        = 32
+	avg_bdi            = int(9.7 * 60) # average block discovery interval (historical)
 
 	@classmethod
 	def addr_fmt_to_ver_bytes(cls,req_fmt,return_hex=False):
@@ -292,6 +293,7 @@ class LitecoinProtocol(BitcoinProtocol):
 	base_coin      = 'LTC'
 	forks          = []
 	bech32_hrp     = 'ltc'
+	avg_bdi        = 2 * 60
 
 class LitecoinTestnetProtocol(LitecoinProtocol):
 	# addr ver nums same as Bitcoin testnet, except for 'p2sh'

+ 20 - 3
mmgen/rpc.py

@@ -232,6 +232,7 @@ class RPCConnection(MMGenObject):
 		'getblockchaininfo',
 		'getblockcount',
 		'getblockhash',
+		'getblockheader',
 		'getmempoolinfo',
 		'getmempoolentry',
 		'getnettotals',
@@ -257,6 +258,8 @@ class EthereumRPCConnection(RPCConnection):
 
 	auth = False
 	db_fs = '    host [{h}] port [{p}]\n'
+	_blockcount = None
+	_cur_date = None
 
 	rpcmethods = (
 		'eth_accounts',
@@ -268,7 +271,6 @@ class EthereumRPCConnection(RPCConnection):
 		'eth_gasPrice',
 		'eth_getBalance',
 		'eth_getBlockByHash',
-		'eth_getBlockByNumber',
 		'eth_getCode',
 		'eth_getTransactionByHash',
 		'eth_getTransactionReceipt',
@@ -285,6 +287,7 @@ class EthereumRPCConnection(RPCConnection):
 		'parity_composeTransaction',
 		'parity_gasCeilTarget',
 		'parity_gasFloorTarget',
+		'parity_getBlockHeaderByNumber',
 		'parity_localTransactions',
 		'parity_minGasPrice',
 		'parity_mode',
@@ -297,6 +300,19 @@ class EthereumRPCConnection(RPCConnection):
 		'parity_versionInfo',
 	)
 
+	# blockcount and cur_date require network RPC calls, so evaluate lazily
+	@property
+	def blockcount(self):
+		if self._blockcount == None:
+			self._blockcount = int(self.eth_blockNumber(),16)
+		return self._blockcount
+
+	@property
+	def cur_date(self):
+		if self._cur_date == None:
+			self._cur_date = int(self.parity_getBlockHeaderByNumber(hex(self.blockcount))['timestamp'],16)
+		return self._cur_date
+
 class MoneroWalletRPCConnection(RPCConnection):
 
 	rpcmethods = (
@@ -390,7 +406,6 @@ def init_daemon_parity():
 	conn = EthereumRPCConnection(
 				g.rpc_host or 'localhost',
 				g.rpc_port or g.proto.rpc_port)
-
 	conn.daemon_version = conn.parity_versionInfo()['version'] # fail immediately if daemon is geth
 	conn.coin_amt_type = str
 	g.chain = conn.parity_chain().replace(' ','_')
@@ -415,7 +430,7 @@ def init_daemon_bitcoind():
 
 	def check_chainfork_mismatch(conn):
 		block0 = conn.getblockhash(0)
-		latest = conn.getblockcount()
+		latest = conn.blockcount
 		try:
 			assert block0 == g.proto.block0,'Incorrect Genesis block for {}'.format(g.proto.__name__)
 			for fork in g.proto.forks:
@@ -446,6 +461,8 @@ def init_daemon_bitcoind():
 		from mmgen.regtest import MMGenRegtest
 		MMGenRegtest(g.coin).switch_user(('alice','bob')[g.bob],quiet=True)
 	conn.daemon_version = int(conn.getnetworkinfo()['version'])
+	conn.blockcount = conn.getblockcount()
+	conn.cur_date = conn.getblockheader(conn.getblockhash(conn.blockcount))['time']
 	conn.coin_amt_type = (float,str)[conn.daemon_version>=120000]
 	g.chain = conn.getblockchaininfo()['chain']
 	if g.chain != 'regtest': g.chain += 'net'

+ 22 - 10
mmgen/tool.py

@@ -27,6 +27,9 @@ from mmgen.addr import *
 
 NL = ('\n','\r\n')[g.platform=='win']
 
+def _options_annot_str(l):
+	return '(valid options: {})'.format(','.join(l))
+
 def _create_call_sig(cmd,parsed=False):
 
 	m = getattr(MMGenToolCmd,cmd)
@@ -803,6 +806,8 @@ class MMGenToolCmdWallet(MMGenToolCmdBase):
 		ret = d.sec.wif if target=='wif' else d.addr
 		return ret
 
+from mmgen.tw import TwAddrList,TwUnspentOutputs
+
 class MMGenToolCmdRPC(MMGenToolCmdBase):
 	"tracking wallet commands using the JSON-RPC interface"
 
@@ -817,14 +822,18 @@ class MMGenToolCmdRPC(MMGenToolCmdBase):
 					pager = False,
 					showempty = True,
 					showbtcaddr = True,
-					age_fmt:'(valid options: days,confs)' = ''):
+					age_fmt: _options_annot_str(TwAddrList.age_fmts) = 'confs',
+					exact_age = False,
+					):
 		"list the specified MMGen address and its balance"
 		return self.listaddresses(  mmgen_addrs = mmgen_addr,
 									minconf = minconf,
 									pager = pager,
 									showempty = showempty,
 									showbtcaddrs = showbtcaddr,
-									age_fmt = age_fmt)
+									age_fmt = age_fmt,
+									exact_age = exact_age,
+								)
 
 	def listaddresses(  self,
 						mmgen_addrs:'(range or list)' = '',
@@ -834,7 +843,9 @@ class MMGenToolCmdRPC(MMGenToolCmdBase):
 						showbtcaddrs = True,
 						all_labels = False,
 						sort:'(valid options: reverse,age)' = '',
-						age_fmt:'(valid options: days,confs)' = ''):
+						age_fmt: _options_annot_str(TwAddrList.age_fmts) = 'confs',
+						exact_age = False,
+						):
 		"list MMGen addresses and their balances"
 		show_age = bool(age_fmt)
 
@@ -853,11 +864,10 @@ class MMGenToolCmdRPC(MMGenToolCmdBase):
 			usr_addr_list = [MMGenID('{}:{}'.format(a[0],i)) for i in AddrIdxList(a[1])]
 
 		rpc_init()
-		from mmgen.tw import TwAddrList
-		al = TwAddrList(usr_addr_list,minconf,showempty,showbtcaddrs,all_labels)
+		al = TwAddrList(usr_addr_list,minconf,showempty,showbtcaddrs,all_labels,exact_age)
 		if not al:
 			die(0,('No tracked addresses with balances!','No tracked addresses!')[showempty])
-		return al.format(showbtcaddrs,sort,show_age,age_fmt or 'days')
+		return al.format(showbtcaddrs,sort,show_age,age_fmt or 'confs')
 
 	def twview( self,
 				pager = False,
@@ -865,16 +875,18 @@ class MMGenToolCmdRPC(MMGenToolCmdBase):
 				wide = False,
 				minconf = 1,
 				sort = 'age',
-				age_fmt:'(valid options: days,confs)' = 'days',
-				show_mmid = True):
+				age_fmt: _options_annot_str(TwUnspentOutputs.age_fmts) = 'confs',
+				exact_age = False,
+				show_mmid = True,
+				wide_show_confs = True):
 		"view tracking wallet"
 		rpc_init()
-		from mmgen.tw import TwUnspentOutputs
 		twuo = TwUnspentOutputs(minconf=minconf)
 		twuo.do_sort(sort,reverse=reverse)
 		twuo.age_fmt = age_fmt
+		twuo.age_prec = 'exact' if exact_age else 'approx'
 		twuo.show_mmid = show_mmid
-		ret = twuo.format_for_printing(color=True) if wide else twuo.format_for_display()
+		ret = twuo.format_for_printing(color=True,show_confs=wide_show_confs) if wide else twuo.format_for_display()
 		del twuo.wallet
 		return ret
 

+ 119 - 46
mmgen/tw.py

@@ -34,6 +34,34 @@ def get_tw_label(s):
 	except BadTwComment: raise
 	except: return None
 
+_date2days = lambda date: (g.rpch.cur_date - date) // 86400
+_confs2date_approx = lambda o: g.rpch.cur_date - int(g.proto.avg_bdi * (o.confs - 1))
+_confs2date_exact = lambda o: (
+#		g.rpch.getblockheader(g.rpch.getblockhash(g.rpch.blockcount - (o.confs - 1)))['time']
+		g.rpch.gettransaction(o.txid)['blocktime'] # same as above, differs from 'time'
+			if o.confs
+		else g.rpch.cur_date )
+
+if os.getenv('MMGEN_BOGUS_WALLET_DATA'):
+	# 1831006505 (09 Jan 2028) = projected time of block 1000000
+	_date2days = lambda date: (1831006505 - date) // 86400
+	_confs2date_approx = lambda o: 1831006505 - (10 * 60 * (o.confs - 1))
+	_confs2date_exact = lambda o: 1831006505 - int(9.7 * 60 * (o.confs - 1))
+
+def _format_date(secs):
+	t = time.gmtime(secs)
+	return '{}-{:02}-{:02}'.format(str(t.tm_year)[2:],t.tm_mon,t.tm_mday)
+
+def _format_date_time(secs):
+	t = time.gmtime(secs)
+	return '{}-{:02}-{:02} {:02}:{:02}'.format(
+				t.tm_year,
+				t.tm_mon,
+				t.tm_mday,
+				t.tm_hour,
+				t.tm_min,
+			)
+
 class TwUnspentOutputs(MMGenObject):
 
 	def __new__(cls,*args,**kwargs):
@@ -49,14 +77,23 @@ class TwUnspentOutputs(MMGenObject):
 	prompt_fs = 'Total to spend, excluding fees: {} {}\n\n'
 	prompt = """
 Sort options: [t]xid, [a]mount, a[d]dress, [A]ge, [r]everse, [M]mgen addr
-Display options: show [D]ays, [g]roup, show [m]mgen addr, r[e]draw screen
+Display options: show [D]ays, [g]roup, [m]mgen addr, e[x]act age; r[e]draw
 Actions: [q]uit view, [p]rint to file, pager [v]iew, [w]ide view, add [l]abel:
 """
 	key_mappings = {
 		't':'s_txid','a':'s_amt','d':'s_addr','A':'s_age','r':'d_reverse','M':'s_twmmid',
-		'D':'d_days','g':'d_group','m':'d_mmid','e':'d_redraw',
+		'D':'d_days','g':'d_group','m':'d_mmid','x':'d_exact_age','e':'d_redraw',
 		'q':'a_quit','p':'a_print','v':'a_view','w':'a_view_wide','l':'a_lbl_add' }
 	col_adj = 38
+	age_fmts = ('confs','block','days','date','date_time')
+	age_fmts_ia = ('confs','block','days','date')
+	_age_fmt = 'confs'
+	age_precs = ('approx','exact')
+	age_prec = 'approx'
+	age_prec_disp = {
+		'approx': '(≈)',
+		'exact': '',
+	}
 
 	class MMGenTwOutputList(list,MMGenObject): pass
 
@@ -69,8 +106,8 @@ Actions: [q]uit view, [p]rint to file, pager [v]iew, [w]ide view, add [l]abel:
 		twmmid   = MMGenImmutableAttr('twmmid','TwMMGenID')
 		addr     = MMGenImmutableAttr('addr','CoinAddr')
 		confs    = MMGenImmutableAttr('confs',int,typeconv=False)
+		date     = MMGenListItemAttr('date',int,typeconv=False,reassign_ok=True)
 		scriptPubKey = MMGenImmutableAttr('scriptPubKey','HexStr')
-		days    = MMGenListItemAttr('days',int,typeconv=False)
 		skip    = MMGenListItemAttr('skip',str,typeconv=False,reassign_ok=True)
 
 	wmsg = {
@@ -90,7 +127,6 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 		self.show_mmid    = True
 		self.minconf      = minconf
 		self.addrs        = addrs
-		self.age_fmt      = 'days'
 		self.sort_key     = 'age'
 		self.disp_prec    = self.get_display_precision()
 
@@ -98,16 +134,14 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 		self.get_unspent_data()
 		self.do_sort()
 
-
 	@property
 	def age_fmt(self):
 		return self._age_fmt
 
 	@age_fmt.setter
 	def age_fmt(self,val):
-		age_fmts = ('days','confs')
-		if val not in age_fmts:
-			raise BadAgeFormat("'{}': invalid age format (must be one of {!r})".format(val,age_fmts))
+		if val not in self.age_fmts:
+			raise BadAgeFormat("'{}': invalid age format (must be one of {!r})".format(val,self.age_fmts))
 		self._age_fmt = val
 
 	def get_display_precision(self):
@@ -137,7 +171,6 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 			us_rpc = self.get_unspent_rpc()
 
 		if not us_rpc: die(0,self.wmsg['no_spendable_outputs'])
-		confs_per_day = 60*60*24 // g.proto.secs_per_block
 		tr_rpc = []
 		lbl_id = ('account','label')['label_api' in g.rpch.caps]
 		for o in us_rpc:
@@ -147,7 +180,6 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 				o.update({
 					'twmmid': l.mmid,
 					'label':  l.comment,
-					'days':   int(o['confirmations'] // confs_per_day),
 					'amt':    g.proto.coin_amt(o['amount']),
 					'addr':   CoinAddr(o['address']),
 					'confs':  o['confirmations']
@@ -207,7 +239,7 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 		acct_w = min(max_acct_w, max(24,addr_w-10))
 		btaddr_w = addr_w - acct_w - 1
 		label_w = acct_w - mmid_w - 1
-		tx_w = min(self.txid_w,self.cols-addr_w-28-col1_w) # min=7
+		tx_w = min(self.txid_w,self.cols-addr_w-29-col1_w) # min=6 TODO
 		txdots = ('','..')[tx_w < self.txid_w]
 
 		for i in unsp: i.skip = ''
@@ -222,14 +254,22 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 		fs = {  'btc':   ' {n:%s} {t:%s} {v:2} {a} {A} {c:<}' % (col1_w,tx_w),
 				'eth':   ' {n:%s} {a} {A}' % col1_w,
 				'token': ' {n:%s} {a} {A} {A2}' % col1_w }[self.disp_type]
-		out += [fs.format(  n='Num',
-							t='TXid'.ljust(tx_w - 5) + ' Vout',
-							v='',
+		fs_hdr = ' {n:%s} {t:%s} {a} {A} {c:<}' % (col1_w,tx_w) if self.disp_type == 'btc' else fs
+		date_hdr = {
+			'confs':     lambda: 'Confs',
+			'block':     lambda: 'Block',
+			'days':      lambda: 'Age({}d)'.format(self.age_prec_disp[self.age_prec][1:2]),
+			'date':      lambda: 'Date'+self.age_prec_disp[self.age_prec],
+			'date_time': lambda: 'Date'+self.age_prec_disp[self.age_prec],
+		}
+		out += [fs_hdr.format(
+							n='Num',
+							t='TXid'.ljust(tx_w - 2) + ' Vout',
 							a='Address'.ljust(addr_w),
-							A='Amt({})'.format(g.dcoin).ljust(self.disp_prec+3),
+							A='Amt({})'.format(g.dcoin).ljust(self.disp_prec+5),
 							A2=' Amt({})'.format(g.coin).ljust(self.disp_prec+4),
-							c=('Confs','Age(d)')[self.age_fmt=='days']
-							).rstrip()]
+							c = date_hdr[self.age_fmt](),
+						).rstrip()]
 
 		for n,i in enumerate(unsp):
 			addr_dots = '|' + '.'*(addr_w-1)
@@ -254,29 +294,31 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 									a=addr_out,
 									A=i.amt.fmt(color=True,prec=self.disp_prec),
 									A2=(i.amt2.fmt(color=True,prec=self.disp_prec) if i.amt2 is not None else ''),
-									c=i.days if self.age_fmt == 'days' else i.confs
+									c=self.age_disp(i,self.age_fmt),
 									).rstrip())
 
 		self.fmt_display = '\n'.join(out) + '\n'
 		return self.fmt_display
 
-	def format_for_printing(self,color=False):
+	def format_for_printing(self,color=False,show_confs=True):
 
 		addr_w = max(len(i.addr) for i in self.unspent)
 		mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in self.unspent) or 12 # DEADBEEF:S:1
-		amt_w = g.proto.coin_amt.max_prec + 4
-		fs = {  'btc':   ' {n:4} {t:%s} {a} {m} {A:%s} {c:<8} {g:<6} {l}' % (self.txid_w+3,amt_w),
+		amt_w = g.proto.coin_amt.max_prec + 5
+		cfs = '{c:<8} ' if show_confs else ''
+		fs = {  'btc': (' {n:4} {t:%s} {a} {m} {A:%s} ' + cfs + '{b:<8} {D:<19} {l}') % (self.txid_w+3,amt_w),
 				'eth':   ' {n:4} {a} {m} {A:%s} {l}' % amt_w,
 				'token': ' {n:4} {a} {m} {A:%s} {A2:%s} {l}' % (amt_w,amt_w)
 				}[self.disp_type]
 		out = [fs.format(   n='Num',
 							t='Tx ID,Vout',
 							a='Address'.ljust(addr_w),
-							m='MMGen ID'.ljust(mmid_w+1),
-							A='Amount({})'.format(g.dcoin).ljust(amt_w+1),
+							m='MMGen ID'.ljust(mmid_w),
+							A='Amount({})'.format(g.dcoin),
 							A2='Amount({})'.format(g.coin),
 							c='Confs',  # skipped for eth
-							g='Age(d)', # skipped for eth
+							b='Block',  # skipped for eth
+							D='Date'+self.age_prec_disp[self.age_prec], # skipped for eth
 							l='Label')]
 
 		max_lbl_len = max([len(i.label) for i in self.unspent if i.label] or [2])
@@ -291,14 +333,16 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 						A=i.amt.fmt(color=color),
 						A2=(i.amt2.fmt(color=color) if i.amt2 is not None else ''),
 						c=i.confs,
-						g=i.days,
+						b=g.rpch.blockcount - (i.confs - 1),
+						D=self.age_disp(i,'date_time'),
 						l=i.label.hl(color=color) if i.label else
 							TwComment.fmtc('',color=color,nullrepl='-',width=max_lbl_len)).rstrip())
 
-		fs = '{} ({} UTC)\nSort order: {}\n{}\n\nTotal {}: {}\n'
+		fs = '{} (block #{}, {} UTC)\nSort order: {}\n{}\n\nTotal {}: {}\n'
 		self.fmt_print = fs.format(
 				capfirst(self.desc),
-				make_timestr(),
+				g.rpch.blockcount,
+				make_timestr(g.rpch.cur_date),
 				' '.join(self.sort_info(include_group=False)),
 				'\n'.join(out),
 				g.dcoin,
@@ -359,11 +403,16 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 			if action[:2] == 's_':
 				self.do_sort(action[2:])
 				if action == 's_twmmid': self.show_mmid = True
-			elif action == 'd_days': self.age_fmt = ('days','confs')[self.age_fmt=='days']
+			elif action == 'd_days':
+				af = self.age_fmts_ia
+				self.age_fmt = af[(af.index(self.age_fmt) + 1) % len(af)]
 			elif action == 'd_mmid': self.show_mmid = not self.show_mmid
 			elif action == 'd_group':
 				if self.can_group:
 					self.group = not self.group
+			elif action == 'd_exact_age':
+				ap = self.age_precs
+				self.age_prec = ap[(ap.index(self.age_prec) + 1) % len(ap)]
 			elif action == 'd_redraw': pass
 			elif action == 'd_reverse': self.unspent.reverse(); self.reverse = not self.reverse
 			elif action == 'a_quit': msg(''); return self.unspent
@@ -412,12 +461,33 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 					msg_r(CUR_RIGHT(len(prompt.split('\n')[-1])-2))
 					no_output = True
 
+	def age_disp(self,o,age_fmt):
+		if age_fmt == 'confs':
+			return o.confs
+		elif age_fmt == 'block':
+			return g.rpch.blockcount - (o.confs - 1)
+		else:
+			if self.age_prec == 'approx':
+				date = _confs2date_approx(o)
+			else:
+				if o.date == None:
+					o.date = _confs2date_exact(o)
+				date = o.date
+			return {
+				'days': _date2days(date),
+				'date': _format_date(date),
+				'date_time': _format_date_time(date),
+			}[age_fmt]
+
 class TwAddrList(MMGenDict):
 
+	age_fmts = TwUnspentOutputs.age_fmts
+	age_disp = TwUnspentOutputs.age_disp
+
 	def __new__(cls,*args,**kwargs):
 		return MMGenDict.__new__(altcoin_subclass(cls,'tw','TwAddrList'),*args,**kwargs)
 
-	def __init__(self,usr_addr_list,minconf,showempty,showbtcaddrs,all_labels,wallet=None):
+	def __init__(self,usr_addr_list,minconf,showempty,showbtcaddrs,all_labels,exact_age,wallet=None):
 
 		def check_dup_mmid(acct_labels):
 			mmid_prev,err = None,False
@@ -440,6 +510,7 @@ class TwAddrList(MMGenDict):
 						msg("'{}': more than one {} address in account!".format(addrs,g.coin))
 			if err: rdie(3,'Tracking wallet is corrupted!')
 
+		self.age_prec = 'exact' if exact_age else 'approx'
 		self.total = g.proto.coin_amt('0')
 		rpc_init()
 
@@ -449,17 +520,21 @@ class TwAddrList(MMGenDict):
 			if d['confirmations'] < minconf: continue
 			label = get_tw_label(d[lbl_id])
 			if label:
-				if usr_addr_list and (label.mmid not in usr_addr_list): continue
-				if label.mmid in self:
-					if self[label.mmid]['addr'] != d['address']:
+				lm = label.mmid
+				if usr_addr_list and (lm not in usr_addr_list):
+					continue
+				if lm in self:
+					if self[lm]['addr'] != d['address']:
 						die(2,'duplicate {} address ({}) for this MMGen address! ({})'.format(
-								g.coin,d['address'],self[label.mmid]['addr']))
+								g.coin,d['address'],self[lm]['addr']))
 				else:
-					self[label.mmid] = {'amt': g.proto.coin_amt('0'),
-										'lbl':  label,
-										'addr': CoinAddr(d['address'])}
-					self[label.mmid]['lbl'].mmid.confs = d['confirmations']
-				self[label.mmid]['amt'] += d['amount']
+					lm.confs = d['confirmations']
+					lm.txid = d['txid']
+					lm.date = None
+					self[lm] = {'amt': g.proto.coin_amt('0'),
+								'lbl': label,
+								'addr': CoinAddr(d['address'])}
+				self[lm]['amt'] += d['amount']
 				self.total += d['amount']
 
 		# We use listaccounts only for empty addresses, as it shows false positive balances
@@ -493,9 +568,8 @@ class TwAddrList(MMGenDict):
 	def coinaddr_list(self): return [self[k]['addr'] for k in self]
 
 	def format(self,showbtcaddrs,sort,show_age,age_fmt):
-		age_fmts = ('days','confs')
-		if age_fmt not in age_fmts:
-			raise BadAgeFormat("'{}': invalid age format (must be one of {!r})".format(age_fmt,age_fmts))
+		if age_fmt not in self.age_fmts:
+			raise BadAgeFormat("'{}': invalid age format (must be one of {!r})".format(age_fmt,self.age_fmts))
 		out = ['Chain: '+green(g.chain.upper())] if g.chain != 'mainnet' else []
 		fs = '{mid}' + ('',' {addr}')[showbtcaddrs] + ' {cmt} {amt}' + ('',' {age}')[show_age]
 		mmaddrs = [k for k in self.keys() if k.type == 'mmgen']
@@ -510,7 +584,7 @@ class TwAddrList(MMGenDict):
 				addr=(CoinAddr.fmtc('ADDRESS',width=addr_width) if showbtcaddrs else None),
 				cmt=TwComment.fmtc('COMMENT',width=max_cmt_width+1),
 				amt='BALANCE'.ljust(max_fp_len+4),
-				age=('CONFS','DAYS')[age_fmt=='days'],
+				age=age_fmt.upper()
 				).rstrip()]
 
 		def sort_algo(j):
@@ -518,13 +592,12 @@ class TwAddrList(MMGenDict):
 				return '{}_{:>012}_{}'.format(
 					j.obj.rsplit(':',1)[0],
 					# Hack, but OK for the foreseeable future:
-					(1000000000-j.confs if hasattr(j,'confs') and j.confs != None else 0),
+					(1000000000-(j.confs or 0) if hasattr(j,'confs') else 0),
 					j.sort_key)
 			else:
 				return j.sort_key
 
 		al_id_save = None
-		confs_per_day = 60*60*24 // g.proto.secs_per_block
 		for mmid in sorted(self,key=sort_algo,reverse=bool(sort and 'reverse' in sort)):
 			if mmid.type == 'mmgen':
 				if al_id_save and al_id_save != mmid.obj.al_id:
@@ -542,7 +615,7 @@ class TwAddrList(MMGenDict):
 				addr=(e['addr'].fmt(color=True,width=addr_width) if showbtcaddrs 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=mmid.confs // (1,confs_per_day)[age_fmt=='days'] if hasattr(mmid,'confs') and mmid.confs != None else '-'
+				age=self.age_disp(mmid,age_fmt) if hasattr(mmid,'confs') and mmid.confs != None else '-'
 				).rstrip())
 
 		return '\n'.join(out + ['\nTOTAL: {} {}'.format(self.total.hl(color=True),g.dcoin)])
@@ -729,7 +802,7 @@ class TrackingWallet(MMGenObject):
 			msg('Data is unchanged\n')
 
 	def is_in_wallet(self,addr):
-		return addr in TwAddrList([],0,True,True,True,wallet=self).coinaddr_list()
+		return addr in TwAddrList([],0,True,True,True,False,wallet=self).coinaddr_list()
 
 	@write_mode
 	def set_label(self,coinaddr,lbl):

+ 2 - 4
mmgen/util.py

@@ -297,13 +297,11 @@ def decode_timestamp(s):
 
 def make_timestamp(secs=None):
 	t = int(secs) if secs else time.time()
-	tv = time.gmtime(t)[:6]
-	return '{:04d}{:02d}{:02d}_{:02d}{:02d}{:02d}'.format(*tv)
+	return '{:04d}{:02d}{:02d}_{:02d}{:02d}{:02d}'.format(*time.gmtime(t)[:6])
 
 def make_timestr(secs=None):
 	t = int(secs) if secs else time.time()
-	tv = time.gmtime(t)[:6]
-	return '{:04d}/{:02d}/{:02d} {:02d}:{:02d}:{:02d}'.format(*tv)
+	return '{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}'.format(*time.gmtime(t)[:6])
 
 def secs_to_dhms(secs):
 	dsecs = secs//3600

+ 0 - 1
test/objattrtest_py_d/oat_btc_mainnet.py

@@ -116,7 +116,6 @@ tests = {
 		'addr':         (0b001, CoinAddr),
 		'confs':        (0b001, int),
 		'scriptPubKey': (0b001, HexStr),
-		'days':         (0b001, int),
 		'skip':         (0b101, str),
 		},
 		[],

+ 1 - 1
test/test_py_d/ts_main.py

@@ -309,7 +309,7 @@ class TestSuiteMain(TestSuiteBase,TestSuiteShared):
 			'address': coinaddr,
 			'spendable': False,
 			'scriptPubKey': '{}{}{}'.format(s_beg,coinaddr.hex,s_end),
-			'confirmations': getrandnum(3) // 2 # max: 8388608 (7 digits)
+			'confirmations': getrandnum(3) // 20 # max: 838860 (6 digits)
 		}
 		return ret
 

+ 71 - 0
test/test_py_d/ts_regtest.py

@@ -31,6 +31,9 @@ from mmgen.seed import Wallet
 from ..include.common import *
 from .common import *
 
+pat_date = r'\b\d\d-\d\d-\d\d\b'
+pat_date_time = r'\b\d\d\d\d-\d\d-\d\d\s+\d\d:\d\d\b'
+
 dfl_wcls = Wallet
 rt_pw = 'abc-α'
 rt_data = {
@@ -225,6 +228,17 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 		('alice_add_label_badaddr2', 'adding a label with invalid address for this chain'),
 		('alice_add_label_badaddr3', 'adding a label with wrong MMGen address'),
 		('alice_add_label_badaddr4', 'adding a label with wrong coin address'),
+		('alice_listaddresses',                 'listaddresses'),
+		('alice_listaddresses_days',            'listaddresses (age_fmt=days)'),
+		('alice_listaddresses_date',            'listaddresses (age_fmt=date)'),
+		('alice_listaddresses_date_time',       'listaddresses (age_fmt=date_time)'),
+		('alice_listaddresses_date_time_exact', 'listaddresses (age_fmt=date_time exact_age=1)'),
+		('alice_twview',                 'twview'),
+		('alice_twview_days',            'twview (age_fmt=days)'),
+		('alice_twview_date',            'twview (age_fmt=date)'),
+		('alice_twview_date_time',       'twview (age_fmt=date_time)'),
+		('alice_twview_date_time_exact', 'twview (age_fmt=date_time exact_age=1)'),
+		('alice_txcreate_info',          'txcreate -i'),
 
 		('stop',                     'stopping regtest daemon'),
 	)
@@ -841,6 +855,63 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 		t.expect(r'\[q\]uit view, .*?:.','q',regex=True)
 		return t
 
+	def alice_listaddresses(self,args=[],expect=r'500\b'):
+		t = self.spawn('mmgen-tool',['--alice','listaddresses','showempty=1'] + args)
+		t.expect(expect,regex=True)
+		t.read()
+		return t
+
+	def alice_listaddresses_days(self):
+		return self.alice_listaddresses(args=['age_fmt=days'],expect=r'500\s+\d+\b')
+
+	def alice_listaddresses_date(self):
+		return self.alice_listaddresses(args=['age_fmt=date'],expect=r'500\s+'+pat_date)
+
+	def alice_listaddresses_date_time(self):
+		return self.alice_listaddresses(
+			args=['age_fmt=date_time'],
+			expect=r'500\s+'+pat_date_time)
+
+	def alice_listaddresses_date_time_exact(self):
+		return self.alice_listaddresses(
+			args=['age_fmt=date_time','exact_age=1'],
+			expect=r'500\s+'+pat_date_time)
+
+	def alice_twview(self,args=[],expect=r'500\s+\d+\b'):
+		t = self.spawn('mmgen-tool',['--alice','twview'] + args)
+		t.expect(expect,regex=True)
+		t.read()
+		return t
+
+	def alice_twview_days(self):
+		return self.alice_twview(args=['age_fmt=days'],expect=r'500\s+\d+\b')
+
+	def alice_twview_date(self):
+		return self.alice_twview(args=['age_fmt=date'],expect=r'500\s+'+pat_date)
+
+	def alice_twview_date_time(self):
+		return self.alice_twview(args=['age_fmt=date_time'],expect=r'500\s+'+pat_date_time)
+
+	def alice_twview_date_time_exact(self):
+		return self.alice_twview(
+			args=['age_fmt=date_time','exact_age=1'],
+			expect=r'500\s+'+pat_date_time)
+
+	def alice_txcreate_info(self,args=[]):
+		t = self.spawn('mmgen-txcreate',['--alice','-Bi'])
+		for e,s in (
+				(r'500\s+\d+\b','D'),
+				(r'500\s+\d+\b','D'),
+				(r'500\s+\d+\b','D'),
+				(r'500\s+'+pat_date,'w'),
+				(r'500\s+\d+\s+\d+\s+'+pat_date_time,'x'),
+				(r'500\s+'+pat_date,'w'),
+				(r'500\s+\d+\s+\d+\s+'+pat_date_time,'q'),
+			):
+			t.expect(e,s,regex=True)
+		t.read()
+		return t
+
 	def stop(self):
 		if opt.no_daemon_stop:
 			self.spawn('',msg_only=True)