Browse Source

new feature: transaction history via `mmgen-tool txhist`

Display nicely formatted info about all transactions known to the tracking
wallet.

Interactive mode presents the user with an interface similar to `mmgen-tool
twview` or `mmgen-txcreate -i`, providing various sort, filter, column format
and printing options.

`--coin=ltc` and `--coin=bch` are also supported.  Use of `--rpc-backend=aio`
speeds up operation significantly under Linux.

Usage examples:

    # Non-interactive mode, tabular output:
    $ mmgen-tool txhist

    # Non-interactive mode, full output:
    $ mmgen-tool txhist detail=1

    # Show only transactions newer than 100000 blocks from chain tip:
    $ mmgen-tool txhist sinceblock=-100000

    # Interactive mode:
    $ mmgen-tool txhist interactive=1

Testing/demo:

    $ test/test.py -n -X bob_txhist1 regtest
    $ test/test.py -Se regtest:bob_txhist1
    $ test/test.py -Se regtest:bob_txhist2
    $ test/test.py -Se regtest:bob_txhist3
    $ test/test.py -Se regtest:bob_txhist4
    $ test/test.py -Se regtest:bob_txhist_interactive
The MMGen Project 2 years ago
parent
commit
7d216564

+ 366 - 0
mmgen/base_proto/bitcoin/tw/txhistory.py

@@ -0,0 +1,366 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
+# Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen
+#   https://gitlab.com/mmgen/mmgen
+
+"""
+base_proto.bitcoin.tw.txhistory: Bitcoin base protocol tracking wallet transaction history class
+"""
+
+from collections import namedtuple
+from ....globalvars import g
+from ....tw.txhistory import TwTxHistory
+from ....tw.common import get_tw_label,TwMMGenID
+from ....addr import CoinAddr
+from ....util import msg,msg_r,remove_dups
+from ....color import nocolor,red,pink,gray
+from ....obj import TwComment,CoinTxID,Int
+from .common import BitcoinTwCommon
+
+class BitcoinTwTransaction(BitcoinTwCommon):
+
+	def __init__(self,parent,proto,rpc,
+			idx,          # unique numeric identifier of this transaction in listing
+			unspent_info, # addrs in wallet with balances: { 'mmid': {'addr','label','amt'} }
+			mm_map,       # all addrs in wallet: ['addr', ['twmmid','label']]
+			tx,           # the decoded transaction data
+			wallet_vouts, # list of ints - wallet-related vouts
+			prevouts,     # list of (txid,vout) pairs
+			prevout_txs   # decoded transaction data for prevouts
+		):
+
+		self.parent       = parent
+		self.proto        = proto
+		self.rpc          = rpc
+		self.unspent_info = unspent_info
+		self.tx           = tx
+
+		def gen_prevouts_data():
+			_d = namedtuple('prevout_data',['txid','data'])
+			for tx in prevout_txs:
+				for e in prevouts:
+					if e.txid == tx['txid']:
+						yield _d( e.txid, tx['vout'][e.vout] )
+
+		def gen_wallet_vouts_data():
+			_d = namedtuple('wallet_vout_data',['txid','data'])
+			txid = self.tx['txid']
+			vouts = self.tx['decoded']['vout']
+			for n in wallet_vouts:
+				yield _d( txid, vouts[n] )
+
+		def gen_vouts_info(data):
+			_d = namedtuple('vout_info',['txid','coin_addr','twlabel','data'])
+			def gen():
+				for d in data:
+					addr = d.data['scriptPubKey'].get('address') or d.data['scriptPubKey']['addresses'][0]
+					yield _d(
+						txid = d.txid,
+						coin_addr = addr,
+						twlabel = mm_map[addr] if (addr in mm_map and mm_map[addr].twmmid) else None,
+						data = d.data )
+			return sorted(
+				gen(),
+				key = lambda d: d.twlabel.twmmid.sort_key if d.twlabel else 'zz_' + d.coin_addr )
+
+		def gen_all_addrs(src):
+			for e in self.vouts_info[src]:
+				if e.twlabel:
+					mmid = e.twlabel.twmmid
+					yield (
+						(mmid if mmid.type == 'mmgen' else mmid.split(':',1)[1]) +
+						('*' if mmid in self.unspent_info else '')
+					)
+				else:
+					yield e.coin_addr
+
+		def total(data):
+			return self.proto.coin_amt( sum(d.data['value'] for d in data) )
+
+		def get_best_label():
+			"""
+			find the most relevant label for tabular (squeezed) display
+			"""
+			def vouts_labels(src):
+				return [ d.twlabel.label for d in self.vouts_info[src] if d.twlabel and d.twlabel.label ]
+			ret = vouts_labels('outputs') or vouts_labels('inputs')
+			return ret[0] if ret else TwComment('')
+
+		# 'outputs' refers to wallet-related outputs only
+		self.vouts_info = {
+			'inputs':  gen_vouts_info( gen_prevouts_data() ),
+			'outputs': gen_vouts_info( gen_wallet_vouts_data() )
+		}
+		self.max_addrlen = {
+			'inputs':  max(len(addr) for addr in gen_all_addrs('inputs')),
+			'outputs': max(len(addr) for addr in gen_all_addrs('outputs'))
+		}
+		self.inputs_total = total( self.vouts_info['inputs'] )
+		self.outputs_total = self.proto.coin_amt( sum(i['value'] for i in self.tx['decoded']['vout']) )
+		self.wallet_outputs_total = total( self.vouts_info['outputs'] )
+		self.fee = self.inputs_total - self.outputs_total
+		self.nOutputs = len(self.tx['decoded']['vout'])
+		self.confirmations = self.tx['confirmations']
+		self.label = get_best_label()
+		self.vsize = self.tx['decoded'].get('vsize') or self.tx['decoded']['size']
+		self.txid = CoinTxID(self.tx['txid'])
+		self.time = self.tx['time']
+
+	def blockheight_disp(self,color):
+		return (
+			# old/altcoin daemons return no 'blockheight' field, so use confirmations instead
+			Int( self.rpc.blockcount + 1 - self.confirmations ).hl(color=color)
+			if self.confirmations > 0 else None )
+
+	def age_disp(self,age_fmt,width,color):
+		if age_fmt == 'confs':
+			ret_str = str(self.confirmations).rjust(width)
+			return gray(ret_str) if self.confirmations < 0 and color else ret_str
+		elif age_fmt == 'block':
+			ret = (self.rpc.blockcount - (abs(self.confirmations) - 1)) * (-1 if self.confirmations < 0 else 1)
+			ret_str = str(ret).rjust(width)
+			return gray(ret_str) if ret < 0 and color else ret_str
+		else:
+			return self.parent.date_formatter[age_fmt](self.rpc,self.tx.get('blocktime',0))
+
+	def txdate_disp(self,age_fmt):
+		return self.parent.date_formatter[age_fmt](self.rpc,self.time)
+
+	def txid_disp(self,width,color):
+		return self.txid.truncate(width=width,color=color)
+
+	def vouts_list_disp(self,src,color,indent=''):
+
+		fs1,fs2 = {
+			'inputs':  ('{i},{n} {a} {A}', '{i},{n} {a} {A} {l}'),
+			'outputs': (    '{n} {a} {A}',     '{n} {a} {A} {l}')
+		}[src]
+
+		def gen_output():
+			for e in self.vouts_info[src]:
+				mmid = e.twlabel.twmmid if e.twlabel else None
+				if not mmid:
+					yield fs1.format(
+						i = CoinTxID(e.txid).hl(color=color),
+						n = (nocolor,red)[color](str(e.data['n']).ljust(3)),
+						a = CoinAddr(self.proto,e.coin_addr).fmt( width=self.max_addrlen[src], color=color ),
+						A = self.proto.coin_amt( e.data['value'] ).fmt(color=color)
+					).rstrip()
+				else:
+					bal_star,co = ('*','melon') if mmid in self.unspent_info else ('','brown')
+					addr_out = mmid if mmid.type == 'mmgen' else mmid.split(':',1)[1]
+					yield fs2.format(
+						i = CoinTxID(e.txid).hl(color=color),
+						n = (nocolor,red)[color](str(e.data['n']).ljust(3)),
+						a = TwMMGenID.hlc(
+							'{:{w}}'.format( addr_out + bal_star, w=self.max_addrlen[src] ),
+							color = color,
+							color_override = co ),
+						A = self.proto.coin_amt( e.data['value'] ).fmt(color=color),
+						l = e.twlabel.label.hl(color=color)
+					).rstrip()
+
+		return f'\n{indent}'.join( gen_output() ).strip()
+
+	def vouts_disp(self,src,width,color):
+
+		class x: space_left = width or 0
+
+		def gen_output():
+			for e in self.vouts_info[src]:
+				mmid = e.twlabel.twmmid if e.twlabel else None
+				bal_star,addr_w,co = ('*',16,'melon') if mmid in self.unspent_info else ('',15,'brown')
+				if not mmid:
+					if width and x.space_left < addr_w:
+						break
+					yield CoinAddr( self.proto, e.coin_addr ).fmt(width=addr_w,color=color)
+					x.space_left -= addr_w
+				elif mmid.type == 'mmgen':
+					mmid_disp = mmid + bal_star
+					if width and x.space_left < len(mmid_disp):
+						break
+					yield TwMMGenID.hlc( mmid_disp, color=color, color_override=co )
+					x.space_left -= len(mmid_disp)
+				else:
+					if width and x.space_left < addr_w:
+						break
+					yield TwMMGenID.hlc(
+						CoinAddr.fmtc( mmid.split(':',1)[1] + bal_star, width=addr_w ),
+						color = color,
+						color_override = co )
+					x.space_left -= addr_w
+				x.space_left -= 1
+
+		return ' '.join(gen_output()) + ' ' * (x.space_left + 1 if width else 0)
+
+	def amt_disp(self,show_total_amt):
+		return (
+			self.outputs_total if show_total_amt else
+			self.wallet_outputs_total )
+
+	def fee_disp(self,color):
+		atomic_unit = self.proto.coin_amt.units[0]
+		return '{} {}'.format(
+			self.fee.hl(color=color),
+			(nocolor,pink)[color]('({:,} {}s/byte)'.format(
+				self.fee.to_unit(atomic_unit) // self.vsize,
+				atomic_unit )) )
+
+class BitcoinTwTxHistory(TwTxHistory,BitcoinTwCommon):
+
+	has_age = True
+	hdr_fmt = 'TRANSACTION HISTORY (sort order: {a})'
+	desc = 'transaction history'
+	item_desc = 'transaction'
+	no_data_errmsg = 'No transactions in tracking wallet!'
+	prompt = """
+Sort options: [t]xid, [a]mt, total a[m]t, [A]ge, [b]locknum, [r]everse
+Column options: toggle [D]ays/date/confs/block, tx[i]d, [T]otal amt
+Filter options: show [u]nconfirmed
+View/Print: pager [v]iew, full [V]iew, screen [p]rint, full [P]rint
+Actions: [q]uit, r[e]draw:
+"""
+	key_mappings = {
+		'A':'s_age',
+		'b':'s_blockheight',
+		'a':'s_amt',
+		'm':'s_total_amt',
+		't':'s_txid',
+		'r':'d_reverse',
+		'D':'d_days',
+		'e':'d_redraw',
+		'u':'d_show_unconfirmed',
+		'i':'d_show_txid',
+		'T':'d_show_total_amt',
+		'q':'a_quit',
+		'v':'a_view',
+		'V':'a_view_detail',
+		'p':'a_print_squeezed',
+		'P':'a_print_detail' }
+
+	squeezed_fs_fs     = ' {{n:>{nw}}} {{d:>{dw}}} {txid_fs}{{a1}} {{A}} {{a2}} {{l}}'
+	squeezed_hdr_fs_fs = ' {{n:>{nw}}} {{d:{dw}}} {txid_fs}{{a1:{aw}}} {{A}} {{a2:{a2w}}} {{l}}'
+
+	async def get_rpc_data(self):
+		blockhash = (
+			await self.rpc.call( 'getblockhash', self.sinceblock )
+				if self.sinceblock else '' )
+		# bitcoin-cli help listsinceblock:
+		# Arguments:
+		# 1. blockhash            (string, optional) If set, the block hash to list transactions since,
+		#                         otherwise list all transactions.
+		# 2. target_confirmations (numeric, optional, default=1) Return the nth block hash from the main
+		#                         chain. e.g. 1 would mean the best block hash. Note: this is not used
+		#                         as a filter, but only affects [lastblock] in the return value
+		# 3. include_watchonly    (boolean, optional, default=true for watch-only wallets, otherwise
+		#                         false) Include transactions to watch-only addresses (see
+		#                         'importaddress')
+		# 4. include_removed      (boolean, optional, default=true) Show transactions that were removed
+		#                         due to a reorg in the "removed" array (not guaranteed to work on
+		#                         pruned nodes)
+		return (await self.rpc.call('listsinceblock',blockhash,1,True,False))['transactions']
+
+	async def gen_data(self,rpc_data,lbl_id):
+
+		def gen_parsed_data():
+			for o in rpc_data:
+				if lbl_id in o:
+					l = get_tw_label(self.proto,o[lbl_id])
+				else:
+					assert o['category'] == 'send', f"{o['address']}: {o['category']} != 'send'"
+					l = None
+				o.update({
+					'twmmid': l.mmid if l else None,
+					'label': (l.comment or '') if l else None,
+				})
+				yield o
+
+		data = list(gen_parsed_data())
+
+		if g.debug_tw:
+			import json
+			from ....rpc import json_encoder
+			def do_json_dump(*data):
+				nw = f'{self.proto.coin.lower()}-{self.proto.network}'
+				for d,fn_stem in data:
+					open(f'/tmp/{fn_stem}-{nw}.json','w').write(json.dumps(d,cls=json_encoder))
+
+		_mmp = namedtuple('mmap_datum',['twmmid','label'])
+
+		mm_map = {
+			i['address']: (
+				_mmp( TwMMGenID(self.proto,i['twmmid']), TwComment(i['label']) )
+					if i['twmmid'] else _mmp(None,None)
+			)
+			for i in data }
+
+		if self.sinceblock: # mapping data may be incomplete for inputs, so update from 'listlabels'
+			mm_map.update(
+				{ addr: _mmp(lbl.mmid, lbl.comment) if lbl else _mmp(None,None) for lbl,addr in
+					[(get_tw_label(self.proto,a), b) for a,b in await self.get_addr_label_pairs()] }
+			)
+
+		msg_r('Getting wallet transactions...')
+		_wallet_txs = await self.rpc.gathered_icall(
+			'gettransaction',
+			[ (i,True,True) for i in {d['txid'] for d in data} ] )
+		msg('done')
+
+		if not 'decoded' in _wallet_txs[0]:
+			_decoded_txs = iter(
+				await self.rpc.gathered_call(
+					'decoderawtransaction',
+					[ (d['hex'],) for d in _wallet_txs ] ))
+			for tx in _wallet_txs:
+				tx['decoded'] = next(_decoded_txs)
+
+		if g.debug_tw:
+			do_json_dump((_wallet_txs, 'wallet-txs'),)
+
+		_wip = namedtuple('prevout',['txid','vout'])
+		txdata = [
+			{
+				'tx': tx,
+				'wallet_vouts': sorted({i.vout for i in
+					[_wip( CoinTxID(d['txid']), d['vout'] ) for d in data]
+						if i.txid == tx['txid']}),
+				'prevouts': [_wip( CoinTxID(vin['txid']), vin['vout'] ) for vin in tx['decoded']['vin']]
+			}
+				for tx in _wallet_txs]
+
+		_prevout_txids = {i.txid for d in txdata for i in d['prevouts']}
+
+		msg_r('Getting input transactions...')
+		_prevout_txs = await self.rpc.gathered_call('getrawtransaction', [ (i,True) for i in _prevout_txids ])
+		msg('done')
+
+		_prevout_txs_dict = dict(zip(_prevout_txids,_prevout_txs))
+
+		for d in txdata:
+			d['prevout_txs'] = [_prevout_txs_dict[txid] for txid in {i.txid for i in d['prevouts']} ]
+
+		if g.debug_tw:
+			do_json_dump(
+				(rpc_data,     'txhist-rpc'),
+				(data,         'txhist'),
+				(mm_map,       'mmap'),
+				(_prevout_txs, 'prevout-txs'),
+				(txdata,       'txdata'),
+			)
+
+		unspent_info = await self.get_unspent_by_mmid()
+
+		return (
+			BitcoinTwTransaction(
+				parent       = self,
+				proto        = self.proto,
+				rpc          = self.rpc,
+				idx          = idx,
+				unspent_info = unspent_info,
+				mm_map       = mm_map,
+				**d ) for idx,d in enumerate(txdata) )

+ 2 - 1
mmgen/color.py

@@ -33,7 +33,8 @@ _colors = {
 	'gray':        (  246,      (30,1) ),
 	'purple':      (  141,      (35,1) ),
 
-	'brown':       (  208,      (33,0) ),
+	'melon':       (  222,      (33,1) ),
+	'brown':       (  173,      (33,0) ),
 	'grndim':      (  108,      (32,0) ),
 	'redbg':       ( (232,210), (30,101) ),
 	'grnbg':       ( (232,121), (30,102) ),

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-13.2.dev1
+13.2.dev2

+ 2 - 0
mmgen/globalvars.py

@@ -81,6 +81,7 @@ class GlobalContext(Lockable):
 	debug_rpc            = False
 	debug_addrlist       = False
 	debug_subseed        = False
+	debug_tw             = False
 	quiet                = False
 	no_license           = False
 	force_256_color      = False
@@ -245,6 +246,7 @@ class GlobalContext(Lockable):
 		'MMGEN_DEBUG_OPTS',
 		'MMGEN_DEBUG_RPC',
 		'MMGEN_DEBUG_ADDRLIST',
+		'MMGEN_DEBUG_TW',
 		'MMGEN_DEBUG_UTF8',
 		'MMGEN_DEBUG_SUBSEED',
 		'MMGEN_QUIET',

+ 1 - 0
mmgen/main_tool.py

@@ -162,6 +162,7 @@ mods = {
 		'remove_address',
 		'remove_label',
 		'twview',
+		'txhist',
 	),
 }
 

+ 15 - 0
mmgen/tool/rpc.py

@@ -132,6 +132,21 @@ class tool_cmd(tool_cmd_base):
 		del obj.wallet
 		return ret
 
+	async def txhist(self,
+			pager           = False,
+			reverse         = False,
+			detail          = False,
+			sinceblock      = 0,
+			sort            = 'age',
+			age_fmt: options_annot_str(TwCommon.age_fmts) = 'confs',
+			interactive     = False ):
+		"view transaction history"
+
+		from ..tw.txhistory import TwTxHistory
+		obj = await TwTxHistory(self.proto,sinceblock=sinceblock)
+		return await self.twops(
+			obj,pager,reverse,detail,sort,age_fmt,interactive,show_mmid=None)
+
 	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

+ 1 - 1
mmgen/tw/common.py

@@ -29,7 +29,7 @@ from ..color import nocolor,yellow,green
 from ..util import msg,msg_r,fmt,die,line_input,do_pager,capfirst,make_timestr
 from ..addr import MMGenID
 
-# mixin class for TwUnspentOutputs,TwAddrList:
+# mixin class for TwUnspentOutputs,TwAddrList,TwTxHistory:
 class TwCommon:
 
 	cols        = None

+ 235 - 0
mmgen/tw/txhistory.py

@@ -0,0 +1,235 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
+# Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen
+#   https://gitlab.com/mmgen/mmgen
+
+"""
+tw.txhistory: Tracking wallet transaction history class for the MMGen suite
+"""
+
+from collections import namedtuple
+
+from ..util import base_proto_subclass,fmt
+from ..base_obj import AsyncInit
+from ..objmethods import MMGenObject
+from ..obj import CoinTxID,MMGenList,Int
+from ..rpc import rpc_init
+from .common import TwCommon
+
+class TwTxHistory(MMGenObject,TwCommon,metaclass=AsyncInit):
+
+	def __new__(cls,proto,*args,**kwargs):
+		return MMGenObject.__new__(base_proto_subclass(cls,proto,'tw','txhistory'))
+
+	txid_w = 64
+	show_txid = False
+	show_unconfirmed = False
+	show_total_amt = False
+	print_hdr_fs = '{a} (block #{b}, {c} UTC)\n{d}Sort order: {e}\n{f}\n'
+	age_fmts_interactive = ('confs','block','days','date','date_time')
+	update_params_on_age_toggle = True
+	detail_display_separator = '\n\n'
+	print_output_types = ('squeezed','detail')
+
+	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 )
+
+	@property
+	def no_rpcdata_errmsg(self):
+		return 'No transaction history {}found!'.format(
+			f'from block {self.sinceblock} ' if self.sinceblock else '')
+
+	def set_column_params(self):
+		data = self.data
+		show_txid = self.show_txid
+		for d in data:
+			d.skip = ''
+
+		if not hasattr(self,'varcol_maxwidths'):
+			self.varcol_maxwidths = {
+				'addr1': max(len(d.vouts_disp('inputs',width=None,color=False)) for d in data),
+				'addr2': max(len(d.vouts_disp('outputs',width=None,color=False)) for d in data),
+				'lbl':   max(len(d.label) for d in data),
+			}
+
+		# var cols: addr1 addr2 comment [txid]
+		maxw = self.varcol_maxwidths
+
+		if show_txid:
+			txid_adj = 40 # we don't need much of the txid, so weight it less than other columns
+			maxw.update({'txid': self.txid_w - txid_adj})
+		elif 'txid' in maxw:
+			del maxw['txid']
+
+		minw = {
+			'addr1': 15,
+			'addr2': 15,
+			'lbl': len('Comment'),
+		}
+		if show_txid:
+			minw.update({'txid': 8})
+
+		# fixed cols: num age amt
+		col1_w = max(2,len(str(len(data)))+1) # num + ')'
+		amt_w = self.disp_prec + 5
+		fixed_w = col1_w + self.age_w + amt_w + sum(minw.values()) + (6 + show_txid) # one leading space in fs
+		var_w = sum(maxw.values()) - sum(minw.values())
+
+		# get actual screen width:
+		self.all_maxw = fixed_w + var_w + (txid_adj if show_txid else 0)
+		self.cols = min( self.get_term_columns(fixed_w), self.all_maxw )
+		total_freew = self.cols - fixed_w
+		varw = {k: max(maxw[k] - minw[k],0) for k in maxw}
+		freew = {k: int(min(total_freew * (varw[k] / var_w), varw[k])) for k in maxw}
+
+		varcols = set(maxw.keys())
+		for k in maxw:
+			freew[k] = min( total_freew - sum(freew[k2] for k2 in varcols-{k}), varw[k] )
+
+		self.column_params = namedtuple('column_params',
+			['col1','txid','addr1','amt','addr2','lbl'])(
+				col1_w,
+				min(
+					# max txid was reduced by txid_adj, so stretch to fill available space, if any
+					minw['txid'] + freew['txid'] + total_freew - sum(freew.values()),
+					self.txid_w ) if 'txid' in minw else 0,
+				minw['addr1'] + freew['addr1'],
+				amt_w,
+				minw['addr2'] + freew['addr2'],
+				minw['lbl'] + freew['lbl'] )
+
+	def gen_squeezed_display(self,cw,color):
+
+		if self.sinceblock:
+			yield f'Displaying transactions since block {self.sinceblock.hl(color=color)}'
+		yield 'Only wallet-related outputs are shown'
+		yield 'Comment is from first wallet address in outputs or inputs'
+		if (cw.addr1 < self.varcol_maxwidths['addr1'] or
+			cw.addr2 < self.varcol_maxwidths['addr2'] ):
+			yield 'Due to screen width limitations, not all addresses could be displayed'
+		yield ''
+
+		hdr_fs = self.squeezed_hdr_fs_fs.format(
+			nw = cw.col1,
+			dw = self.age_w,
+			txid_fs = f'{{i:{cw.txid}}} ' if self.show_txid else '',
+			aw = cw.addr1,
+			a2w = cw.addr2 )
+
+		fs = self.squeezed_fs_fs.format(
+			nw = cw.col1,
+			dw = self.age_w,
+			txid_fs = f'{{i:{cw.txid}}} ' if self.show_txid else '' )
+
+		yield hdr_fs.format(
+			n  = '',
+			i  = 'TxID',
+			d  = self.age_hdr,
+			a1 = 'Inputs',
+			A  = 'Amt({})'.format('TX' if self.show_total_amt else 'Wallet').ljust(cw.amt),
+			a2 = 'Outputs',
+			l  = 'Comment' ).rstrip()
+
+		n = 0
+		for d in self.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 ),
+					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.label.fmt( width=cw.lbl, color=color ) ).rstrip()
+
+	def gen_detail_display(self,color):
+
+		yield (
+			(f'Displaying transactions since block {self.sinceblock.hl(color=color)}\n'
+				if self.sinceblock else '')
+			+ 'Only wallet-related outputs are shown'
+		)
+
+		fs = fmt("""
+		{n}
+		    Block:        [{d}] {b}
+		    TxID:         [{D}] {i}
+		    Value:        {A1}
+		    Wallet Value: {A2}
+		    Fee:          {f}
+		    Inputs:
+		        {a1}
+		    Outputs ({oc}):
+		        {a2}
+		""",strip_char='\t').strip()
+
+		n = 0
+		for d in self.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 ),
+				)
+
+	sort_disp = {
+		'age':         'Age',
+		'blockheight': 'Block Height',
+		'amt':         'Wallet Amt',
+		'total_amt':   'TX Amt',
+		'txid':        'TxID',
+	}
+
+	sort_funcs = {
+		'age':         lambda i: i.time,
+		'blockheight': lambda i: 0 - abs(i.confirmations), # old/altcoin daemons return no 'blockheight' field
+		'amt':         lambda i: i.wallet_outputs_total,
+		'total_amt':   lambda i: i.outputs_total,
+		'txid':        lambda i: i.txid,
+	}
+
+	@staticmethod
+	async def set_dates(rpc,us):
+		pass
+
+	@property
+	def dump_fn_pfx(self):
+		return 'transaction-history' + (f'-since-block-{self.sinceblock}' if self.sinceblock else '')
+
+	class action(TwCommon.action):
+
+		def s_amt(self,parent):
+			parent.do_sort('amt')
+			parent.show_total_amt = False
+
+		def s_total_amt(self,parent):
+			parent.do_sort('total_amt')
+			parent.show_total_amt = True
+
+		def d_show_txid(self,parent):
+			parent.show_txid = not parent.show_txid
+			parent.set_column_params()
+
+		def d_show_unconfirmed(self,parent):
+			parent.show_unconfirmed = not parent.show_unconfirmed
+
+		def d_show_total_amt(self,parent):
+			parent.show_total_amt = not parent.show_total_amt

+ 58 - 3
test/test_py_d/ts_regtest.py

@@ -48,13 +48,13 @@ rt_data = {
 	'rtBals': {
 		'btc': ('499.9999488','399.9998282','399.9998147','399.9996877',
 				'52.99980410','946.99933647','999.99914057','52.9999',
-				'946.99933647','0.4169328'),
+				'946.99933647','0.4169328','6.24987417'),
 		'bch': ('499.9999484','399.9999194','399.9998972','399.9997692',
 				'46.78890380','953.20966920','999.99857300','46.789',
-				'953.2096692','0.4169328'),
+				'953.2096692','0.4169328','39.58187387'),
 		'ltc': ('5499.99744','5399.994425','5399.993885','5399.987535',
 				'52.98520500','10946.93753500','10999.92274000','52.99',
-				'10946.937535','0.41364'),
+				'10946.937535','0.41364','6.24846787'),
 	},
 	'rtBals_gb': {
 		'btc': {
@@ -230,6 +230,16 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 		('bob_twview4',                "viewing Bob's tracking wallet"),
 
 		('bob_alice_bal',            "Bob and Alice's balances"),
+
+		('bob_nochg_burn',           'zero-change transaction to burn address'),
+		('generate',                 'mining a block'),
+
+		('bob_txhist1',              "viewing Bob's transaction history (sort=age)"),
+		('bob_txhist2',              "viewing Bob's transaction history (sort=blockheight reverse=1)"),
+		('bob_txhist3',              "viewing Bob's transaction history (sort=blockheight sinceblock=-7)"),
+		('bob_txhist4',              "viewing Bob's transaction history (sinceblock=439 detail=1)"),
+		('bob_txhist_interactive',   "viewing Bob's transaction history (age_fmt=date_time interactive=true)"),
+
 		('alice_bal2',               "Alice's balance"),
 
 		('alice_add_label1',         'adding a label'),
@@ -592,6 +602,45 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 		sid = self._user_sid('bob')
 		return self.user_twview('bob',chk=(sid+':L:5',rtBals[9]),sort='twmmid')
 
+	def user_txhist(self,user,args,expect):
+		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
+		return t
+
+	def bob_txhist1(self):
+		return self.user_txhist('bob',
+			args = ['sort=age'],
+			expect = fr'\s1\).*\s{rtFundAmt}\s' )
+
+	def bob_txhist2(self):
+		return self.user_txhist('bob',
+			args = ['sort=blockheight','reverse=1','age_fmt=block'],
+			expect = fr'\s1\).*:{self.dfl_mmtype}:1\s' )
+
+	def bob_txhist3(self):
+		return self.user_txhist('bob',
+			args = ['sort=blockheight','sinceblock=-7','age_fmt=block'],
+			expect = fr'Displaying transactions since block 439.*\s6\).*:C:2\s.*\s{rtBals[9]}\s.*:L:5.*\s7\)'
+		)
+
+	def bob_txhist4(self):
+		return self.user_txhist('bob',
+			args = ['sort=blockheight','sinceblock=439','age_fmt=block','detail=1'],
+			expect = fr'Displaying transactions since block 439.*\s7\).*Block:.*446.*Value:.*{rtBals[10]}'
+		)
+
+	def bob_txhist_interactive(self):
+		self.get_file_with_ext('out',delete_all=True)
+		t = self.spawn('mmgen-tool',
+			['--bob',f'--outdir={self.tmpdir}','txhist','age_fmt=date_time','interactive=true'] )
+		for resp in ('u','i','t','a','m','T','A','r','r','D','D','D','D','p','P','b','V'):
+			t.expect('draw:\b',resp,regex=True)
+		txnum,idx = (8,1) if self.proto.coin == 'BCH' else (9,3)
+		t.expect(f'\s{txnum}\).*Inputs:.*:L:{idx}.*Outputs \(3\):.*:C:2.*\s10\)','q',regex=True)
+		return t
+
 	def bob_getbalance(self,bals,confs=1):
 		for i in (0,1,2):
 			assert Decimal(bals['mmgen'][i]) + Decimal(bals['nonmm'][i]) == Decimal(bals['total'][i])
@@ -611,6 +660,12 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 	def bob_1conf1_getbalance(self): return self.bob_getbalance(rtBals_gb['1conf1'],confs=1)
 	def bob_1conf2_getbalance(self): return self.bob_getbalance(rtBals_gb['1conf2'],confs=2)
 
+	def bob_nochg_burn(self):
+		return self.user_txdo('bob',
+			fee          = '0.00009713',
+			outputs_cl   = [f'{make_burn_addr(self.proto)}'],
+			outputs_list = '1' )
+
 	def bob_alice_bal(self):
 		t = self.spawn('mmgen-regtest',['balances'])
 		ret = t.expect_getend("Bob's balance:").strip()