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
b671453c13

+ 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')
 		twmmid = MMGenImmutableAttr('twmmid','TwMMGenID')
 		addr   = MMGenImmutableAttr('addr','CoinAddr')
 		addr   = MMGenImmutableAttr('addr','CoinAddr')
 		confs  = MMGenImmutableAttr('confs',int,typeconv=False)
 		confs  = MMGenImmutableAttr('confs',int,typeconv=False)
-		days   = MMGenListItemAttr('days',int,typeconv=False)
 		skip   = MMGenListItemAttr('skip',str,typeconv=False,reassign_ok=True)
 		skip   = MMGenListItemAttr('skip',str,typeconv=False,reassign_ok=True)
 
 
+	def age_disp(self,o,age_fmt): # TODO
+		return None
+
 class EthereumTokenTwUnspentOutputs(EthereumTwUnspentOutputs):
 class EthereumTokenTwUnspentOutputs(EthereumTwUnspentOutputs):
 
 
 	disp_type = 'token'
 	disp_type = 'token'
@@ -322,7 +324,7 @@ class EthereumTokenTwUnspentOutputs(EthereumTwUnspentOutputs):
 
 
 class EthereumTwAddrList(TwAddrList):
 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')
 		self.wallet = wallet or TrackingWallet(mode='w')
 		tw_dict = self.wallet.mmid_ordered_dict
 		tw_dict = self.wallet.mmid_ordered_dict

+ 2 - 0
mmgen/protocol.py

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

+ 20 - 3
mmgen/rpc.py

@@ -232,6 +232,7 @@ class RPCConnection(MMGenObject):
 		'getblockchaininfo',
 		'getblockchaininfo',
 		'getblockcount',
 		'getblockcount',
 		'getblockhash',
 		'getblockhash',
+		'getblockheader',
 		'getmempoolinfo',
 		'getmempoolinfo',
 		'getmempoolentry',
 		'getmempoolentry',
 		'getnettotals',
 		'getnettotals',
@@ -257,6 +258,8 @@ class EthereumRPCConnection(RPCConnection):
 
 
 	auth = False
 	auth = False
 	db_fs = '    host [{h}] port [{p}]\n'
 	db_fs = '    host [{h}] port [{p}]\n'
+	_blockcount = None
+	_cur_date = None
 
 
 	rpcmethods = (
 	rpcmethods = (
 		'eth_accounts',
 		'eth_accounts',
@@ -268,7 +271,6 @@ class EthereumRPCConnection(RPCConnection):
 		'eth_gasPrice',
 		'eth_gasPrice',
 		'eth_getBalance',
 		'eth_getBalance',
 		'eth_getBlockByHash',
 		'eth_getBlockByHash',
-		'eth_getBlockByNumber',
 		'eth_getCode',
 		'eth_getCode',
 		'eth_getTransactionByHash',
 		'eth_getTransactionByHash',
 		'eth_getTransactionReceipt',
 		'eth_getTransactionReceipt',
@@ -285,6 +287,7 @@ class EthereumRPCConnection(RPCConnection):
 		'parity_composeTransaction',
 		'parity_composeTransaction',
 		'parity_gasCeilTarget',
 		'parity_gasCeilTarget',
 		'parity_gasFloorTarget',
 		'parity_gasFloorTarget',
+		'parity_getBlockHeaderByNumber',
 		'parity_localTransactions',
 		'parity_localTransactions',
 		'parity_minGasPrice',
 		'parity_minGasPrice',
 		'parity_mode',
 		'parity_mode',
@@ -297,6 +300,19 @@ class EthereumRPCConnection(RPCConnection):
 		'parity_versionInfo',
 		'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):
 class MoneroWalletRPCConnection(RPCConnection):
 
 
 	rpcmethods = (
 	rpcmethods = (
@@ -390,7 +406,6 @@ def init_daemon_parity():
 	conn = EthereumRPCConnection(
 	conn = EthereumRPCConnection(
 				g.rpc_host or 'localhost',
 				g.rpc_host or 'localhost',
 				g.rpc_port or g.proto.rpc_port)
 				g.rpc_port or g.proto.rpc_port)
-
 	conn.daemon_version = conn.parity_versionInfo()['version'] # fail immediately if daemon is geth
 	conn.daemon_version = conn.parity_versionInfo()['version'] # fail immediately if daemon is geth
 	conn.coin_amt_type = str
 	conn.coin_amt_type = str
 	g.chain = conn.parity_chain().replace(' ','_')
 	g.chain = conn.parity_chain().replace(' ','_')
@@ -415,7 +430,7 @@ def init_daemon_bitcoind():
 
 
 	def check_chainfork_mismatch(conn):
 	def check_chainfork_mismatch(conn):
 		block0 = conn.getblockhash(0)
 		block0 = conn.getblockhash(0)
-		latest = conn.getblockcount()
+		latest = conn.blockcount
 		try:
 		try:
 			assert block0 == g.proto.block0,'Incorrect Genesis block for {}'.format(g.proto.__name__)
 			assert block0 == g.proto.block0,'Incorrect Genesis block for {}'.format(g.proto.__name__)
 			for fork in g.proto.forks:
 			for fork in g.proto.forks:
@@ -446,6 +461,8 @@ def init_daemon_bitcoind():
 		from mmgen.regtest import MMGenRegtest
 		from mmgen.regtest import MMGenRegtest
 		MMGenRegtest(g.coin).switch_user(('alice','bob')[g.bob],quiet=True)
 		MMGenRegtest(g.coin).switch_user(('alice','bob')[g.bob],quiet=True)
 	conn.daemon_version = int(conn.getnetworkinfo()['version'])
 	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]
 	conn.coin_amt_type = (float,str)[conn.daemon_version>=120000]
 	g.chain = conn.getblockchaininfo()['chain']
 	g.chain = conn.getblockchaininfo()['chain']
 	if g.chain != 'regtest': g.chain += 'net'
 	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']
 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):
 def _create_call_sig(cmd,parsed=False):
 
 
 	m = getattr(MMGenToolCmd,cmd)
 	m = getattr(MMGenToolCmd,cmd)
@@ -803,6 +806,8 @@ class MMGenToolCmdWallet(MMGenToolCmdBase):
 		ret = d.sec.wif if target=='wif' else d.addr
 		ret = d.sec.wif if target=='wif' else d.addr
 		return ret
 		return ret
 
 
+from mmgen.tw import TwAddrList,TwUnspentOutputs
+
 class MMGenToolCmdRPC(MMGenToolCmdBase):
 class MMGenToolCmdRPC(MMGenToolCmdBase):
 	"tracking wallet commands using the JSON-RPC interface"
 	"tracking wallet commands using the JSON-RPC interface"
 
 
@@ -817,14 +822,18 @@ class MMGenToolCmdRPC(MMGenToolCmdBase):
 					pager = False,
 					pager = False,
 					showempty = True,
 					showempty = True,
 					showbtcaddr = 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"
 		"list the specified MMGen address and its balance"
 		return self.listaddresses(  mmgen_addrs = mmgen_addr,
 		return self.listaddresses(  mmgen_addrs = mmgen_addr,
 									minconf = minconf,
 									minconf = minconf,
 									pager = pager,
 									pager = pager,
 									showempty = showempty,
 									showempty = showempty,
 									showbtcaddrs = showbtcaddr,
 									showbtcaddrs = showbtcaddr,
-									age_fmt = age_fmt)
+									age_fmt = age_fmt,
+									exact_age = exact_age,
+								)
 
 
 	def listaddresses(  self,
 	def listaddresses(  self,
 						mmgen_addrs:'(range or list)' = '',
 						mmgen_addrs:'(range or list)' = '',
@@ -834,7 +843,9 @@ class MMGenToolCmdRPC(MMGenToolCmdBase):
 						showbtcaddrs = True,
 						showbtcaddrs = True,
 						all_labels = False,
 						all_labels = False,
 						sort:'(valid options: reverse,age)' = '',
 						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"
 		"list MMGen addresses and their balances"
 		show_age = bool(age_fmt)
 		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])]
 			usr_addr_list = [MMGenID('{}:{}'.format(a[0],i)) for i in AddrIdxList(a[1])]
 
 
 		rpc_init()
 		rpc_init()
-		from mmgen.tw import TwAddrList
+		al = TwAddrList(usr_addr_list,minconf,showempty,showbtcaddrs,all_labels,exact_age)
-		al = TwAddrList(usr_addr_list,minconf,showempty,showbtcaddrs,all_labels)
 		if not al:
 		if not al:
 			die(0,('No tracked addresses with balances!','No tracked addresses!')[showempty])
 			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,
 	def twview( self,
 				pager = False,
 				pager = False,
@@ -865,16 +875,18 @@ class MMGenToolCmdRPC(MMGenToolCmdBase):
 				wide = False,
 				wide = False,
 				minconf = 1,
 				minconf = 1,
 				sort = 'age',
 				sort = 'age',
-				age_fmt:'(valid options: days,confs)' = 'days',
+				age_fmt: _options_annot_str(TwUnspentOutputs.age_fmts) = 'confs',
-				show_mmid = True):
+				exact_age = False,
+				show_mmid = True,
+				wide_show_confs = True):
 		"view tracking wallet"
 		"view tracking wallet"
 		rpc_init()
 		rpc_init()
-		from mmgen.tw import TwUnspentOutputs
 		twuo = TwUnspentOutputs(minconf=minconf)
 		twuo = TwUnspentOutputs(minconf=minconf)
 		twuo.do_sort(sort,reverse=reverse)
 		twuo.do_sort(sort,reverse=reverse)
 		twuo.age_fmt = age_fmt
 		twuo.age_fmt = age_fmt
+		twuo.age_prec = 'exact' if exact_age else 'approx'
 		twuo.show_mmid = show_mmid
 		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
 		del twuo.wallet
 		return ret
 		return ret
 
 

+ 119 - 46
mmgen/tw.py

@@ -34,6 +34,34 @@ def get_tw_label(s):
 	except BadTwComment: raise
 	except BadTwComment: raise
 	except: return None
 	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):
 class TwUnspentOutputs(MMGenObject):
 
 
 	def __new__(cls,*args,**kwargs):
 	def __new__(cls,*args,**kwargs):
@@ -49,14 +77,23 @@ class TwUnspentOutputs(MMGenObject):
 	prompt_fs = 'Total to spend, excluding fees: {} {}\n\n'
 	prompt_fs = 'Total to spend, excluding fees: {} {}\n\n'
 	prompt = """
 	prompt = """
 Sort options: [t]xid, [a]mount, a[d]dress, [A]ge, [r]everse, [M]mgen addr
 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:
 Actions: [q]uit view, [p]rint to file, pager [v]iew, [w]ide view, add [l]abel:
 """
 """
 	key_mappings = {
 	key_mappings = {
 		't':'s_txid','a':'s_amt','d':'s_addr','A':'s_age','r':'d_reverse','M':'s_twmmid',
 		'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' }
 		'q':'a_quit','p':'a_print','v':'a_view','w':'a_view_wide','l':'a_lbl_add' }
 	col_adj = 38
 	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
 	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')
 		twmmid   = MMGenImmutableAttr('twmmid','TwMMGenID')
 		addr     = MMGenImmutableAttr('addr','CoinAddr')
 		addr     = MMGenImmutableAttr('addr','CoinAddr')
 		confs    = MMGenImmutableAttr('confs',int,typeconv=False)
 		confs    = MMGenImmutableAttr('confs',int,typeconv=False)
+		date     = MMGenListItemAttr('date',int,typeconv=False,reassign_ok=True)
 		scriptPubKey = MMGenImmutableAttr('scriptPubKey','HexStr')
 		scriptPubKey = MMGenImmutableAttr('scriptPubKey','HexStr')
-		days    = MMGenListItemAttr('days',int,typeconv=False)
 		skip    = MMGenListItemAttr('skip',str,typeconv=False,reassign_ok=True)
 		skip    = MMGenListItemAttr('skip',str,typeconv=False,reassign_ok=True)
 
 
 	wmsg = {
 	wmsg = {
@@ -90,7 +127,6 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 		self.show_mmid    = True
 		self.show_mmid    = True
 		self.minconf      = minconf
 		self.minconf      = minconf
 		self.addrs        = addrs
 		self.addrs        = addrs
-		self.age_fmt      = 'days'
 		self.sort_key     = 'age'
 		self.sort_key     = 'age'
 		self.disp_prec    = self.get_display_precision()
 		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.get_unspent_data()
 		self.do_sort()
 		self.do_sort()
 
 
-
 	@property
 	@property
 	def age_fmt(self):
 	def age_fmt(self):
 		return self._age_fmt
 		return self._age_fmt
 
 
 	@age_fmt.setter
 	@age_fmt.setter
 	def age_fmt(self,val):
 	def age_fmt(self,val):
-		age_fmts = ('days','confs')
+		if val not in self.age_fmts:
-		if val not in age_fmts:
+			raise BadAgeFormat("'{}': invalid age format (must be one of {!r})".format(val,self.age_fmts))
-			raise BadAgeFormat("'{}': invalid age format (must be one of {!r})".format(val,age_fmts))
 		self._age_fmt = val
 		self._age_fmt = val
 
 
 	def get_display_precision(self):
 	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()
 			us_rpc = self.get_unspent_rpc()
 
 
 		if not us_rpc: die(0,self.wmsg['no_spendable_outputs'])
 		if not us_rpc: die(0,self.wmsg['no_spendable_outputs'])
-		confs_per_day = 60*60*24 // g.proto.secs_per_block
 		tr_rpc = []
 		tr_rpc = []
 		lbl_id = ('account','label')['label_api' in g.rpch.caps]
 		lbl_id = ('account','label')['label_api' in g.rpch.caps]
 		for o in us_rpc:
 		for o in us_rpc:
@@ -147,7 +180,6 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 				o.update({
 				o.update({
 					'twmmid': l.mmid,
 					'twmmid': l.mmid,
 					'label':  l.comment,
 					'label':  l.comment,
-					'days':   int(o['confirmations'] // confs_per_day),
 					'amt':    g.proto.coin_amt(o['amount']),
 					'amt':    g.proto.coin_amt(o['amount']),
 					'addr':   CoinAddr(o['address']),
 					'addr':   CoinAddr(o['address']),
 					'confs':  o['confirmations']
 					'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))
 		acct_w = min(max_acct_w, max(24,addr_w-10))
 		btaddr_w = addr_w - acct_w - 1
 		btaddr_w = addr_w - acct_w - 1
 		label_w = acct_w - mmid_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]
 		txdots = ('','..')[tx_w < self.txid_w]
 
 
 		for i in unsp: i.skip = ''
 		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),
 		fs = {  'btc':   ' {n:%s} {t:%s} {v:2} {a} {A} {c:<}' % (col1_w,tx_w),
 				'eth':   ' {n:%s} {a} {A}' % col1_w,
 				'eth':   ' {n:%s} {a} {A}' % col1_w,
 				'token': ' {n:%s} {a} {A} {A2}' % col1_w }[self.disp_type]
 				'token': ' {n:%s} {a} {A} {A2}' % col1_w }[self.disp_type]
-		out += [fs.format(  n='Num',
+		fs_hdr = ' {n:%s} {t:%s} {a} {A} {c:<}' % (col1_w,tx_w) if self.disp_type == 'btc' else fs
-							t='TXid'.ljust(tx_w - 5) + ' Vout',
+		date_hdr = {
-							v='',
+			'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='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),
 							A2=' Amt({})'.format(g.coin).ljust(self.disp_prec+4),
-							c=('Confs','Age(d)')[self.age_fmt=='days']
+							c = date_hdr[self.age_fmt](),
-							).rstrip()]
+						).rstrip()]
 
 
 		for n,i in enumerate(unsp):
 		for n,i in enumerate(unsp):
 			addr_dots = '|' + '.'*(addr_w-1)
 			addr_dots = '|' + '.'*(addr_w-1)
@@ -254,29 +294,31 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 									a=addr_out,
 									a=addr_out,
 									A=i.amt.fmt(color=True,prec=self.disp_prec),
 									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 ''),
 									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())
 									).rstrip())
 
 
 		self.fmt_display = '\n'.join(out) + '\n'
 		self.fmt_display = '\n'.join(out) + '\n'
 		return self.fmt_display
 		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)
 		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
 		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
+		amt_w = g.proto.coin_amt.max_prec + 5
-		fs = {  'btc':   ' {n:4} {t:%s} {a} {m} {A:%s} {c:<8} {g:<6} {l}' % (self.txid_w+3,amt_w),
+		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,
 				'eth':   ' {n:4} {a} {m} {A:%s} {l}' % amt_w,
 				'token': ' {n:4} {a} {m} {A:%s} {A2:%s} {l}' % (amt_w,amt_w)
 				'token': ' {n:4} {a} {m} {A:%s} {A2:%s} {l}' % (amt_w,amt_w)
 				}[self.disp_type]
 				}[self.disp_type]
 		out = [fs.format(   n='Num',
 		out = [fs.format(   n='Num',
 							t='Tx ID,Vout',
 							t='Tx ID,Vout',
 							a='Address'.ljust(addr_w),
 							a='Address'.ljust(addr_w),
-							m='MMGen ID'.ljust(mmid_w+1),
+							m='MMGen ID'.ljust(mmid_w),
-							A='Amount({})'.format(g.dcoin).ljust(amt_w+1),
+							A='Amount({})'.format(g.dcoin),
 							A2='Amount({})'.format(g.coin),
 							A2='Amount({})'.format(g.coin),
 							c='Confs',  # skipped for eth
 							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')]
 							l='Label')]
 
 
 		max_lbl_len = max([len(i.label) for i in self.unspent if i.label] or [2])
 		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),
 						A=i.amt.fmt(color=color),
 						A2=(i.amt2.fmt(color=color) if i.amt2 is not None else ''),
 						A2=(i.amt2.fmt(color=color) if i.amt2 is not None else ''),
 						c=i.confs,
 						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
 						l=i.label.hl(color=color) if i.label else
 							TwComment.fmtc('',color=color,nullrepl='-',width=max_lbl_len)).rstrip())
 							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(
 		self.fmt_print = fs.format(
 				capfirst(self.desc),
 				capfirst(self.desc),
-				make_timestr(),
+				g.rpch.blockcount,
+				make_timestr(g.rpch.cur_date),
 				' '.join(self.sort_info(include_group=False)),
 				' '.join(self.sort_info(include_group=False)),
 				'\n'.join(out),
 				'\n'.join(out),
 				g.dcoin,
 				g.dcoin,
@@ -359,11 +403,16 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 			if action[:2] == 's_':
 			if action[:2] == 's_':
 				self.do_sort(action[2:])
 				self.do_sort(action[2:])
 				if action == 's_twmmid': self.show_mmid = True
 				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_mmid': self.show_mmid = not self.show_mmid
 			elif action == 'd_group':
 			elif action == 'd_group':
 				if self.can_group:
 				if self.can_group:
 					self.group = not self.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_redraw': pass
 			elif action == 'd_reverse': self.unspent.reverse(); self.reverse = not self.reverse
 			elif action == 'd_reverse': self.unspent.reverse(); self.reverse = not self.reverse
 			elif action == 'a_quit': msg(''); return self.unspent
 			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))
 					msg_r(CUR_RIGHT(len(prompt.split('\n')[-1])-2))
 					no_output = True
 					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):
 class TwAddrList(MMGenDict):
 
 
+	age_fmts = TwUnspentOutputs.age_fmts
+	age_disp = TwUnspentOutputs.age_disp
+
 	def __new__(cls,*args,**kwargs):
 	def __new__(cls,*args,**kwargs):
 		return MMGenDict.__new__(altcoin_subclass(cls,'tw','TwAddrList'),*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):
 		def check_dup_mmid(acct_labels):
 			mmid_prev,err = None,False
 			mmid_prev,err = None,False
@@ -440,6 +510,7 @@ class TwAddrList(MMGenDict):
 						msg("'{}': more than one {} address in account!".format(addrs,g.coin))
 						msg("'{}': more than one {} address in account!".format(addrs,g.coin))
 			if err: rdie(3,'Tracking wallet is corrupted!')
 			if err: rdie(3,'Tracking wallet is corrupted!')
 
 
+		self.age_prec = 'exact' if exact_age else 'approx'
 		self.total = g.proto.coin_amt('0')
 		self.total = g.proto.coin_amt('0')
 		rpc_init()
 		rpc_init()
 
 
@@ -449,17 +520,21 @@ class TwAddrList(MMGenDict):
 			if d['confirmations'] < minconf: continue
 			if d['confirmations'] < minconf: continue
 			label = get_tw_label(d[lbl_id])
 			label = get_tw_label(d[lbl_id])
 			if label:
 			if label:
-				if usr_addr_list and (label.mmid not in usr_addr_list): continue
+				lm = label.mmid
-				if label.mmid in self:
+				if usr_addr_list and (lm not in usr_addr_list):
-					if self[label.mmid]['addr'] != d['address']:
+					continue
+				if lm in self:
+					if self[lm]['addr'] != d['address']:
 						die(2,'duplicate {} address ({}) for this MMGen address! ({})'.format(
 						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:
 				else:
-					self[label.mmid] = {'amt': g.proto.coin_amt('0'),
+					lm.confs = d['confirmations']
-										'lbl':  label,
+					lm.txid = d['txid']
-										'addr': CoinAddr(d['address'])}
+					lm.date = None
-					self[label.mmid]['lbl'].mmid.confs = d['confirmations']
+					self[lm] = {'amt': g.proto.coin_amt('0'),
-				self[label.mmid]['amt'] += d['amount']
+								'lbl': label,
+								'addr': CoinAddr(d['address'])}
+				self[lm]['amt'] += d['amount']
 				self.total += d['amount']
 				self.total += d['amount']
 
 
 		# We use listaccounts only for empty addresses, as it shows false positive balances
 		# 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 coinaddr_list(self): return [self[k]['addr'] for k in self]
 
 
 	def format(self,showbtcaddrs,sort,show_age,age_fmt):
 	def format(self,showbtcaddrs,sort,show_age,age_fmt):
-		age_fmts = ('days','confs')
+		if age_fmt not in self.age_fmts:
-		if age_fmt not in age_fmts:
+			raise BadAgeFormat("'{}': invalid age format (must be one of {!r})".format(age_fmt,self.age_fmts))
-			raise BadAgeFormat("'{}': invalid age format (must be one of {!r})".format(age_fmt,age_fmts))
 		out = ['Chain: '+green(g.chain.upper())] if g.chain != 'mainnet' else []
 		out = ['Chain: '+green(g.chain.upper())] if g.chain != 'mainnet' else []
 		fs = '{mid}' + ('',' {addr}')[showbtcaddrs] + ' {cmt} {amt}' + ('',' {age}')[show_age]
 		fs = '{mid}' + ('',' {addr}')[showbtcaddrs] + ' {cmt} {amt}' + ('',' {age}')[show_age]
 		mmaddrs = [k for k in self.keys() if k.type == 'mmgen']
 		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),
 				addr=(CoinAddr.fmtc('ADDRESS',width=addr_width) if showbtcaddrs else None),
 				cmt=TwComment.fmtc('COMMENT',width=max_cmt_width+1),
 				cmt=TwComment.fmtc('COMMENT',width=max_cmt_width+1),
 				amt='BALANCE'.ljust(max_fp_len+4),
 				amt='BALANCE'.ljust(max_fp_len+4),
-				age=('CONFS','DAYS')[age_fmt=='days'],
+				age=age_fmt.upper()
 				).rstrip()]
 				).rstrip()]
 
 
 		def sort_algo(j):
 		def sort_algo(j):
@@ -518,13 +592,12 @@ class TwAddrList(MMGenDict):
 				return '{}_{:>012}_{}'.format(
 				return '{}_{:>012}_{}'.format(
 					j.obj.rsplit(':',1)[0],
 					j.obj.rsplit(':',1)[0],
 					# Hack, but OK for the foreseeable future:
 					# 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)
 					j.sort_key)
 			else:
 			else:
 				return j.sort_key
 				return j.sort_key
 
 
 		al_id_save = None
 		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)):
 		for mmid in sorted(self,key=sort_algo,reverse=bool(sort and 'reverse' in sort)):
 			if mmid.type == 'mmgen':
 			if mmid.type == 'mmgen':
 				if al_id_save and al_id_save != mmid.obj.al_id:
 				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),
 				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='-'),
 				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),
 				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())
 				).rstrip())
 
 
 		return '\n'.join(out + ['\nTOTAL: {} {}'.format(self.total.hl(color=True),g.dcoin)])
 		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')
 			msg('Data is unchanged\n')
 
 
 	def is_in_wallet(self,addr):
 	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
 	@write_mode
 	def set_label(self,coinaddr,lbl):
 	def set_label(self,coinaddr,lbl):

+ 2 - 4
mmgen/util.py

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

+ 0 - 1
test/objattrtest_py_d/oat_btc_mainnet.py

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

+ 1 - 1
test/test_py_d/ts_main.py

@@ -309,7 +309,7 @@ class TestSuiteMain(TestSuiteBase,TestSuiteShared):
 			'address': coinaddr,
 			'address': coinaddr,
 			'spendable': False,
 			'spendable': False,
 			'scriptPubKey': '{}{}{}'.format(s_beg,coinaddr.hex,s_end),
 			'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
 		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 ..include.common import *
 from .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
 dfl_wcls = Wallet
 rt_pw = 'abc-α'
 rt_pw = 'abc-α'
 rt_data = {
 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_badaddr2', 'adding a label with invalid address for this chain'),
 		('alice_add_label_badaddr3', 'adding a label with wrong MMGen address'),
 		('alice_add_label_badaddr3', 'adding a label with wrong MMGen address'),
 		('alice_add_label_badaddr4', 'adding a label with wrong coin 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'),
 		('stop',                     'stopping regtest daemon'),
 	)
 	)
@@ -841,6 +855,63 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 		t.expect(r'\[q\]uit view, .*?:.','q',regex=True)
 		t.expect(r'\[q\]uit view, .*?:.','q',regex=True)
 		return t
 		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):
 	def stop(self):
 		if opt.no_daemon_stop:
 		if opt.no_daemon_stop:
 			self.spawn('',msg_only=True)
 			self.spawn('',msg_only=True)