From 7d2165641f0492a01a025de29a7798f2594ca40a Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Mon, 23 May 2022 16:28:57 +0000 Subject: [PATCH] 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 --- mmgen/base_proto/bitcoin/tw/txhistory.py | 366 +++++++++++++++++++++++ mmgen/color.py | 3 +- mmgen/data/version | 2 +- mmgen/globalvars.py | 2 + mmgen/main_tool.py | 1 + mmgen/tool/rpc.py | 15 + mmgen/tw/common.py | 2 +- mmgen/tw/txhistory.py | 235 +++++++++++++++ test/test_py_d/ts_regtest.py | 61 +++- 9 files changed, 681 insertions(+), 6 deletions(-) create mode 100755 mmgen/base_proto/bitcoin/tw/txhistory.py create mode 100755 mmgen/tw/txhistory.py diff --git a/mmgen/base_proto/bitcoin/tw/txhistory.py b/mmgen/base_proto/bitcoin/tw/txhistory.py new file mode 100755 index 00000000..fb4d2fea --- /dev/null +++ b/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 +# 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) ) diff --git a/mmgen/color.py b/mmgen/color.py index 35c5b219..aaa44c76 100755 --- a/mmgen/color.py +++ b/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) ), diff --git a/mmgen/data/version b/mmgen/data/version index 74e58831..369af71f 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -13.2.dev1 +13.2.dev2 diff --git a/mmgen/globalvars.py b/mmgen/globalvars.py index 97fd31b7..ee059841 100755 --- a/mmgen/globalvars.py +++ b/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', diff --git a/mmgen/main_tool.py b/mmgen/main_tool.py index 74619cf6..dcb1f2f5 100755 --- a/mmgen/main_tool.py +++ b/mmgen/main_tool.py @@ -162,6 +162,7 @@ mods = { 'remove_address', 'remove_label', 'twview', + 'txhist', ), } diff --git a/mmgen/tool/rpc.py b/mmgen/tool/rpc.py index 657a6998..029f1f90 100755 --- a/mmgen/tool/rpc.py +++ b/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 diff --git a/mmgen/tw/common.py b/mmgen/tw/common.py index 91c68d98..747f2cff 100755 --- a/mmgen/tw/common.py +++ b/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 diff --git a/mmgen/tw/txhistory.py b/mmgen/tw/txhistory.py new file mode 100755 index 00000000..58247724 --- /dev/null +++ b/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 +# 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 diff --git a/test/test_py_d/ts_regtest.py b/test/test_py_d/ts_regtest.py index 975c0bba..ec835bbb 100755 --- a/test/test_py_d/ts_regtest.py +++ b/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()