twuo.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. twuo: Tracking wallet unspent outputs class for the MMGen suite
  20. """
  21. import time
  22. from collections import namedtuple
  23. from .globalvars import g
  24. from .color import red,yellow,green
  25. from .util import (
  26. msg,
  27. msg_r,
  28. die,
  29. capfirst,
  30. suf,
  31. fmt,
  32. make_timestr,
  33. keypress_confirm,
  34. line_input,
  35. do_pager,
  36. base_proto_subclass
  37. )
  38. from .base_obj import AsyncInit
  39. from .objmethods import MMGenObject
  40. from .obj import ImmutableAttr,ListItemAttr,MMGenListItem,TwComment,get_obj,HexStr,CoinTxID
  41. from .addr import CoinAddr,MMGenID,AddrIdx
  42. from .rpc import rpc_init
  43. from .tw import TwCommon,TwMMGenID,get_tw_label
  44. class TwUnspentOutputs(MMGenObject,TwCommon,metaclass=AsyncInit):
  45. def __new__(cls,proto,*args,**kwargs):
  46. return MMGenObject.__new__(base_proto_subclass(cls,proto,'twuo'))
  47. txid_w = 64
  48. age_fmts_date_dependent = ('days','date','date_time')
  49. age_fmts_interactive = ('confs','block','days','date')
  50. _age_fmt = 'confs'
  51. class MMGenTwOutputList(list,MMGenObject): pass
  52. class MMGenTwUnspentOutput(MMGenListItem):
  53. txid = ListItemAttr(CoinTxID)
  54. vout = ListItemAttr(int,typeconv=False)
  55. amt = ImmutableAttr(None)
  56. amt2 = ListItemAttr(None)
  57. label = ListItemAttr(TwComment,reassign_ok=True)
  58. twmmid = ImmutableAttr(TwMMGenID,include_proto=True)
  59. addr = ImmutableAttr(CoinAddr,include_proto=True)
  60. confs = ImmutableAttr(int,typeconv=False)
  61. date = ListItemAttr(int,typeconv=False,reassign_ok=True)
  62. scriptPubKey = ImmutableAttr(HexStr)
  63. skip = ListItemAttr(str,typeconv=False,reassign_ok=True)
  64. def __init__(self,proto,**kwargs):
  65. self.__dict__['proto'] = proto
  66. MMGenListItem.__init__(self,**kwargs)
  67. class conv_funcs:
  68. def amt(self,value):
  69. return self.proto.coin_amt(value)
  70. def amt2(self,value):
  71. return self.proto.coin_amt(value)
  72. async def __init__(self,proto,minconf=1,addrs=[]):
  73. self.proto = proto
  74. self.unspent = self.MMGenTwOutputList()
  75. self.fmt_display = ''
  76. self.fmt_print = ''
  77. self.cols = None
  78. self.reverse = False
  79. self.group = False
  80. self.show_mmid = True
  81. self.minconf = minconf
  82. self.addrs = addrs
  83. self.sort_key = 'age'
  84. self.disp_prec = self.get_display_precision()
  85. self.rpc = await rpc_init(proto)
  86. from .twctl import TrackingWallet
  87. self.wallet = await TrackingWallet(proto,mode='w')
  88. @property
  89. def age_fmt(self):
  90. return self._age_fmt
  91. @age_fmt.setter
  92. def age_fmt(self,val):
  93. if val not in self.age_fmts:
  94. die( 'BadAgeFormat', f'{val!r}: invalid age format (must be one of {self.age_fmts!r})' )
  95. self._age_fmt = val
  96. def get_display_precision(self):
  97. return self.proto.coin_amt.max_prec
  98. @property
  99. def total(self):
  100. return sum(i.amt for i in self.unspent)
  101. async def get_unspent_data(self,sort_key=None,reverse_sort=False):
  102. us_raw = await self.get_unspent_rpc()
  103. if not us_raw:
  104. die(0,fmt(f"""
  105. No spendable outputs found! Import addresses with balances into your
  106. watch-only wallet using '{g.proj_name.lower()}-addrimport' and then re-run this program.
  107. """).strip())
  108. lbl_id = ('account','label')['label_api' in self.rpc.caps]
  109. def gen_unspent():
  110. for o in us_raw:
  111. if not lbl_id in o:
  112. continue # coinbase outputs have no account field
  113. l = get_tw_label(self.proto,o[lbl_id])
  114. if l:
  115. o.update({
  116. 'twmmid': l.mmid,
  117. 'label': l.comment or '',
  118. 'amt': self.proto.coin_amt(o['amount']),
  119. 'addr': CoinAddr(self.proto,o['address']),
  120. 'confs': o['confirmations']
  121. })
  122. yield self.MMGenTwUnspentOutput(
  123. self.proto,
  124. **{ k:v for k,v in o.items() if k in self.MMGenTwUnspentOutput.valid_attrs } )
  125. self.unspent = self.MMGenTwOutputList(gen_unspent())
  126. if not self.unspent:
  127. die(1, f'No tracked {self.item_desc}s in tracking wallet!')
  128. self.do_sort(key=sort_key,reverse=reverse_sort)
  129. def do_sort(self,key=None,reverse=False):
  130. sort_funcs = {
  131. 'addr': lambda i: i.addr,
  132. 'age': lambda i: 0 - i.confs,
  133. 'amt': lambda i: i.amt,
  134. 'txid': lambda i: f'{i.txid} {i.vout:04}',
  135. 'twmmid': lambda i: i.twmmid.sort_key
  136. }
  137. key = key or self.sort_key
  138. if key not in sort_funcs:
  139. die(1,f'{key!r}: invalid sort key. Valid options: {" ".join(sort_funcs.keys())}')
  140. self.sort_key = key
  141. assert type(reverse) == bool
  142. self.unspent.sort(key=sort_funcs[key],reverse=reverse or self.reverse)
  143. def sort_info(self,include_group=True):
  144. ret = ([],['Reverse'])[self.reverse]
  145. ret.append(capfirst(self.sort_key).replace('Twmmid','MMGenID'))
  146. if include_group and self.group and (self.sort_key in ('addr','txid','twmmid')):
  147. ret.append('Grouped')
  148. return ret
  149. def set_term_columns(self):
  150. from .term import get_terminal_size
  151. while True:
  152. self.cols = g.terminal_width or get_terminal_size().width
  153. if self.cols >= g.min_screen_width:
  154. break
  155. line_input(
  156. 'Screen too narrow to display the tracking wallet\n'
  157. + f'Please resize your screen to at least {g.min_screen_width} characters and hit ENTER ' )
  158. def get_display_constants(self):
  159. unsp = self.unspent
  160. for i in unsp:
  161. i.skip = ''
  162. # allow for 7-digit confirmation nums
  163. col1_w = max(3,len(str(len(unsp)))+1) # num + ')'
  164. mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in unsp) or 12 # DEADBEEF:S:1
  165. max_acct_w = max(i.label.screen_width for i in unsp) + mmid_w + 1
  166. max_btcaddr_w = max(len(i.addr) for i in unsp)
  167. min_addr_w = self.cols - self.col_adj
  168. addr_w = min(max_btcaddr_w + (0,1+max_acct_w)[self.show_mmid],min_addr_w)
  169. acct_w = min(max_acct_w, max(24,addr_w-10))
  170. btaddr_w = addr_w - acct_w - 1
  171. label_w = acct_w - mmid_w - 1
  172. tx_w = min(self.txid_w,self.cols-addr_w-29-col1_w) # min=6 TODO
  173. txdots = ('','..')[tx_w < self.txid_w]
  174. dc = namedtuple('display_constants',['col1_w','mmid_w','addr_w','btaddr_w','label_w','tx_w','txdots'])
  175. return dc(col1_w,mmid_w,addr_w,btaddr_w,label_w,tx_w,txdots)
  176. async def format_for_display(self):
  177. unsp = self.unspent
  178. if self.has_age and self.age_fmt in self.age_fmts_date_dependent:
  179. await self.set_dates(self.rpc,unsp)
  180. self.set_term_columns()
  181. c = getattr(self,'display_constants',None)
  182. if not c:
  183. c = self.display_constants = self.get_display_constants()
  184. if self.group and (self.sort_key in ('addr','txid','twmmid')):
  185. for a,b in [(unsp[i],unsp[i+1]) for i in range(len(unsp)-1)]:
  186. for k in ('addr','txid','twmmid'):
  187. if self.sort_key == k and getattr(a,k) == getattr(b,k):
  188. b.skip = (k,'addr')[k=='twmmid']
  189. def gen_output():
  190. yield self.hdr_fmt.format(' '.join(self.sort_info()),self.proto.dcoin,self.total.hl())
  191. if self.proto.chain_name != 'mainnet':
  192. yield 'Chain: '+green(self.proto.chain_name.upper())
  193. fs = self.display_fs_fs.format( cw=c.col1_w, tw=c.tx_w )
  194. hdr_fs = self.display_hdr_fs_fs.format( cw=c.col1_w, tw=c.tx_w )
  195. yield hdr_fs.format(
  196. n = 'Num',
  197. t = 'TXid'.ljust(c.tx_w - 2) + ' Vout',
  198. a = 'Address'.ljust(c.addr_w),
  199. A = f'Amt({self.proto.dcoin})'.ljust(self.disp_prec+5),
  200. A2 = f' Amt({self.proto.coin})'.ljust(self.disp_prec+4),
  201. c = {
  202. 'confs': 'Confs',
  203. 'block': 'Block',
  204. 'days': 'Age(d)',
  205. 'date': 'Date',
  206. 'date_time': 'Date',
  207. }[self.age_fmt],
  208. ).rstrip()
  209. for n,i in enumerate(unsp):
  210. addr_dots = '|' + '.'*(c.addr_w-1)
  211. mmid_disp = MMGenID.fmtc(
  212. (
  213. '.'*c.mmid_w if i.skip == 'addr' else
  214. i.twmmid if i.twmmid.type == 'mmgen' else
  215. f'Non-{g.proj_name}'
  216. ),
  217. width = c.mmid_w,
  218. color = True )
  219. if self.show_mmid:
  220. addr_out = '{} {}{}'.format((
  221. type(i.addr).fmtc(addr_dots,width=c.btaddr_w,color=True) if i.skip == 'addr' else
  222. i.addr.fmt(width=c.btaddr_w,color=True)
  223. ),
  224. mmid_disp,
  225. (' ' + i.label.fmt(width=c.label_w,color=True)) if c.label_w > 0 else ''
  226. )
  227. else:
  228. addr_out = (
  229. type(i.addr).fmtc(addr_dots,width=c.addr_w,color=True) if i.skip=='addr' else
  230. i.addr.fmt(width=c.addr_w,color=True) )
  231. yield fs.format(
  232. n = str(n+1)+')',
  233. t = (
  234. '' if not i.txid else
  235. ' ' * (c.tx_w-4) + '|...' if i.skip == 'txid' else
  236. i.txid[:c.tx_w-len(c.txdots)] + c.txdots ),
  237. v = i.vout,
  238. a = addr_out,
  239. A = i.amt.fmt(color=True,prec=self.disp_prec),
  240. A2 = (i.amt2.fmt(color=True,prec=self.disp_prec) if i.amt2 is not None else ''),
  241. c = self.age_disp(i,self.age_fmt),
  242. ).rstrip()
  243. self.fmt_display = '\n'.join(gen_output()) + '\n'
  244. return self.fmt_display
  245. async def format_for_printing(self,color=False,show_confs=True):
  246. if self.has_age:
  247. await self.set_dates(self.rpc,self.unspent)
  248. addr_w = max(len(i.addr) for i in self.unspent)
  249. mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in self.unspent) or 12 # DEADBEEF:S:1
  250. fs = self.print_fs_fs.format(
  251. tw = self.txid_w + 3,
  252. cf = '{c:<8} ' if show_confs else '',
  253. aw = self.proto.coin_amt.max_prec + 5 )
  254. def gen_output():
  255. yield fs.format(
  256. n = 'Num',
  257. t = 'Tx ID,Vout',
  258. a = 'Address'.ljust(addr_w),
  259. m = 'MMGen ID'.ljust(mmid_w),
  260. A = f'Amount({self.proto.dcoin})',
  261. A2 = f'Amount({self.proto.coin})',
  262. c = 'Confs', # skipped for eth
  263. b = 'Block', # skipped for eth
  264. D = 'Date',
  265. l = 'Label' )
  266. max_lbl_len = max([len(i.label) for i in self.unspent if i.label] or [2])
  267. for n,i in enumerate(self.unspent):
  268. yield fs.format(
  269. n = str(n+1) + ')',
  270. t = '{},{}'.format(
  271. ('|'+'.'*63 if i.skip == 'txid' and self.group else i.txid),
  272. i.vout ),
  273. a = (
  274. '|'+'.' * addr_w if i.skip == 'addr' and self.group else
  275. i.addr.fmt(color=color,width=addr_w) ),
  276. m = MMGenID.fmtc(
  277. (i.twmmid if i.twmmid.type == 'mmgen' else f'Non-{g.proj_name}'),
  278. width = mmid_w,
  279. color = color ),
  280. A = i.amt.fmt(color=color),
  281. A2 = ( i.amt2.fmt(color=color) if i.amt2 is not None else '' ),
  282. c = i.confs,
  283. b = self.rpc.blockcount - (i.confs - 1),
  284. D = self.age_disp(i,'date_time'),
  285. l = i.label.hl(color=color) if i.label else
  286. TwComment.fmtc(
  287. s = '',
  288. color = color,
  289. nullrepl = '-',
  290. width = max_lbl_len )
  291. ).rstrip()
  292. fs2 = '{} (block #{}, {} UTC)\n{}Sort order: {}\n{}\n\nTotal {}: {}\n'
  293. self.fmt_print = fs2.format(
  294. capfirst(self.desc),
  295. self.rpc.blockcount,
  296. make_timestr(self.rpc.cur_date),
  297. ('' if self.proto.chain_name == 'mainnet' else
  298. 'Chain: {}\n'.format(green(self.proto.chain_name.upper())) ),
  299. ' '.join(self.sort_info(include_group=False)),
  300. '\n'.join(gen_output()),
  301. self.proto.dcoin,
  302. self.total.hl(color=color) )
  303. return self.fmt_print
  304. def display_total(self):
  305. msg('\nTotal unspent: {} {} ({} output{})'.format(
  306. self.total.hl(),
  307. self.proto.dcoin,
  308. len(self.unspent),
  309. suf(self.unspent) ))
  310. def get_idx_from_user(self,action):
  311. msg('')
  312. while True:
  313. ret = line_input(f'Enter {self.item_desc} number (or RETURN to return to main menu): ')
  314. if ret == '':
  315. return (None,None) if action == 'a_lbl_add' else None
  316. n = get_obj(AddrIdx,n=ret,silent=True)
  317. if not n or n < 1 or n > len(self.unspent):
  318. msg(f'Choice must be a single number between 1 and {len(self.unspent)}')
  319. else:
  320. if action == 'a_lbl_add':
  321. cur_lbl = self.unspent[n-1].label
  322. msg('Current label: {}'.format(cur_lbl.hl() if cur_lbl else '(none)'))
  323. while True:
  324. s = line_input(
  325. "Enter label text (or 'q' to return to main menu): ",
  326. insert_txt = cur_lbl )
  327. if s == 'q':
  328. return None,None
  329. elif s == '':
  330. if keypress_confirm(
  331. f'Removing label for {self.item_desc} #{n}. Is this what you want?'):
  332. return n,s
  333. elif s:
  334. if get_obj(TwComment,s=s):
  335. return n,s
  336. else:
  337. if action == 'a_addr_delete':
  338. fs = 'Removing {} #{} from tracking wallet. Is this what you want?'
  339. elif action == 'a_balance_refresh':
  340. fs = 'Refreshing tracking wallet {} #{}. Is this what you want?'
  341. if keypress_confirm(fs.format(self.item_desc,n)):
  342. return n
  343. async def view_and_sort(self,tx):
  344. from .term import get_char
  345. prompt = self.prompt.strip() + '\b'
  346. no_output,oneshot_msg = False,None
  347. from .opts import opt
  348. CUR_HOME,ERASE_ALL = '\033[H','\033[0J'
  349. CUR_RIGHT = lambda n: f'\033[{n}C'
  350. while True:
  351. msg_r('' if no_output else '\n\n' if opt.no_blank else CUR_HOME+ERASE_ALL)
  352. reply = get_char(
  353. '' if no_output else await self.format_for_display()+'\n'+(oneshot_msg or '')+prompt,
  354. immed_chars=''.join(self.key_mappings.keys())
  355. )
  356. no_output = False
  357. oneshot_msg = '' if oneshot_msg else None # tristate, saves previous state
  358. if reply not in self.key_mappings:
  359. msg_r('\ninvalid keypress ')
  360. time.sleep(0.5)
  361. continue
  362. action = self.key_mappings[reply]
  363. if action[:2] == 's_':
  364. self.do_sort(action[2:])
  365. if action == 's_twmmid': self.show_mmid = True
  366. elif action == 'd_days':
  367. af = self.age_fmts_interactive
  368. self.age_fmt = af[(af.index(self.age_fmt) + 1) % len(af)]
  369. elif action == 'd_mmid':
  370. self.show_mmid = not self.show_mmid
  371. elif action == 'd_group':
  372. if self.can_group:
  373. self.group = not self.group
  374. elif action == 'd_redraw':
  375. pass
  376. elif action == 'd_reverse':
  377. self.unspent.reverse()
  378. self.reverse = not self.reverse
  379. elif action == 'a_quit':
  380. msg('')
  381. return self.unspent
  382. elif action == 'a_balance_refresh':
  383. idx = self.get_idx_from_user(action)
  384. if idx:
  385. e = self.unspent[idx-1]
  386. bal = await self.wallet.get_balance(e.addr,force_rpc=True)
  387. await self.get_unspent_data()
  388. oneshot_msg = yellow(f'{self.proto.dcoin} balance for account #{idx} refreshed\n\n')
  389. self.display_constants = self.get_display_constants()
  390. elif action == 'a_lbl_add':
  391. idx,lbl = self.get_idx_from_user(action)
  392. if idx:
  393. e = self.unspent[idx-1]
  394. if await self.wallet.add_label(e.twmmid,lbl,addr=e.addr):
  395. await self.get_unspent_data()
  396. oneshot_msg = yellow('Label {} {} #{}\n\n'.format(
  397. ('added to' if lbl else 'removed from'),
  398. self.item_desc,
  399. idx ))
  400. else:
  401. oneshot_msg = red('Label could not be added\n\n')
  402. self.display_constants = self.get_display_constants()
  403. elif action == 'a_addr_delete':
  404. idx = self.get_idx_from_user(action)
  405. if idx:
  406. e = self.unspent[idx-1]
  407. if await self.wallet.remove_address(e.addr):
  408. await self.get_unspent_data()
  409. oneshot_msg = yellow(f'{capfirst(self.item_desc)} #{idx} removed\n\n')
  410. else:
  411. oneshot_msg = red('Address could not be removed\n\n')
  412. self.display_constants = self.get_display_constants()
  413. elif action == 'a_print':
  414. of = '{}-{}[{}].out'.format(
  415. self.dump_fn_pfx,
  416. self.proto.dcoin,
  417. ','.join(self.sort_info(include_group=False)).lower() )
  418. msg('')
  419. from .fileutil import write_data_to_file
  420. try:
  421. write_data_to_file(
  422. of,
  423. await self.format_for_printing(),
  424. desc = f'{self.desc} listing' )
  425. except UserNonConfirmation as e:
  426. oneshot_msg = red(f'File {of!r} not overwritten by user request\n\n')
  427. else:
  428. oneshot_msg = yellow(f'Data written to {of!r}\n\n')
  429. elif action in ('a_view','a_view_wide'):
  430. do_pager(
  431. self.fmt_display if action == 'a_view' else
  432. await self.format_for_printing(color=True) )
  433. if g.platform == 'linux' and oneshot_msg == None:
  434. msg_r(CUR_RIGHT(len(prompt.split('\n')[-1])-2))
  435. no_output = True