txhistory.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
  4. # Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
  5. # Licensed under the GNU General Public License, Version 3:
  6. # https://www.gnu.org/licenses
  7. # Public project repositories:
  8. # https://github.com/mmgen/mmgen
  9. # https://gitlab.com/mmgen/mmgen
  10. """
  11. proto.btc.tw.txhistory: Bitcoin base protocol tracking wallet transaction history class
  12. """
  13. from collections import namedtuple
  14. from ....globalvars import g
  15. from ....tw.txhistory import TwTxHistory
  16. from ....tw.common import get_tw_label,TwMMGenID
  17. from ....addr import CoinAddr
  18. from ....util import msg,msg_r
  19. from ....color import nocolor,red,pink,gray
  20. from ....obj import TwComment,CoinTxID,Int
  21. from .common import BitcoinTwCommon
  22. class BitcoinTwTransaction(BitcoinTwCommon):
  23. def __init__(self,parent,proto,rpc,
  24. idx, # unique numeric identifier of this transaction in listing
  25. unspent_info, # addrs in wallet with balances: { 'mmid': {'addr','comment','amt'} }
  26. mm_map, # all addrs in wallet: ['addr', ['twmmid','comment']]
  27. tx, # the decoded transaction data
  28. wallet_vouts, # list of ints - wallet-related vouts
  29. prevouts, # list of (txid,vout) pairs
  30. prevout_txs # decoded transaction data for prevouts
  31. ):
  32. self.parent = parent
  33. self.proto = proto
  34. self.rpc = rpc
  35. self.unspent_info = unspent_info
  36. self.tx = tx
  37. def gen_prevouts_data():
  38. _d = namedtuple('prevout_data',['txid','data'])
  39. for tx in prevout_txs:
  40. for e in prevouts:
  41. if e.txid == tx['txid']:
  42. yield _d( e.txid, tx['vout'][e.vout] )
  43. def gen_wallet_vouts_data():
  44. _d = namedtuple('wallet_vout_data',['txid','data'])
  45. txid = self.tx['txid']
  46. vouts = self.tx['decoded']['vout']
  47. for n in wallet_vouts:
  48. yield _d( txid, vouts[n] )
  49. def gen_vouts_info(data):
  50. _d = namedtuple('vout_info',['txid','coin_addr','twlabel','data'])
  51. def gen():
  52. for d in data:
  53. addr = d.data['scriptPubKey'].get('address') or d.data['scriptPubKey']['addresses'][0]
  54. yield _d(
  55. txid = d.txid,
  56. coin_addr = addr,
  57. twlabel = mm_map[addr] if (addr in mm_map and mm_map[addr].twmmid) else None,
  58. data = d.data )
  59. return sorted(
  60. gen(),
  61. # if address is not MMGen, ignore address and sort by TxID + vout only
  62. key = lambda d: (
  63. (d.twlabel.twmmid.sort_key if d.twlabel and d.twlabel.twmmid.type == 'mmgen' else '')
  64. + '_'
  65. + d.txid
  66. + '{:08d}'.format(d.data['n'])
  67. ))
  68. def gen_all_addrs(src):
  69. for e in self.vouts_info[src]:
  70. if e.twlabel:
  71. mmid = e.twlabel.twmmid
  72. yield (
  73. (mmid if mmid.type == 'mmgen' else mmid.split(':',1)[1]) +
  74. ('*' if mmid in self.unspent_info else '')
  75. )
  76. else:
  77. yield e.coin_addr
  78. def total(data):
  79. return self.proto.coin_amt( sum(d.data['value'] for d in data) )
  80. def get_best_comment():
  81. """
  82. find the most relevant comment for tabular (squeezed) display
  83. """
  84. def vouts_labels(src):
  85. return [ d.twlabel.comment for d in self.vouts_info[src] if d.twlabel and d.twlabel.comment ]
  86. ret = vouts_labels('outputs') or vouts_labels('inputs')
  87. return ret[0] if ret else TwComment('')
  88. # 'outputs' refers to wallet-related outputs only
  89. self.vouts_info = {
  90. 'inputs': gen_vouts_info( gen_prevouts_data() ),
  91. 'outputs': gen_vouts_info( gen_wallet_vouts_data() )
  92. }
  93. self.max_addrlen = {
  94. 'inputs': max(len(addr) for addr in gen_all_addrs('inputs')),
  95. 'outputs': max(len(addr) for addr in gen_all_addrs('outputs'))
  96. }
  97. self.inputs_total = total( self.vouts_info['inputs'] )
  98. self.outputs_total = self.proto.coin_amt( sum(i['value'] for i in self.tx['decoded']['vout']) )
  99. self.wallet_outputs_total = total( self.vouts_info['outputs'] )
  100. self.fee = self.inputs_total - self.outputs_total
  101. self.nOutputs = len(self.tx['decoded']['vout'])
  102. self.confirmations = self.tx['confirmations']
  103. self.comment = get_best_comment()
  104. self.vsize = self.tx['decoded'].get('vsize') or self.tx['decoded']['size']
  105. self.txid = CoinTxID(self.tx['txid'])
  106. # Though 'blocktime' is flagged as an “optional” field, it’s always present for transactions
  107. # that are in the blockchain. However, Bitcoin Core wallet saves a record of broadcast but
  108. # unconfirmed transactions, e.g. replaced transactions, and the 'blocktime' field is missing
  109. # for these, so use 'time' as a fallback.
  110. self.time = self.tx.get('blocktime') or self.tx['time']
  111. def blockheight_disp(self,color):
  112. return (
  113. # old/altcoin daemons return no 'blockheight' field, so use confirmations instead
  114. Int( self.rpc.blockcount + 1 - self.confirmations ).hl(color=color)
  115. if self.confirmations > 0 else None )
  116. def age_disp(self,age_fmt,width,color):
  117. if age_fmt == 'confs':
  118. ret_str = str(self.confirmations).ljust(width)
  119. return gray(ret_str) if self.confirmations < 0 and color else ret_str
  120. elif age_fmt == 'block':
  121. ret = (self.rpc.blockcount - (abs(self.confirmations) - 1)) * (-1 if self.confirmations < 0 else 1)
  122. ret_str = str(ret).ljust(width)
  123. return gray(ret_str) if ret < 0 and color else ret_str
  124. else:
  125. return self.parent.date_formatter[age_fmt](self.rpc,self.tx.get('blocktime',0))
  126. def txdate_disp(self,age_fmt):
  127. return self.parent.date_formatter[age_fmt](self.rpc,self.time)
  128. def txid_disp(self,width,color):
  129. return self.txid.truncate(width=width,color=color)
  130. def vouts_list_disp(self,src,color,indent=''):
  131. fs1,fs2 = {
  132. 'inputs': ('{i},{n} {a} {A}', '{i},{n} {a} {A} {l}'),
  133. 'outputs': ( '{n} {a} {A}', '{n} {a} {A} {l}')
  134. }[src]
  135. def gen_output():
  136. for e in self.vouts_info[src]:
  137. mmid = e.twlabel.twmmid if e.twlabel else None
  138. if not mmid:
  139. yield fs1.format(
  140. i = CoinTxID(e.txid).hl(color=color),
  141. n = (nocolor,red)[color](str(e.data['n']).ljust(3)),
  142. a = CoinAddr(self.proto,e.coin_addr).fmt( width=self.max_addrlen[src], color=color ),
  143. A = self.proto.coin_amt( e.data['value'] ).fmt(color=color)
  144. ).rstrip()
  145. else:
  146. bal_star,co = ('*','melon') if mmid in self.unspent_info else ('','brown')
  147. addr_out = mmid if mmid.type == 'mmgen' else mmid.split(':',1)[1]
  148. yield fs2.format(
  149. i = CoinTxID(e.txid).hl(color=color),
  150. n = (nocolor,red)[color](str(e.data['n']).ljust(3)),
  151. a = TwMMGenID.hlc(
  152. '{:{w}}'.format( addr_out + bal_star, w=self.max_addrlen[src] ),
  153. color = color,
  154. color_override = co ),
  155. A = self.proto.coin_amt( e.data['value'] ).fmt(color=color),
  156. l = e.twlabel.comment.hl(color=color)
  157. ).rstrip()
  158. return f'\n{indent}'.join( gen_output() ).strip()
  159. def vouts_disp(self,src,width,color):
  160. class x: space_left = width or 0
  161. def gen_output():
  162. for e in self.vouts_info[src]:
  163. mmid = e.twlabel.twmmid if e.twlabel else None
  164. bal_star,addr_w,co = ('*',16,'melon') if mmid in self.unspent_info else ('',15,'brown')
  165. if not mmid:
  166. if width and x.space_left < addr_w:
  167. break
  168. yield CoinAddr( self.proto, e.coin_addr ).fmt(width=addr_w,color=color)
  169. x.space_left -= addr_w
  170. elif mmid.type == 'mmgen':
  171. mmid_disp = mmid + bal_star
  172. if width and x.space_left < len(mmid_disp):
  173. break
  174. yield TwMMGenID.hlc( mmid_disp, color=color, color_override=co )
  175. x.space_left -= len(mmid_disp)
  176. else:
  177. if width and x.space_left < addr_w:
  178. break
  179. yield TwMMGenID.hlc(
  180. CoinAddr.fmtc( mmid.split(':',1)[1] + bal_star, width=addr_w ),
  181. color = color,
  182. color_override = co )
  183. x.space_left -= addr_w
  184. x.space_left -= 1
  185. return ' '.join(gen_output()) + ' ' * (x.space_left + 1 if width else 0)
  186. def amt_disp(self,show_total_amt):
  187. return (
  188. self.outputs_total if show_total_amt else
  189. self.wallet_outputs_total )
  190. def fee_disp(self,color):
  191. atomic_unit = self.proto.coin_amt.units[0]
  192. return '{} {}'.format(
  193. self.fee.hl(color=color),
  194. (nocolor,pink)[color]('({:,} {}s/byte)'.format(
  195. self.fee.to_unit(atomic_unit) // self.vsize,
  196. atomic_unit )) )
  197. class BitcoinTwTxHistory(TwTxHistory,BitcoinTwCommon):
  198. has_age = True
  199. hdr_lbl = 'transaction history'
  200. desc = 'transaction history'
  201. item_desc = 'transaction'
  202. no_data_errmsg = 'No transactions in tracking wallet!'
  203. prompt = """
  204. Sort options: [t]xid, [a]mt, total a[m]t, [A]ge, [b]locknum, [r]everse
  205. Column options: toggle [D]ays/date/confs/block, tx[i]d, [T]otal amt
  206. Filters: show [u]nconfirmed
  207. View/Print: pager [v]iew, full [V]iew, screen [p]rint, full [P]rint
  208. Actions: [q]uit, r[e]draw:
  209. """
  210. key_mappings = {
  211. 'A':'s_age',
  212. 'b':'s_blockheight',
  213. 'a':'s_amt',
  214. 'm':'s_total_amt',
  215. 't':'s_txid',
  216. 'r':'d_reverse',
  217. 'D':'d_days',
  218. 'e':'d_redraw',
  219. 'u':'d_show_unconfirmed',
  220. 'i':'d_show_txid',
  221. 'T':'d_show_total_amt',
  222. 'q':'a_quit',
  223. 'v':'a_view',
  224. 'V':'a_view_detail',
  225. 'p':'a_print_squeezed',
  226. 'P':'a_print_detail' }
  227. async def get_rpc_data(self):
  228. blockhash = (
  229. await self.rpc.call( 'getblockhash', self.sinceblock )
  230. if self.sinceblock else '' )
  231. # bitcoin-cli help listsinceblock:
  232. # Arguments:
  233. # 1. blockhash (string, optional) If set, the block hash to list transactions since,
  234. # otherwise list all transactions.
  235. # 2. target_confirmations (numeric, optional, default=1) Return the nth block hash from the main
  236. # chain. e.g. 1 would mean the best block hash. Note: this is not used
  237. # as a filter, but only affects [lastblock] in the return value
  238. # 3. include_watchonly (boolean, optional, default=true for watch-only wallets, otherwise
  239. # false) Include transactions to watch-only addresses
  240. # 4. include_removed (boolean, optional, default=true) Show transactions that were removed
  241. # due to a reorg in the "removed" array (not guaranteed to work on
  242. # pruned nodes)
  243. return (await self.rpc.call('listsinceblock',blockhash,1,True,False))['transactions']
  244. async def gen_data(self,rpc_data,lbl_id):
  245. def gen_parsed_data():
  246. for o in rpc_data:
  247. if lbl_id in o:
  248. l = get_tw_label(self.proto,o[lbl_id])
  249. else:
  250. assert o['category'] == 'send', f"{o['address']}: {o['category']} != 'send'"
  251. l = None
  252. o.update({
  253. 'twmmid': l.mmid if l else None,
  254. 'comment': (l.comment or '') if l else None,
  255. })
  256. yield o
  257. data = list(gen_parsed_data())
  258. if g.debug_tw:
  259. import json
  260. from ....rpc import json_encoder
  261. def do_json_dump(*data):
  262. nw = f'{self.proto.coin.lower()}-{self.proto.network}'
  263. for d,fn_stem in data:
  264. open(f'/tmp/{fn_stem}-{nw}.json','w').write(json.dumps(d,cls=json_encoder))
  265. _mmp = namedtuple('mmap_datum',['twmmid','comment'])
  266. mm_map = {
  267. i['address']: (
  268. _mmp( TwMMGenID(self.proto,i['twmmid']), TwComment(i['comment']) )
  269. if i['twmmid'] else _mmp(None,None)
  270. )
  271. for i in data }
  272. if self.sinceblock: # mapping data may be incomplete for inputs, so update from 'listlabels'
  273. mm_map.update(
  274. { addr: _mmp(label.mmid, label.comment) if label else _mmp(None,None)
  275. for label,addr in await self.get_addr_label_pairs() }
  276. )
  277. msg_r('Getting wallet transactions...')
  278. _wallet_txs = await self.rpc.gathered_icall(
  279. 'gettransaction',
  280. [ (i,True,True) for i in {d['txid'] for d in data} ] )
  281. msg('done')
  282. if not 'decoded' in _wallet_txs[0]:
  283. _decoded_txs = iter(
  284. await self.rpc.gathered_call(
  285. 'decoderawtransaction',
  286. [ (d['hex'],) for d in _wallet_txs ] ))
  287. for tx in _wallet_txs:
  288. tx['decoded'] = next(_decoded_txs)
  289. if g.debug_tw:
  290. do_json_dump((_wallet_txs, 'wallet-txs'),)
  291. _wip = namedtuple('prevout',['txid','vout'])
  292. txdata = [
  293. {
  294. 'tx': tx,
  295. 'wallet_vouts': sorted({i.vout for i in
  296. [_wip( CoinTxID(d['txid']), d['vout'] ) for d in data]
  297. if i.txid == tx['txid']}),
  298. 'prevouts': [_wip( CoinTxID(vin['txid']), vin['vout'] ) for vin in tx['decoded']['vin']]
  299. }
  300. for tx in _wallet_txs]
  301. _prevout_txids = {i.txid for d in txdata for i in d['prevouts']}
  302. msg_r('Getting input transactions...')
  303. _prevout_txs = await self.rpc.gathered_call('getrawtransaction', [ (i,True) for i in _prevout_txids ])
  304. msg('done')
  305. _prevout_txs_dict = dict(zip(_prevout_txids,_prevout_txs))
  306. for d in txdata:
  307. d['prevout_txs'] = [_prevout_txs_dict[txid] for txid in {i.txid for i in d['prevouts']} ]
  308. if g.debug_tw:
  309. do_json_dump(
  310. (rpc_data, 'txhist-rpc'),
  311. (data, 'txhist'),
  312. (mm_map, 'mmap'),
  313. (_prevout_txs, 'prevout-txs'),
  314. (txdata, 'txdata'),
  315. )
  316. unspent_info = await self.get_unspent_by_mmid()
  317. return (
  318. BitcoinTwTransaction(
  319. parent = self,
  320. proto = self.proto,
  321. rpc = self.rpc,
  322. idx = idx,
  323. unspent_info = unspent_info,
  324. mm_map = mm_map,
  325. **d ) for idx,d in enumerate(txdata) )