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
This commit is contained in:
parent
85a9673d15
commit
7d2165641f
9 changed files with 681 additions and 6 deletions
366
mmgen/base_proto/bitcoin/tw/txhistory.py
Executable file
366
mmgen/base_proto/bitcoin/tw/txhistory.py
Executable file
|
|
@ -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) )
|
||||
|
|
@ -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 @@
|
|||
13.2.dev1
|
||||
13.2.dev2
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ mods = {
|
|||
'remove_address',
|
||||
'remove_label',
|
||||
'twview',
|
||||
'txhist',
|
||||
),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
mmgen/tw/txhistory.py
Executable file
235
mmgen/tw/txhistory.py
Executable file
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue