tw.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2020 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. tw: Tracking wallet methods for the MMGen suite
  20. """
  21. import json
  22. from mmgen.exception import *
  23. from mmgen.common import *
  24. from mmgen.obj import *
  25. from mmgen.tx import is_mmgen_id
  26. CUR_HOME,ERASE_ALL = '\033[H','\033[0J'
  27. def CUR_RIGHT(n): return '\033[{}C'.format(n)
  28. def get_tw_label(s):
  29. try: return TwLabel(s,on_fail='raise')
  30. except BadTwComment: raise
  31. except: return None
  32. class TwUnspentOutputs(MMGenObject):
  33. def __new__(cls,*args,**kwargs):
  34. return MMGenObject.__new__(altcoin_subclass(cls,'tw','TwUnspentOutputs'))
  35. txid_w = 64
  36. disp_type = 'btc'
  37. can_group = True
  38. hdr_fmt = 'UNSPENT OUTPUTS (sort order: {}) Total {}: {}'
  39. desc = 'unspent outputs'
  40. item_desc = 'unspent output'
  41. dump_fn_pfx = 'listunspent'
  42. prompt_fs = 'Total to spend, excluding fees: {} {}\n\n'
  43. prompt = """
  44. Sort options: [t]xid, [a]mount, a[d]dress, [A]ge, [r]everse, [M]mgen addr
  45. Display options: show [D]ays, [g]roup, show [m]mgen addr, r[e]draw screen
  46. Actions: [q]uit view, [p]rint to file, pager [v]iew, [w]ide view, add [l]abel:
  47. """
  48. key_mappings = {
  49. 't':'s_txid','a':'s_amt','d':'s_addr','A':'s_age','r':'d_reverse','M':'s_twmmid',
  50. 'D':'d_days','g':'d_group','m':'d_mmid','e':'d_redraw',
  51. 'q':'a_quit','p':'a_print','v':'a_view','w':'a_view_wide','l':'a_lbl_add' }
  52. col_adj = 38
  53. class MMGenTwOutputList(list,MMGenObject): pass
  54. class MMGenTwUnspentOutput(MMGenListItem):
  55. txid = MMGenListItemAttr('txid','CoinTxID')
  56. vout = MMGenListItemAttr('vout',int,typeconv=False)
  57. amt = MMGenImmutableAttr('amt',lambda:g.proto.coin_amt,typeconv=False)
  58. amt2 = MMGenListItemAttr('amt2',lambda:g.proto.coin_amt,typeconv=False)
  59. label = MMGenListItemAttr('label','TwComment',reassign_ok=True)
  60. twmmid = MMGenImmutableAttr('twmmid','TwMMGenID')
  61. addr = MMGenImmutableAttr('addr','CoinAddr')
  62. confs = MMGenImmutableAttr('confs',int,typeconv=False)
  63. scriptPubKey = MMGenImmutableAttr('scriptPubKey','HexStr')
  64. days = MMGenListItemAttr('days',int,typeconv=False)
  65. skip = MMGenListItemAttr('skip',str,typeconv=False,reassign_ok=True)
  66. wmsg = {
  67. 'no_spendable_outputs': """
  68. No spendable outputs found! Import addresses with balances into your
  69. watch-only wallet using '{}-addrimport' and then re-run this program.
  70. """.strip().format(g.proj_name.lower())
  71. }
  72. def __init__(self,minconf=1,addrs=[]):
  73. self.unspent = self.MMGenTwOutputList()
  74. self.fmt_display = ''
  75. self.fmt_print = ''
  76. self.cols = None
  77. self.reverse = False
  78. self.group = False
  79. self.show_mmid = True
  80. self.minconf = minconf
  81. self.addrs = addrs
  82. self.age_fmt = 'days'
  83. self.sort_key = 'age'
  84. self.disp_prec = self.get_display_precision()
  85. self.wallet = TrackingWallet('w')
  86. self.get_unspent_data()
  87. self.do_sort()
  88. @property
  89. def age_fmt(self):
  90. return self._age_fmt
  91. @age_fmt.setter
  92. def age_fmt(self,val):
  93. age_fmts = ('days','confs')
  94. if val not in age_fmts:
  95. raise BadAgeFormat("'{}': invalid age format (must be one of {!r})".format(val,age_fmts))
  96. self._age_fmt = val
  97. def get_display_precision(self):
  98. return g.proto.coin_amt.max_prec
  99. @property
  100. def total(self):
  101. return sum(i.amt for i in self.unspent)
  102. def get_unspent_rpc(self):
  103. # bitcoin-cli help listunspent:
  104. # Arguments:
  105. # 1. minconf (numeric, optional, default=1) The minimum confirmations to filter
  106. # 2. maxconf (numeric, optional, default=9999999) The maximum confirmations to filter
  107. # 3. addresses (json array, optional, default=empty array) A json array of bitcoin addresses
  108. # 4. include_unsafe (boolean, optional, default=true) Include outputs that are not safe to spend
  109. # 5. query_options (json object, optional) JSON with query options
  110. # for now, self.addrs is just an empty list for Bitcoin and friends
  111. add_args = (9999999,self.addrs) if self.addrs else ()
  112. return g.rpch.listunspent(self.minconf,*add_args)
  113. def get_unspent_data(self):
  114. if g.bogus_wallet_data: # for debugging purposes only
  115. us_rpc = eval(get_data_from_file(g.bogus_wallet_data)) # testing, so ok
  116. else:
  117. us_rpc = self.get_unspent_rpc()
  118. if not us_rpc: die(0,self.wmsg['no_spendable_outputs'])
  119. confs_per_day = 60*60*24 // g.proto.secs_per_block
  120. tr_rpc = []
  121. lbl_id = ('account','label')['label_api' in g.rpch.caps]
  122. for o in us_rpc:
  123. if not lbl_id in o: continue # coinbase outputs have no account field
  124. l = get_tw_label(o[lbl_id])
  125. if l:
  126. o.update({
  127. 'twmmid': l.mmid,
  128. 'label': l.comment,
  129. 'days': int(o['confirmations'] // confs_per_day),
  130. 'amt': g.proto.coin_amt(o['amount']),
  131. 'addr': CoinAddr(o['address']),
  132. 'confs': o['confirmations']
  133. })
  134. tr_rpc.append(o)
  135. self.unspent = self.MMGenTwOutputList(
  136. self.MMGenTwUnspentOutput(
  137. **{k:v for k,v in o.items() if k in dir(self.MMGenTwUnspentOutput)}
  138. ) for o in tr_rpc)
  139. for u in self.unspent:
  140. if u.label == None: u.label = ''
  141. if not self.unspent:
  142. die(1,'No tracked {}s in tracking wallet!'.format(self.item_desc))
  143. def do_sort(self,key=None,reverse=False):
  144. sort_funcs = {
  145. 'addr': lambda i: i.addr,
  146. 'age': lambda i: 0 - i.confs,
  147. 'amt': lambda i: i.amt,
  148. 'txid': lambda i: '{} {:04}'.format(i.txid,i.vout),
  149. 'twmmid': lambda i: i.twmmid.sort_key
  150. }
  151. key = key or self.sort_key
  152. if key not in sort_funcs:
  153. die(1,"'{}': invalid sort key. Valid options: {}".format(key,' '.join(sort_funcs.keys())))
  154. self.sort_key = key
  155. assert type(reverse) == bool
  156. self.unspent.sort(key=sort_funcs[key],reverse=reverse or self.reverse)
  157. def sort_info(self,include_group=True):
  158. ret = ([],['Reverse'])[self.reverse]
  159. ret.append(capfirst(self.sort_key).replace('Twmmid','MMGenID'))
  160. if include_group and self.group and (self.sort_key in ('addr','txid','twmmid')):
  161. ret.append('Grouped')
  162. return ret
  163. def set_term_columns(self):
  164. from mmgen.term import get_terminal_size
  165. while True:
  166. self.cols = g.terminal_width or get_terminal_size()[0]
  167. if self.cols >= g.min_screen_width: break
  168. m1 = 'Screen too narrow to display the tracking wallet\n'
  169. m2 = 'Please resize your screen to at least {} characters and hit ENTER '
  170. my_raw_input((m1+m2).format(g.min_screen_width))
  171. def format_for_display(self):
  172. unsp = self.unspent
  173. self.set_term_columns()
  174. # allow for 7-digit confirmation nums
  175. col1_w = max(3,len(str(len(unsp)))+1) # num + ')'
  176. mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in unsp) or 12 # DEADBEEF:S:1
  177. max_acct_w = max(i.label.screen_width for i in unsp) + mmid_w + 1
  178. max_btcaddr_w = max(len(i.addr) for i in unsp)
  179. min_addr_w = self.cols - self.col_adj
  180. addr_w = min(max_btcaddr_w + (0,1+max_acct_w)[self.show_mmid],min_addr_w)
  181. acct_w = min(max_acct_w, max(24,addr_w-10))
  182. btaddr_w = addr_w - acct_w - 1
  183. label_w = acct_w - mmid_w - 1
  184. tx_w = min(self.txid_w,self.cols-addr_w-28-col1_w) # min=7
  185. txdots = ('','..')[tx_w < self.txid_w]
  186. for i in unsp: i.skip = ''
  187. if self.group and (self.sort_key in ('addr','txid','twmmid')):
  188. for a,b in [(unsp[i],unsp[i+1]) for i in range(len(unsp)-1)]:
  189. for k in ('addr','txid','twmmid'):
  190. if self.sort_key == k and getattr(a,k) == getattr(b,k):
  191. b.skip = (k,'addr')[k=='twmmid']
  192. out = [self.hdr_fmt.format(' '.join(self.sort_info()),g.dcoin,self.total.hl())]
  193. if g.chain != 'mainnet': out += ['Chain: '+green(g.chain.upper())]
  194. fs = { 'btc': ' {n:%s} {t:%s} {v:2} {a} {A} {c:<}' % (col1_w,tx_w),
  195. 'eth': ' {n:%s} {a} {A}' % col1_w,
  196. 'token': ' {n:%s} {a} {A} {A2}' % col1_w }[self.disp_type]
  197. out += [fs.format( n='Num',
  198. t='TXid'.ljust(tx_w - 5) + ' Vout',
  199. v='',
  200. a='Address'.ljust(addr_w),
  201. A='Amt({})'.format(g.dcoin).ljust(self.disp_prec+3),
  202. A2=' Amt({})'.format(g.coin).ljust(self.disp_prec+4),
  203. c=('Confs','Age(d)')[self.age_fmt=='days']
  204. ).rstrip()]
  205. for n,i in enumerate(unsp):
  206. addr_dots = '|' + '.'*(addr_w-1)
  207. mmid_disp = MMGenID.fmtc('.'*mmid_w if i.skip=='addr'
  208. else i.twmmid if i.twmmid.type=='mmgen'
  209. else 'Non-{}'.format(g.proj_name),width=mmid_w,color=True)
  210. if self.show_mmid:
  211. addr_out = '{} {}'.format(
  212. type(i.addr).fmtc(addr_dots,width=btaddr_w,color=True) if i.skip == 'addr' \
  213. else i.addr.fmt(width=btaddr_w,color=True),
  214. '{} {}'.format(mmid_disp,i.label.fmt(width=label_w,color=True) \
  215. if label_w > 0 else ''))
  216. else:
  217. addr_out = type(i.addr).fmtc(addr_dots,width=addr_w,color=True) \
  218. if i.skip=='addr' else i.addr.fmt(width=addr_w,color=True)
  219. out.append(fs.format( n=str(n+1)+')',
  220. t='' if not i.txid else \
  221. ' ' * (tx_w-4) + '|...' if i.skip == 'txid' \
  222. else i.txid[:tx_w-len(txdots)] + txdots,
  223. v=i.vout,
  224. a=addr_out,
  225. A=i.amt.fmt(color=True,prec=self.disp_prec),
  226. A2=(i.amt2.fmt(color=True,prec=self.disp_prec) if i.amt2 is not None else ''),
  227. c=i.days if self.age_fmt == 'days' else i.confs
  228. ).rstrip())
  229. self.fmt_display = '\n'.join(out) + '\n'
  230. return self.fmt_display
  231. def format_for_printing(self,color=False):
  232. addr_w = max(len(i.addr) for i in self.unspent)
  233. mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in self.unspent) or 12 # DEADBEEF:S:1
  234. amt_w = g.proto.coin_amt.max_prec + 4
  235. fs = { 'btc': ' {n:4} {t:%s} {a} {m} {A:%s} {c:<8} {g:<6} {l}' % (self.txid_w+3,amt_w),
  236. 'eth': ' {n:4} {a} {m} {A:%s} {l}' % amt_w,
  237. 'token': ' {n:4} {a} {m} {A:%s} {A2:%s} {l}' % (amt_w,amt_w)
  238. }[self.disp_type]
  239. out = [fs.format( n='Num',
  240. t='Tx ID,Vout',
  241. a='Address'.ljust(addr_w),
  242. m='MMGen ID'.ljust(mmid_w+1),
  243. A='Amount({})'.format(g.dcoin).ljust(amt_w+1),
  244. A2='Amount({})'.format(g.coin),
  245. c='Confs', # skipped for eth
  246. g='Age(d)', # skipped for eth
  247. l='Label')]
  248. max_lbl_len = max([len(i.label) for i in self.unspent if i.label] or [2])
  249. for n,i in enumerate(self.unspent):
  250. addr = '|'+'.' * addr_w if i.skip == 'addr' and self.group else i.addr.fmt(color=color,width=addr_w)
  251. out.append(fs.format(
  252. n=str(n+1)+')',
  253. t='{},{}'.format('|'+'.'*63 if i.skip == 'txid' and self.group else i.txid,i.vout),
  254. a=addr,
  255. m=MMGenID.fmtc(i.twmmid if i.twmmid.type=='mmgen'
  256. else 'Non-{}'.format(g.proj_name),width=mmid_w,color=color),
  257. A=i.amt.fmt(color=color),
  258. A2=(i.amt2.fmt(color=color) if i.amt2 is not None else ''),
  259. c=i.confs,
  260. g=i.days,
  261. l=i.label.hl(color=color) if i.label else
  262. TwComment.fmtc('',color=color,nullrepl='-',width=max_lbl_len)).rstrip())
  263. fs = '{} ({} UTC)\nSort order: {}\n{}\n\nTotal {}: {}\n'
  264. self.fmt_print = fs.format(
  265. capfirst(self.desc),
  266. make_timestr(),
  267. ' '.join(self.sort_info(include_group=False)),
  268. '\n'.join(out),
  269. g.dcoin,
  270. self.total.hl(color=color))
  271. return self.fmt_print
  272. def display_total(self):
  273. fs = '\nTotal unspent: {} {} ({} output%s)' % suf(self.unspent)
  274. msg(fs.format(self.total.hl(),g.dcoin,len(self.unspent)))
  275. def get_idx_from_user(self,action):
  276. msg('')
  277. while True:
  278. ret = my_raw_input('Enter {} number (or RETURN to return to main menu): '.format(self.item_desc))
  279. if ret == '': return (None,None) if action == 'a_lbl_add' else None
  280. n = AddrIdx(ret,on_fail='silent')
  281. if not n or n < 1 or n > len(self.unspent):
  282. msg('Choice must be a single number between 1 and {}'.format(len(self.unspent)))
  283. else:
  284. if action == 'a_lbl_add':
  285. while True:
  286. s = my_raw_input("Enter label text (or 'q' to return to main menu): ")
  287. if s == 'q':
  288. return None,None
  289. elif s == '':
  290. fs = "Removing label for {} #{}. Is this what you want?"
  291. if keypress_confirm(fs.format(self.item_desc,n)):
  292. return n,s
  293. elif s:
  294. if TwComment(s,on_fail='return'):
  295. return n,s
  296. else:
  297. if action == 'a_addr_delete':
  298. fs = "Removing {} #{} from tracking wallet. Is this what you want?"
  299. elif action == 'a_balance_refresh':
  300. fs = "Refreshing tracking wallet {} #{}. Is this what you want?"
  301. if keypress_confirm(fs.format(self.item_desc,n)):
  302. return n
  303. def view_and_sort(self,tx):
  304. from mmgen.term import get_char
  305. prompt = self.prompt.strip() + '\b'
  306. no_output,oneshot_msg = False,None
  307. while True:
  308. msg_r('' if no_output else '\n\n' if opt.no_blank else CUR_HOME+ERASE_ALL)
  309. reply = get_char('' if no_output else self.format_for_display()+'\n'+(oneshot_msg or '')+prompt,
  310. immed_chars=''.join(self.key_mappings.keys())).decode()
  311. no_output = False
  312. oneshot_msg = '' if oneshot_msg else None # tristate, saves previous state
  313. if reply not in self.key_mappings:
  314. msg_r('\ninvalid keypress ')
  315. time.sleep(0.5)
  316. continue
  317. action = self.key_mappings[reply]
  318. if action[:2] == 's_':
  319. self.do_sort(action[2:])
  320. if action == 's_twmmid': self.show_mmid = True
  321. elif action == 'd_days': self.age_fmt = ('days','confs')[self.age_fmt=='days']
  322. elif action == 'd_mmid': self.show_mmid = not self.show_mmid
  323. elif action == 'd_group':
  324. if self.can_group:
  325. self.group = not self.group
  326. elif action == 'd_redraw': pass
  327. elif action == 'd_reverse': self.unspent.reverse(); self.reverse = not self.reverse
  328. elif action == 'a_quit': msg(''); return self.unspent
  329. elif action == 'a_balance_refresh':
  330. idx = self.get_idx_from_user(action)
  331. if idx:
  332. e = self.unspent[idx-1]
  333. bal = self.wallet.get_balance(e.addr,force_rpc=True)
  334. self.get_unspent_data()
  335. self.do_sort()
  336. oneshot_msg = yellow('{} balance for account #{} refreshed\n\n'.format(g.dcoin,idx))
  337. elif action == 'a_lbl_add':
  338. idx,lbl = self.get_idx_from_user(action)
  339. if idx:
  340. e = self.unspent[idx-1]
  341. if self.wallet.add_label(e.twmmid,lbl,addr=e.addr):
  342. self.get_unspent_data()
  343. self.do_sort()
  344. a = 'added to' if lbl else 'removed from'
  345. oneshot_msg = yellow("Label {} {} #{}\n\n".format(a,self.item_desc,idx))
  346. else:
  347. oneshot_msg = red('Label could not be added\n\n')
  348. elif action == 'a_addr_delete':
  349. idx = self.get_idx_from_user(action)
  350. if idx:
  351. e = self.unspent[idx-1]
  352. if self.wallet.remove_address(e.addr):
  353. self.get_unspent_data()
  354. self.do_sort()
  355. oneshot_msg = yellow("{} #{} removed\n\n".format(capfirst(self.item_desc),idx))
  356. else:
  357. oneshot_msg = red('Address could not be removed\n\n')
  358. elif action == 'a_print':
  359. of = '{}-{}[{}].out'.format(self.dump_fn_pfx,g.dcoin,
  360. ','.join(self.sort_info(include_group=False)).lower())
  361. msg('')
  362. try:
  363. write_data_to_file(of,self.format_for_printing(),desc='{} listing'.format(self.desc))
  364. except UserNonConfirmation as e:
  365. oneshot_msg = red("File '{}' not overwritten by user request\n\n".format(of))
  366. else:
  367. oneshot_msg = yellow("Data written to '{}'\n\n".format(of))
  368. elif action in ('a_view','a_view_wide'):
  369. do_pager(self.fmt_display if action == 'a_view' else self.format_for_printing(color=True))
  370. if g.platform == 'linux' and oneshot_msg == None:
  371. msg_r(CUR_RIGHT(len(prompt.split('\n')[-1])-2))
  372. no_output = True
  373. class TwAddrList(MMGenDict):
  374. def __new__(cls,*args,**kwargs):
  375. return MMGenDict.__new__(altcoin_subclass(cls,'tw','TwAddrList'),*args,**kwargs)
  376. def __init__(self,usr_addr_list,minconf,showempty,showbtcaddrs,all_labels,wallet=None):
  377. def check_dup_mmid(acct_labels):
  378. mmid_prev,err = None,False
  379. for mmid in sorted(a.mmid for a in acct_labels if a):
  380. if mmid == mmid_prev:
  381. err = True
  382. msg('Duplicate MMGen ID ({}) discovered in tracking wallet!\n'.format(mmid))
  383. mmid_prev = mmid
  384. if err: rdie(3,'Tracking wallet is corrupted!')
  385. def check_addr_array_lens(acct_pairs):
  386. err = False
  387. for label,addrs in acct_pairs:
  388. if not label: continue
  389. if len(addrs) != 1:
  390. err = True
  391. if len(addrs) == 0:
  392. msg("Label '{}': has no associated address!".format(label))
  393. else:
  394. msg("'{}': more than one {} address in account!".format(addrs,g.coin))
  395. if err: rdie(3,'Tracking wallet is corrupted!')
  396. self.total = g.proto.coin_amt('0')
  397. rpc_init()
  398. lbl_id = ('account','label')['label_api' in g.rpch.caps]
  399. for d in g.rpch.listunspent(0):
  400. if not lbl_id in d: continue # skip coinbase outputs with missing account
  401. if d['confirmations'] < minconf: continue
  402. label = get_tw_label(d[lbl_id])
  403. if label:
  404. if usr_addr_list and (label.mmid not in usr_addr_list): continue
  405. if label.mmid in self:
  406. if self[label.mmid]['addr'] != d['address']:
  407. die(2,'duplicate {} address ({}) for this MMGen address! ({})'.format(
  408. g.coin,d['address'],self[label.mmid]['addr']))
  409. else:
  410. self[label.mmid] = {'amt': g.proto.coin_amt('0'),
  411. 'lbl': label,
  412. 'addr': CoinAddr(d['address'])}
  413. self[label.mmid]['lbl'].mmid.confs = d['confirmations']
  414. self[label.mmid]['amt'] += d['amount']
  415. self.total += d['amount']
  416. # We use listaccounts only for empty addresses, as it shows false positive balances
  417. if showempty or all_labels:
  418. # for compatibility with old mmids, must use raw RPC rather than native data for matching
  419. # args: minconf,watchonly, MUST use keys() so we get list, not dict
  420. if 'label_api' in g.rpch.caps:
  421. acct_list = g.rpch.listlabels()
  422. acct_addrs = [list(a.keys()) for a in g.rpch.getaddressesbylabel([[k] for k in acct_list],batch=True)]
  423. else:
  424. acct_list = list(g.rpch.listaccounts(0,True).keys()) # raw list, no 'L'
  425. acct_addrs = g.rpch.getaddressesbyaccount([[a] for a in acct_list],batch=True) # use raw list here
  426. acct_labels = MMGenList([get_tw_label(a) for a in acct_list])
  427. check_dup_mmid(acct_labels)
  428. assert len(acct_list) == len(acct_addrs),(
  429. 'listaccounts() and getaddressesbyaccount() not equal in length')
  430. addr_pairs = list(zip(acct_labels,acct_addrs))
  431. check_addr_array_lens(addr_pairs)
  432. for label,addr_arr in addr_pairs:
  433. if not label: continue
  434. if all_labels and not showempty and not label.comment: continue
  435. if usr_addr_list and (label.mmid not in usr_addr_list): continue
  436. if label.mmid not in self:
  437. self[label.mmid] = { 'amt':g.proto.coin_amt('0'), 'lbl':label, 'addr':'' }
  438. if showbtcaddrs:
  439. self[label.mmid]['addr'] = CoinAddr(addr_arr[0])
  440. def raw_list(self):
  441. return [((k if k.type == 'mmgen' else 'Non-MMGen'),self[k]['addr'],self[k]['amt']) for k in self]
  442. def coinaddr_list(self): return [self[k]['addr'] for k in self]
  443. def format(self,showbtcaddrs,sort,show_age,age_fmt):
  444. age_fmts = ('days','confs')
  445. if age_fmt not in age_fmts:
  446. raise BadAgeFormat("'{}': invalid age format (must be one of {!r})".format(age_fmt,age_fmts))
  447. out = ['Chain: '+green(g.chain.upper())] if g.chain != 'mainnet' else []
  448. fs = '{mid}' + ('',' {addr}')[showbtcaddrs] + ' {cmt} {amt}' + ('',' {age}')[show_age]
  449. mmaddrs = [k for k in self.keys() if k.type == 'mmgen']
  450. max_mmid_len = max(len(k) for k in mmaddrs) + 2 if mmaddrs else 10
  451. max_cmt_width = max(max(v['lbl'].comment.screen_width for v in self.values()),7)
  452. addr_width = max(len(self[mmid]['addr']) for mmid in self)
  453. # fp: fractional part
  454. max_fp_len = max([len(a.split('.')[1]) for a in [str(v['amt']) for v in self.values()] if '.' in a] or [1])
  455. out += [fs.format(
  456. mid=MMGenID.fmtc('MMGenID',width=max_mmid_len),
  457. addr=(CoinAddr.fmtc('ADDRESS',width=addr_width) if showbtcaddrs else None),
  458. cmt=TwComment.fmtc('COMMENT',width=max_cmt_width+1),
  459. amt='BALANCE'.ljust(max_fp_len+4),
  460. age=('CONFS','DAYS')[age_fmt=='days'],
  461. ).rstrip()]
  462. def sort_algo(j):
  463. if sort and 'age' in sort:
  464. return '{}_{:>012}_{}'.format(
  465. j.obj.rsplit(':',1)[0],
  466. # Hack, but OK for the foreseeable future:
  467. (1000000000-j.confs if hasattr(j,'confs') and j.confs != None else 0),
  468. j.sort_key)
  469. else:
  470. return j.sort_key
  471. al_id_save = None
  472. confs_per_day = 60*60*24 // g.proto.secs_per_block
  473. for mmid in sorted(self,key=sort_algo,reverse=bool(sort and 'reverse' in sort)):
  474. if mmid.type == 'mmgen':
  475. if al_id_save and al_id_save != mmid.obj.al_id:
  476. out.append('')
  477. al_id_save = mmid.obj.al_id
  478. mmid_disp = mmid
  479. else:
  480. if al_id_save:
  481. out.append('')
  482. al_id_save = None
  483. mmid_disp = 'Non-MMGen'
  484. e = self[mmid]
  485. out.append(fs.format(
  486. mid=MMGenID.fmtc(mmid_disp,width=max_mmid_len,color=True),
  487. addr=(e['addr'].fmt(color=True,width=addr_width) if showbtcaddrs else None),
  488. cmt=e['lbl'].comment.fmt(width=max_cmt_width,color=True,nullrepl='-'),
  489. amt=e['amt'].fmt('4.{}'.format(max(max_fp_len,3)),color=True),
  490. age=mmid.confs // (1,confs_per_day)[age_fmt=='days'] if hasattr(mmid,'confs') and mmid.confs != None else '-'
  491. ).rstrip())
  492. return '\n'.join(out + ['\nTOTAL: {} {}'.format(self.total.hl(color=True),g.dcoin)])
  493. class TrackingWallet(MMGenObject):
  494. caps = ('rescan','batch')
  495. data_key = 'addresses'
  496. use_tw_file = False
  497. aggressive_sync = False
  498. def __new__(cls,*args,**kwargs):
  499. return MMGenObject.__new__(altcoin_subclass(cls,'tw','TrackingWallet'))
  500. def __init__(self,mode='r',no_rpc=False):
  501. if g.debug:
  502. print_stack_trace('TW INIT {!r} {!r}'.format(mode,self))
  503. assert mode in ('r','w'),"{!r}: wallet mode must be 'r' or 'w'".format(self)
  504. self.mode = mode
  505. self.desc = self.base_desc = '{} tracking wallet'.format(capfirst(g.proto.name))
  506. if self.use_tw_file:
  507. self.init_from_wallet_file()
  508. else:
  509. self.init_empty()
  510. if self.data['coin'] != g.coin:
  511. m = 'Tracking wallet coin ({}) does not match current coin ({})!'
  512. raise WalletFileError(m.format(self.data['coin'],g.coin))
  513. self.conv_types(self.data[self.data_key])
  514. self.rpc_init()
  515. self.cur_balances = {} # cache balances to prevent repeated lookups per program invocation
  516. def init_empty(self):
  517. self.data = { 'coin': g.coin, 'addresses': {} }
  518. def init_from_wallet_file(self):
  519. tw_dir = (
  520. os.path.join(g.data_dir,g.proto.data_subdir) if g.coin == 'BTC' else
  521. os.path.join(g.data_dir_root,'altcoins',g.coin.lower(),g.proto.data_subdir) )
  522. self.tw_fn = os.path.join(tw_dir,'tracking-wallet.json')
  523. check_or_create_dir(tw_dir)
  524. try:
  525. self.orig_data = get_data_from_file(self.tw_fn,quiet=True)
  526. self.data = json.loads(self.orig_data)
  527. except:
  528. try: os.stat(self.tw_fn)
  529. except:
  530. self.orig_data = ''
  531. self.init_empty()
  532. self.force_write()
  533. else:
  534. m = "File '{}' exists but does not contain valid json data"
  535. raise WalletFileError(m.format(self.tw_fn))
  536. else:
  537. self.upgrade_wallet_maybe()
  538. # ensure that wallet file is written when user exits via KeyboardInterrupt:
  539. if self.mode == 'w':
  540. import atexit
  541. def del_tw(tw):
  542. dmsg('Running exit handler del_tw() for {!r}'.format(tw))
  543. del tw
  544. atexit.register(del_tw,self)
  545. # TrackingWallet instances must be explicitly destroyed with 'del tw', 'del twuo.wallet'
  546. # and the like to ensure the instance is deleted and wallet is written before global
  547. # vars are destroyed by interpreter at shutdown.
  548. # This is especially important, as exceptions are ignored within __del__():
  549. # /usr/share/doc/python3.6-doc/html/reference/datamodel.html#object.__del__
  550. # This code can only be debugged by examining the program output. Since no exceptions
  551. # are raised, errors will not be caught by the test suite.
  552. def __del__(self):
  553. if g.debug:
  554. print_stack_trace('TW DEL {!r}'.format(self))
  555. if self.mode == 'w':
  556. self.write()
  557. elif g.debug:
  558. msg('read-only wallet, doing nothing')
  559. def upgrade_wallet_maybe(self): pass
  560. @staticmethod
  561. def conv_types(ad):
  562. for k,v in ad.items():
  563. if k in ('params','coin'): continue
  564. v['mmid'] = TwMMGenID(v['mmid'],on_fail='raise')
  565. v['comment'] = TwComment(v['comment'],on_fail='raise')
  566. def rpc_init(self):
  567. rpc_init()
  568. @property
  569. def data_root(self):
  570. return self.data[self.data_key]
  571. @property
  572. def data_root_desc(self):
  573. return self.data_key
  574. def cache_balance(self,addr,bal,session_cache,data_root,force=False):
  575. if force or addr not in session_cache:
  576. session_cache[addr] = str(bal)
  577. if addr in data_root:
  578. data_root[addr]['balance'] = str(bal)
  579. if self.aggressive_sync:
  580. self.write()
  581. def get_cached_balance(self,addr,session_cache,data_root):
  582. if addr in session_cache:
  583. return g.proto.coin_amt(session_cache[addr])
  584. if not g.use_cached_balances:
  585. return None
  586. if addr in data_root and 'balance' in data_root[addr]:
  587. return g.proto.coin_amt(data_root[addr]['balance'])
  588. def get_balance(self,addr,force_rpc=False):
  589. ret = None if force_rpc else self.get_cached_balance(addr,self.cur_balances,self.data_root)
  590. if ret == None:
  591. ret = self.rpc_get_balance(addr)
  592. self.cache_balance(addr,ret,self.cur_balances,self.data_root)
  593. return ret
  594. def rpc_get_balance(self,addr):
  595. raise NotImplementedError('not implemented')
  596. @property
  597. def sorted_list(self):
  598. return sorted(
  599. [ { 'addr':x[0],
  600. 'mmid':x[1]['mmid'],
  601. 'comment':x[1]['comment'] }
  602. for x in self.data_root.items() if x[0] not in ('params','coin') ],
  603. key=lambda x: x['mmid'].sort_key+x['addr'] )
  604. @property
  605. def mmid_ordered_dict(self):
  606. from collections import OrderedDict
  607. return OrderedDict([(x['mmid'],{'addr':x['addr'],'comment':x['comment']}) for x in self.sorted_list])
  608. @write_mode
  609. def import_address(self,addr,label,rescan):
  610. return g.rpch.importaddress(addr,label,rescan,timeout=(False,3600)[rescan])
  611. @write_mode
  612. def batch_import_address(self,arg_list):
  613. return g.rpch.importaddress(arg_list,batch=True)
  614. def force_write(self):
  615. mode_save = self.mode
  616. self.mode = 'w'
  617. self.write()
  618. self.mode = mode_save
  619. @write_mode
  620. def write_changed(self,data):
  621. write_data_to_file(
  622. self.tw_fn,data,
  623. desc='{} data'.format(self.base_desc),
  624. ask_overwrite=False,ignore_opt_outdir=True,quiet=True,
  625. check_data=True,cmp_data=self.orig_data)
  626. self.orig_data = data
  627. def write(self): # use 'check_data' to check wallet hasn't been altered by another program
  628. if not self.use_tw_file:
  629. dmsg("'use_tw_file' is False, doing nothing")
  630. return
  631. dmsg('write(): checking if {} data has changed'.format(self.desc))
  632. wdata = json.dumps(self.data)
  633. if self.orig_data != wdata:
  634. if g.debug:
  635. print_stack_trace('TW DATA CHANGED {!r}'.format(self))
  636. print_diff(self.orig_data,wdata,from_json=True)
  637. self.write_changed(wdata)
  638. elif g.debug:
  639. msg('Data is unchanged\n')
  640. def is_in_wallet(self,addr):
  641. return addr in TwAddrList([],0,True,True,True,wallet=self).coinaddr_list()
  642. @write_mode
  643. def set_label(self,coinaddr,lbl):
  644. # bitcoin-abc 'setlabel' RPC is broken, so use old 'importaddress' method to set label
  645. # broken behavior: new label is set OK, but old label gets attached to another address
  646. if 'label_api' in g.rpch.caps and g.coin != 'BCH':
  647. return g.rpch.setlabel(coinaddr,lbl,on_fail='return')
  648. else:
  649. # NOTE: this works because importaddress() removes the old account before
  650. # associating the new account with the address.
  651. # RPC args: addr,label,rescan[=true],p2sh[=none]
  652. return g.rpch.importaddress(coinaddr,lbl,False,on_fail='return')
  653. # returns on failure
  654. @write_mode
  655. def add_label(self,arg1,label='',addr=None,silent=False,on_fail='return'):
  656. from mmgen.tx import is_mmgen_id,is_coin_addr
  657. mmaddr,coinaddr = None,None
  658. if is_coin_addr(addr or arg1):
  659. coinaddr = CoinAddr(addr or arg1,on_fail='return')
  660. if is_mmgen_id(arg1):
  661. mmaddr = TwMMGenID(arg1)
  662. if mmaddr and not coinaddr:
  663. from mmgen.addr import AddrData
  664. coinaddr = AddrData(source='tw').mmaddr2coinaddr(mmaddr)
  665. try:
  666. if not is_mmgen_id(arg1):
  667. assert coinaddr,"Invalid coin address for this chain: {}".format(arg1)
  668. assert coinaddr,"{pn} address '{ma}' not found in tracking wallet"
  669. assert self.is_in_wallet(coinaddr),"Address '{ca}' not found in tracking wallet"
  670. except Exception as e:
  671. msg(e.args[0].format(pn=g.proj_name,ma=mmaddr,ca=coinaddr))
  672. return False
  673. # Allow for the possibility that BTC addr of MMGen addr was entered.
  674. # Do reverse lookup, so that MMGen addr will not be marked as non-MMGen.
  675. if not mmaddr:
  676. from mmgen.addr import AddrData
  677. mmaddr = AddrData(source='tw').coinaddr2mmaddr(coinaddr)
  678. if not mmaddr: mmaddr = '{}:{}'.format(g.proto.base_coin.lower(),coinaddr)
  679. mmaddr = TwMMGenID(mmaddr)
  680. cmt = TwComment(label,on_fail=on_fail)
  681. if cmt in (False,None): return False
  682. lbl = TwLabel(mmaddr + ('',' '+cmt)[bool(cmt)],on_fail=on_fail)
  683. ret = self.set_label(coinaddr,lbl)
  684. from mmgen.rpc import rpc_error,rpc_errmsg
  685. if rpc_error(ret):
  686. msg('From {}: {}'.format(g.proto.daemon_name,rpc_errmsg(ret)))
  687. if not silent:
  688. msg('Label could not be {}'.format(('removed','added')[bool(label)]))
  689. return False
  690. else:
  691. m = mmaddr.type.replace('mmg','MMG')
  692. a = mmaddr.replace(g.proto.base_coin.lower()+':','')
  693. s = '{} address {} in tracking wallet'.format(m,a)
  694. if label: msg("Added label '{}' to {}".format(label,s))
  695. else: msg('Removed label from {}'.format(s))
  696. return True
  697. @write_mode
  698. def remove_label(self,mmaddr):
  699. self.add_label(mmaddr,'')
  700. @write_mode
  701. def remove_address(self,addr):
  702. raise NotImplementedError('address removal not implemented for coin {}'.format(g.coin))
  703. class TwGetBalance(MMGenObject):
  704. fs = '{w:13} {u:<16} {p:<16} {c}\n'
  705. def __new__(cls,*args,**kwargs):
  706. return MMGenObject.__new__(altcoin_subclass(cls,'tw','TwGetBalance'))
  707. def __init__(self,minconf,quiet):
  708. rpc_init()
  709. self.minconf = minconf
  710. self.quiet = quiet
  711. self.data = {k:[g.proto.coin_amt('0')] * 4 for k in ('TOTAL','Non-MMGen','Non-wallet')}
  712. self.create_data()
  713. def create_data(self):
  714. # 0: unconfirmed, 1: below minconf, 2: confirmed, 3: spendable (privkey in wallet)
  715. lbl_id = ('account','label')['label_api' in g.rpch.caps]
  716. for d in g.rpch.listunspent(0):
  717. lbl = get_tw_label(d[lbl_id])
  718. if lbl:
  719. if lbl.mmid.type == 'mmgen':
  720. key = lbl.mmid.obj.sid
  721. if key not in self.data:
  722. self.data[key] = [g.proto.coin_amt('0')] * 4
  723. else:
  724. key = 'Non-MMGen'
  725. else:
  726. lbl,key = None,'Non-wallet'
  727. if not d['confirmations']:
  728. self.data['TOTAL'][0] += d['amount']
  729. self.data[key][0] += d['amount']
  730. conf_level = (1,2)[d['confirmations'] >= self.minconf]
  731. self.data['TOTAL'][conf_level] += d['amount']
  732. self.data[key][conf_level] += d['amount']
  733. if d['spendable']:
  734. self.data[key][3] += d['amount']
  735. def format(self):
  736. if self.quiet:
  737. o = str(self.data['TOTAL'][2] if self.data else 0) + '\n'
  738. else:
  739. o = self.fs.format( w='Wallet',
  740. u=' Unconfirmed',
  741. p=' <{} confirms'.format(self.minconf),
  742. c=' >={} confirms'.format(self.minconf))
  743. for key in sorted(self.data):
  744. if not any(self.data[key]): continue
  745. o += self.fs.format(**dict(zip(
  746. ('w','u','p','c'),
  747. [key+':'] + [a.fmt(color=True,suf=' '+g.dcoin) for a in self.data[key]]
  748. )))
  749. for key,vals in list(self.data.items()):
  750. if key == 'TOTAL': continue
  751. if vals[3]:
  752. o += red('Warning: this wallet contains PRIVATE KEYS for {} outputs!\n'.format(key))
  753. return o.rstrip()