tw.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. #!/usr/bin/env python
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2018 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. from mmgen.common import *
  22. from mmgen.obj import *
  23. from mmgen.tx import is_mmgen_id
  24. CUR_HOME,ERASE_ALL = '\033[H','\033[0J'
  25. def CUR_RIGHT(n): return '\033[{}C'.format(n)
  26. class TwUnspentOutputs(MMGenObject):
  27. def __new__(cls,*args,**kwargs):
  28. return MMGenObject.__new__(altcoin_subclass(cls,'tw','TwUnspentOutputs'),*args,**kwargs)
  29. txid_w = 64
  30. disp_type = 'btc'
  31. can_group = True
  32. hdr_fmt = 'UNSPENT OUTPUTS (sort order: {}) Total {}: {}'
  33. desc = 'unspent outputs'
  34. item_desc = 'unspent output'
  35. dump_fn_pfx = 'listunspent'
  36. prompt_fs = 'Total to spend, excluding fees: {} {}\n\n'
  37. prompt = """
  38. Sort options: [t]xid, [a]mount, a[d]dress, [A]ge, [r]everse, [M]mgen addr
  39. Display options: show [D]ays, [g]roup, show [m]mgen addr, r[e]draw screen
  40. Actions: [q]uit view, [p]rint to file, pager [v]iew, [w]ide view, add [l]abel:
  41. """
  42. key_mappings = {
  43. 't':'s_txid','a':'s_amt','d':'s_addr','A':'s_age','r':'d_reverse','M':'s_twmmid',
  44. 'D':'d_days','g':'d_group','m':'d_mmid','e':'d_redraw',
  45. 'q':'a_quit','p':'a_print','v':'a_view','w':'a_view_wide','l':'a_lbl_add' }
  46. col_adj = 38
  47. class MMGenTwOutputList(list,MMGenObject): pass
  48. class MMGenTwUnspentOutput(MMGenListItem):
  49. # attrs = 'txid','vout','amt','label','twmmid','addr','confs','scriptPubKey','days','skip'
  50. txid = MMGenListItemAttr('txid','CoinTxID')
  51. vout = MMGenListItemAttr('vout',int,typeconv=False)
  52. amt = MMGenImmutableAttr('amt',g.proto.coin_amt.__name__)
  53. amt2 = MMGenListItemAttr('amt2',g.proto.coin_amt.__name__)
  54. label = MMGenListItemAttr('label','TwComment',reassign_ok=True)
  55. twmmid = MMGenImmutableAttr('twmmid','TwMMGenID')
  56. addr = MMGenImmutableAttr('addr','CoinAddr')
  57. confs = MMGenImmutableAttr('confs',int,typeconv=False)
  58. scriptPubKey = MMGenImmutableAttr('scriptPubKey','HexStr')
  59. days = MMGenListItemAttr('days',int,typeconv=False)
  60. skip = MMGenListItemAttr('skip',str,typeconv=False,reassign_ok=True)
  61. wmsg = {
  62. 'no_spendable_outputs': """
  63. No spendable outputs found! Import addresses with balances into your
  64. watch-only wallet using '{}-addrimport' and then re-run this program.
  65. """.strip().format(g.proj_name.lower())
  66. }
  67. def __init__(self,minconf=1):
  68. self.unspent = self.MMGenTwOutputList()
  69. self.fmt_display = ''
  70. self.fmt_print = ''
  71. self.cols = None
  72. self.reverse = False
  73. self.group = False
  74. self.show_days = True
  75. self.show_mmid = True
  76. self.minconf = minconf
  77. self.get_unspent_data()
  78. self.sort_key = 'age'
  79. self.do_sort()
  80. self.total = self.get_total_coin()
  81. self.disp_prec = self.get_display_precision()
  82. def get_display_precision(self):
  83. return g.proto.coin_amt.max_prec
  84. def get_total_coin(self):
  85. return sum(i.amt for i in self.unspent)
  86. def get_unspent_rpc(self):
  87. return g.rpch.listunspent(self.minconf)
  88. def get_unspent_data(self):
  89. if g.bogus_wallet_data: # for debugging purposes only
  90. us_rpc = eval(get_data_from_file(g.bogus_wallet_data)) # testing, so ok
  91. else:
  92. us_rpc = self.get_unspent_rpc()
  93. # write_data_to_file('bogus_unspent.json', repr(us), 'bogus unspent data')
  94. # sys.exit(0)
  95. if not us_rpc: die(0,self.wmsg['no_spendable_outputs'])
  96. confs_per_day = 60*60*24 / g.proto.secs_per_block
  97. tr_rpc = []
  98. for o in us_rpc:
  99. if not 'account' in o: continue # coinbase outputs have no account field
  100. l = TwLabel(o['account'],on_fail='silent')
  101. if l:
  102. o.update({
  103. 'twmmid': l.mmid,
  104. 'label': l.comment,
  105. 'days': int(o['confirmations'] / confs_per_day),
  106. 'amt': g.proto.coin_amt(o['amount']),
  107. 'addr': CoinAddr(o['address']),
  108. 'confs': o['confirmations']
  109. })
  110. tr_rpc.append(o)
  111. self.unspent = self.MMGenTwOutputList(
  112. self.MMGenTwUnspentOutput(
  113. **dict(i for i in o.items() if i[0] in dir(self.MMGenTwUnspentOutput))
  114. ) for o in tr_rpc)
  115. for u in self.unspent:
  116. if u.label == None: u.label = ''
  117. if not self.unspent:
  118. die(1,'No tracked {}s in tracking wallet!'.format(self.item_desc))
  119. def do_sort(self,key=None,reverse=False):
  120. sort_funcs = {
  121. 'addr': lambda i: i.addr,
  122. 'age': lambda i: 0 - i.confs,
  123. 'amt': lambda i: i.amt,
  124. 'txid': lambda i: '{} {:04}'.format(i.txid,i.vout),
  125. 'twmmid': lambda i: i.twmmid.sort_key
  126. }
  127. key = key or self.sort_key
  128. if key not in sort_funcs:
  129. die(1,"'{}': invalid sort key. Valid options: {}".format(key,' '.join(sort_funcs.keys())))
  130. self.sort_key = key
  131. assert type(reverse) == bool
  132. self.unspent.sort(key=sort_funcs[key],reverse=reverse or self.reverse)
  133. def sort_info(self,include_group=True):
  134. ret = ([],['Reverse'])[self.reverse]
  135. ret.append(capfirst(self.sort_key).replace('Twmmid','MMGenID'))
  136. if include_group and self.group and (self.sort_key in ('addr','txid','twmmid')):
  137. ret.append('Grouped')
  138. return ret
  139. def set_term_columns(self):
  140. from mmgen.term import get_terminal_size
  141. while True:
  142. self.cols = get_terminal_size()[0]
  143. if self.cols >= g.min_screen_width: break
  144. m1 = 'Screen too narrow to display the tracking wallet\n'
  145. m2 = 'Please resize your screen to at least {} characters and hit ENTER '
  146. my_raw_input((m1+m2).format(g.min_screen_width))
  147. def format_for_display(self):
  148. unsp = self.unspent
  149. self.set_term_columns()
  150. # allow for 7-digit confirmation nums
  151. col1_w = max(3,len(str(len(unsp)))+1) # num + ')'
  152. mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in unsp) or 12 # DEADBEEF:S:1
  153. max_acct_w = max(len(i.label) for i in unsp) + mmid_w + 1
  154. max_btcaddr_w = max(len(i.addr) for i in unsp)
  155. min_addr_w = self.cols - self.col_adj
  156. addr_w = min(max_btcaddr_w + (0,1+max_acct_w)[self.show_mmid],min_addr_w)
  157. acct_w = min(max_acct_w, max(24,addr_w-10))
  158. btaddr_w = addr_w - acct_w - 1
  159. label_w = acct_w - mmid_w - 1
  160. tx_w = min(self.txid_w,self.cols-addr_w-28-col1_w) # min=7
  161. txdots = ('','..')[tx_w < self.txid_w]
  162. for i in unsp: i.skip = ''
  163. if self.group and (self.sort_key in ('addr','txid','twmmid')):
  164. for a,b in [(unsp[i],unsp[i+1]) for i in range(len(unsp)-1)]:
  165. for k in ('addr','txid','twmmid'):
  166. if self.sort_key == k and getattr(a,k) == getattr(b,k):
  167. b.skip = (k,'addr')[k=='twmmid']
  168. out = [self.hdr_fmt.format(' '.join(self.sort_info()),g.dcoin,self.total.hl())]
  169. if g.chain != 'mainnet': out += ['Chain: '+green(g.chain.upper())]
  170. fs = { 'btc': u' {n:%s} {t:%s} {v:2} {a} {A} {c:<}' % (col1_w,tx_w),
  171. 'eth': u' {n:%s} {a} {A}' % col1_w,
  172. 'token': u' {n:%s} {a} {A} {A2}' % col1_w }[self.disp_type]
  173. out += [fs.format( n='Num',
  174. t='TXid'.ljust(tx_w - 5) + ' Vout',
  175. v='',
  176. a='Address'.ljust(addr_w),
  177. A='Amt({})'.format(g.dcoin).ljust(self.disp_prec+3),
  178. A2=' Amt({})'.format(g.coin).ljust(self.disp_prec+4),
  179. c=('Confs','Age(d)')[self.show_days]
  180. ).rstrip()]
  181. for n,i in enumerate(unsp):
  182. addr_dots = '|' + '.'*(addr_w-1)
  183. mmid_disp = MMGenID.fmtc('.'*mmid_w if i.skip=='addr'
  184. else i.twmmid if i.twmmid.type=='mmgen'
  185. else 'Non-{}'.format(g.proj_name),width=mmid_w,color=True)
  186. if self.show_mmid:
  187. addr_out = u'{} {}'.format(
  188. type(i.addr).fmtc(addr_dots,width=btaddr_w,color=True) if i.skip == 'addr' \
  189. else i.addr.fmt(width=btaddr_w,color=True),
  190. u'{} {}'.format(mmid_disp,i.label.fmt(width=label_w,color=True) \
  191. if label_w > 0 else ''))
  192. else:
  193. addr_out = type(i.addr).fmtc(addr_dots,width=addr_w,color=True) \
  194. if i.skip=='addr' else i.addr.fmt(width=addr_w,color=True)
  195. out.append(fs.format( n=str(n+1)+')',
  196. t='' if not i.txid else \
  197. ' ' * (tx_w-4) + '|...' if i.skip == 'txid' \
  198. else i.txid[:tx_w-len(txdots)]+txdots,
  199. v=i.vout,
  200. a=addr_out,
  201. A=i.amt.fmt(color=True,prec=self.disp_prec),
  202. A2=(i.amt2.fmt(color=True,prec=self.disp_prec) if i.amt2 is not None else ''),
  203. c=i.days if self.show_days else i.confs
  204. ).rstrip())
  205. self.fmt_display = '\n'.join(out) + '\n'
  206. return self.fmt_display
  207. def format_for_printing(self,color=False):
  208. addr_w = max(len(i.addr) for i in self.unspent)
  209. mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in self.unspent) or 12 # DEADBEEF:S:1
  210. amt_w = g.proto.coin_amt.max_prec + 4
  211. fs = { 'btc': u' {n:4} {t:%s} {a} {m} {A:%s} {c:<8} {g:<6} {l}' % (self.txid_w+3,amt_w),
  212. 'eth': u' {n:4} {a} {m} {A:%s} {l}' % amt_w,
  213. 'token': u' {n:4} {a} {m} {A:%s} {A2:%s} {l}' % (amt_w,amt_w)
  214. }[self.disp_type]
  215. out = [fs.format( n='Num',
  216. t='Tx ID,Vout',
  217. a='Address'.ljust(addr_w),
  218. m='MMGen ID'.ljust(mmid_w+1),
  219. A='Amount({})'.format(g.dcoin).ljust(amt_w+1),
  220. A2='Amount({})'.format(g.coin),
  221. c='Confs', # skipped for eth
  222. g='Age(d)', # skipped for eth
  223. l='Label')]
  224. max_lbl_len = max([len(i.label) for i in self.unspent if i.label] or [2])
  225. for n,i in enumerate(self.unspent):
  226. addr = '|'+'.' * addr_w if i.skip == 'addr' and self.group else i.addr.fmt(color=color,width=addr_w)
  227. out.append(fs.format(
  228. n=str(n+1)+')',
  229. t='{},{}'.format('|'+'.'*63 if i.skip == 'txid' and self.group else i.txid,i.vout),
  230. a=addr,
  231. m=MMGenID.fmtc(i.twmmid if i.twmmid.type=='mmgen'
  232. else 'Non-{}'.format(g.proj_name),width=mmid_w,color=color),
  233. A=i.amt.fmt(color=color),
  234. A2=(i.amt2.fmt(color=color) if i.amt2 is not None else ''),
  235. c=i.confs,
  236. g=i.days,
  237. l=i.label.hl(color=color) if i.label else
  238. TwComment.fmtc('',color=color,nullrepl='-',width=max_lbl_len)).rstrip())
  239. fs = '{} ({} UTC)\nSort order: {}\n{}\n\nTotal {}: {}\n'
  240. self.fmt_print = fs.format(
  241. capfirst(self.desc),
  242. make_timestr(),
  243. ' '.join(self.sort_info(include_group=False)),
  244. '\n'.join(out),
  245. g.dcoin,
  246. self.total.hl(color=color))
  247. return self.fmt_print
  248. def display_total(self):
  249. fs = '\nTotal unspent: {} {} ({} outputs)'
  250. msg(fs.format(self.total.hl(),g.dcoin,len(self.unspent)))
  251. def get_idx_from_user(self,get_label=False):
  252. msg('')
  253. while True:
  254. ret = my_raw_input('Enter {} number (or RETURN to return to main menu): '.format(self.item_desc))
  255. if ret == '': return (None,None) if get_label else None
  256. n = AddrIdx(ret,on_fail='silent') # hacky way to test and convert to integer
  257. if not n or n < 1 or n > len(self.unspent):
  258. msg('Choice must be a single number between 1 and {}'.format(len(self.unspent)))
  259. # elif not self.unspent[n-1].mmid:
  260. # msg('Address #{} is not an {} address. No label can be added to it'.format(n,g.proj_name))
  261. else:
  262. if get_label:
  263. while True:
  264. s = my_raw_input("Enter label text (or 'q' to return to main menu): ")
  265. if s == 'q':
  266. return None,None
  267. elif s == '':
  268. fs = "Removing label for {} #{}. Is this what you want?"
  269. if keypress_confirm(fs.format(self.item_desc,n)):
  270. return n,s
  271. elif s:
  272. if TwComment(s,on_fail='return'):
  273. return n,s
  274. else:
  275. fs = "Removing {} #{} from tracking wallet. Is this what you want?"
  276. if keypress_confirm(fs.format(self.item_desc,n)):
  277. return n
  278. def view_and_sort(self,tx):
  279. from mmgen.term import get_char
  280. prompt = self.prompt.strip() + '\b'
  281. no_output,oneshot_msg = False,None
  282. while True:
  283. msg_r('' if no_output else '\n\n' if opt.no_blank else CUR_HOME+ERASE_ALL)
  284. reply = get_char('' if no_output else self.format_for_display()+'\n'+(oneshot_msg or '')+prompt,
  285. immed_chars=self.key_mappings)
  286. no_output = False
  287. oneshot_msg = '' if oneshot_msg else None # tristate, saves previous state
  288. if reply not in self.key_mappings:
  289. msg_r('\ninvalid keypress ')
  290. time.sleep(0.5)
  291. continue
  292. action = self.key_mappings[reply]
  293. if action[:2] == 's_':
  294. self.do_sort(action[2:])
  295. if action == 's_twmmid': self.show_mmid = True
  296. elif action == 'd_days': self.show_days = not self.show_days
  297. elif action == 'd_mmid': self.show_mmid = not self.show_mmid
  298. elif action == 'd_group':
  299. if self.can_group:
  300. self.group = not self.group
  301. elif action == 'd_redraw': pass
  302. elif action == 'd_reverse': self.unspent.reverse(); self.reverse = not self.reverse
  303. elif action == 'a_quit': msg(''); return self.unspent
  304. elif action == 'a_lbl_add':
  305. idx,lbl = self.get_idx_from_user(get_label=True)
  306. if idx:
  307. e = self.unspent[idx-1]
  308. if TrackingWallet(mode='w').add_label(e.twmmid,lbl,addr=e.addr):
  309. self.get_unspent_data()
  310. self.do_sort()
  311. a = 'added to' if lbl else 'removed from'
  312. oneshot_msg = yellow("Label {} {} #{}\n\n".format(a,self.item_desc,idx))
  313. else:
  314. oneshot_msg = red('Label could not be added\n\n')
  315. elif action == 'a_addr_remove':
  316. idx = self.get_idx_from_user()
  317. if idx:
  318. e = self.unspent[idx-1]
  319. if TrackingWallet(mode='w').remove_address(e.addr):
  320. self.get_unspent_data()
  321. self.do_sort()
  322. self.total = self.get_total_coin()
  323. oneshot_msg = yellow("{} #{} removed\n\n".format(capfirst(self.item_desc),idx))
  324. else:
  325. oneshot_msg = red('Address could not be removed\n\n')
  326. elif action == 'a_print':
  327. of = '{}-{}[{}].out'.format(self.dump_fn_pfx,g.dcoin,
  328. ','.join(self.sort_info(include_group=False)).lower())
  329. msg('')
  330. write_data_to_file(of,self.format_for_printing(),desc='{} listing'.format(self.desc))
  331. oneshot_msg = yellow("Data written to '{}'\n\n".format(of))
  332. elif action in ('a_view','a_view_wide'):
  333. do_pager(self.fmt_display if action == 'a_view' else self.format_for_printing(color=True))
  334. if g.platform == 'linux' and oneshot_msg == None:
  335. msg_r(CUR_RIGHT(len(prompt.split('\n')[-1])-2))
  336. no_output = True
  337. class TwAddrList(MMGenDict):
  338. def __new__(cls,*args,**kwargs):
  339. return MMGenDict.__new__(altcoin_subclass(cls,'tw','TwAddrList'),*args,**kwargs)
  340. def __init__(self,usr_addr_list,minconf,showempty,showbtcaddrs,all_labels):
  341. def check_dup_mmid(acct_labels):
  342. mmid_prev,err = None,False
  343. for mmid in sorted(a.mmid for a in acct_labels if a):
  344. if mmid == mmid_prev:
  345. err = True
  346. msg('Duplicate MMGen ID ({}) discovered in tracking wallet!\n'.format(mmid))
  347. mmid_prev = mmid
  348. if err: rdie(3,'Tracking wallet is corrupted!')
  349. def check_addr_array_lens(acct_pairs):
  350. err = False
  351. for label,addrs in acct_pairs:
  352. if not label: continue
  353. if len(addrs) != 1:
  354. err = True
  355. if len(addrs) == 0:
  356. msg("Label '{}': has no associated address!".format(label))
  357. else:
  358. msg("'{}': more than one {} address in account!".format(addrs,g.coin))
  359. if err: rdie(3,'Tracking wallet is corrupted!')
  360. self.total = g.proto.coin_amt('0')
  361. rpc_init()
  362. for d in g.rpch.listunspent(0):
  363. if not 'account' in d: continue # skip coinbase outputs with missing account
  364. if d['confirmations'] < minconf: continue
  365. label = TwLabel(d['account'],on_fail='silent')
  366. if label:
  367. if usr_addr_list and (label.mmid not in usr_addr_list): continue
  368. if label.mmid in self:
  369. if self[label.mmid]['addr'] != d['address']:
  370. die(2,'duplicate {} address ({}) for this MMGen address! ({})'.format(
  371. g.coin,d['address'],self[label.mmid]['addr']))
  372. else:
  373. self[label.mmid] = {'amt': g.proto.coin_amt('0'),
  374. 'lbl': label,
  375. 'addr': CoinAddr(d['address'])}
  376. self[label.mmid]['lbl'].mmid.confs = d['confirmations']
  377. self[label.mmid]['amt'] += d['amount']
  378. self.total += d['amount']
  379. # We use listaccounts only for empty addresses, as it shows false positive balances
  380. if showempty or all_labels:
  381. # for compatibility with old mmids, must use raw RPC rather than native data for matching
  382. # args: minconf,watchonly, MUST use keys() so we get list, not dict
  383. acct_list = g.rpch.listaccounts(0,True).keys() # raw list, no 'L'
  384. acct_labels = MMGenList([TwLabel(a,on_fail='silent') for a in acct_list])
  385. check_dup_mmid(acct_labels)
  386. acct_addrs = g.rpch.getaddressesbyaccount([[a] for a in acct_list],batch=True) # use raw list here
  387. assert len(acct_list) == len(acct_addrs),(
  388. 'listaccounts() and getaddressesbyaccount() not equal in length')
  389. addr_pairs = zip(acct_labels,acct_addrs)
  390. check_addr_array_lens(addr_pairs)
  391. for label,addr_arr in addr_pairs:
  392. if not label: continue
  393. if all_labels and not showempty and not label.comment: continue
  394. if usr_addr_list and (label.mmid not in usr_addr_list): continue
  395. if label.mmid not in self:
  396. self[label.mmid] = { 'amt':g.proto.coin_amt('0'), 'lbl':label, 'addr':'' }
  397. if showbtcaddrs:
  398. self[label.mmid]['addr'] = CoinAddr(addr_arr[0])
  399. def format(self,showbtcaddrs,sort,show_age,show_days):
  400. out = ['Chain: '+green(g.chain.upper())] if g.chain != 'mainnet' else []
  401. fs = u'{{mid}}{} {{cmt}} {{amt}}{}'.format(('',' {addr}')[showbtcaddrs],('',' {age}')[show_age])
  402. mmaddrs = [k for k in self.keys() if k.type == 'mmgen']
  403. max_mmid_len = max(len(k) for k in mmaddrs) + 2 if mmaddrs else 10
  404. max_cmt_len = max(max(screen_width(v['lbl'].comment) for v in self.values()),7)
  405. addr_width = max(len(self[mmid]['addr']) for mmid in self)
  406. # fp: fractional part
  407. max_fp_len = max([len(a.split('.')[1]) for a in [str(v['amt']) for v in self.values()] if '.' in a] or [1])
  408. out += [fs.format(
  409. mid=MMGenID.fmtc('MMGenID',width=max_mmid_len),
  410. addr=(CoinAddr.fmtc('ADDRESS',width=addr_width) if showbtcaddrs else None),
  411. cmt=TwComment.fmtc('COMMENT',width=max_cmt_len+1),
  412. amt='BALANCE'.ljust(max_fp_len+4),
  413. age=('CONFS','DAYS')[show_days],
  414. )]
  415. def sort_algo(j):
  416. if sort and 'age' in sort:
  417. return '{}_{:>012}_{}'.format(
  418. j.obj.rsplit(':',1)[0],
  419. # Hack, but OK for the foreseeable future:
  420. (1000000000-j.confs if hasattr(j,'confs') and j.confs != None else 0),
  421. j.sort_key)
  422. else:
  423. return j.sort_key
  424. al_id_save = None
  425. confs_per_day = 60*60*24 / g.proto.secs_per_block
  426. for mmid in sorted(self,key=sort_algo,reverse=bool(sort and 'reverse' in sort)):
  427. if mmid.type == 'mmgen':
  428. if al_id_save and al_id_save != mmid.obj.al_id:
  429. out.append('')
  430. al_id_save = mmid.obj.al_id
  431. mmid_disp = mmid
  432. else:
  433. if al_id_save:
  434. out.append('')
  435. al_id_save = None
  436. mmid_disp = 'Non-MMGen'
  437. e = self[mmid]
  438. out.append(fs.format(
  439. mid=MMGenID.fmtc(mmid_disp,width=max_mmid_len,color=True),
  440. addr=(e['addr'].fmt(color=True,width=addr_width) if showbtcaddrs else None),
  441. cmt=e['lbl'].comment.fmt(width=max_cmt_len,color=True,nullrepl='-'),
  442. amt=e['amt'].fmt('4.{}'.format(max(max_fp_len,3)),color=True),
  443. age=mmid.confs / (1,confs_per_day)[show_days] if hasattr(mmid,'confs') and mmid.confs != None else '-'
  444. ))
  445. return '\n'.join(out + ['\nTOTAL: {} {}'.format(self.total.hl(color=True),g.dcoin)])
  446. class TrackingWallet(MMGenObject):
  447. def __new__(cls,*args,**kwargs):
  448. return MMGenObject.__new__(altcoin_subclass(cls,'tw','TrackingWallet'),*args,**kwargs)
  449. mode = 'r'
  450. caps = ('rescan','batch')
  451. def __init__(self,mode='r'):
  452. m = "'{}': invalid 'mode' parameter for {} constructor"
  453. assert mode in ('r','w'),m.format(mode,type(self).__name__)
  454. self.mode = mode
  455. @write_mode
  456. def import_address(self,addr,label,rescan):
  457. return g.rpch.importaddress(addr,label,rescan,timeout=(False,3600)[rescan])
  458. @write_mode
  459. def batch_import_address(self,arg_list):
  460. return g.rpch.importaddress(arg_list,batch=True)
  461. @write_mode
  462. def write(self): pass
  463. def is_in_wallet(self,addr):
  464. d = g.rpch.validateaddress(addr)
  465. return d['iswatchonly'] and 'account' in d
  466. @write_mode
  467. def import_label(self,coinaddr,lbl):
  468. # NOTE: this works because importaddress() removes the old account before
  469. # associating the new account with the address.
  470. # Will be replaced by setlabel() with new RPC label API
  471. # RPC args: addr,label,rescan[=true],p2sh[=none]
  472. return g.rpch.importaddress(coinaddr,lbl,False,on_fail='return')
  473. # returns on failure
  474. @write_mode
  475. def add_label(self,arg1,label='',addr=None,silent=False,on_fail='return'):
  476. from mmgen.tx import is_mmgen_id,is_coin_addr
  477. mmaddr,coinaddr = None,None
  478. if is_coin_addr(addr or arg1):
  479. coinaddr = CoinAddr(addr or arg1,on_fail='return')
  480. if is_mmgen_id(arg1):
  481. mmaddr = TwMMGenID(arg1)
  482. if mmaddr and not coinaddr:
  483. from mmgen.addr import AddrData
  484. coinaddr = AddrData(source='tw').mmaddr2coinaddr(mmaddr)
  485. try:
  486. if not is_mmgen_id(arg1):
  487. assert coinaddr,u"Invalid coin address for this chain: {}".format(arg1)
  488. assert coinaddr,u"{pn} address '{ma}' not found in tracking wallet"
  489. assert self.is_in_wallet(coinaddr),u"Address '{ca}' not found in tracking wallet"
  490. except Exception as e:
  491. msg(e.message.format(pn=g.proj_name,ma=mmaddr,ca=coinaddr))
  492. return False
  493. # Allow for the possibility that BTC addr of MMGen addr was entered.
  494. # Do reverse lookup, so that MMGen addr will not be marked as non-MMGen.
  495. if not mmaddr:
  496. from mmgen.addr import AddrData
  497. mmaddr = AddrData(source='tw').coinaddr2mmaddr(coinaddr)
  498. if not mmaddr: mmaddr = '{}:{}'.format(g.proto.base_coin.lower(),coinaddr)
  499. mmaddr = TwMMGenID(mmaddr)
  500. cmt = TwComment(label,on_fail=on_fail)
  501. if cmt in (False,None): return False
  502. lbl = TwLabel(mmaddr + ('',' '+cmt)[bool(cmt)],on_fail=on_fail)
  503. ret = self.import_label(coinaddr,lbl)
  504. from mmgen.rpc import rpc_error,rpc_errmsg
  505. if rpc_error(ret):
  506. msg('From {}: {}'.format(g.proto.daemon_name,rpc_errmsg(ret)))
  507. if not silent:
  508. msg('Label could not be {}'.format(('removed','added')[bool(label)]))
  509. return False
  510. else:
  511. m = mmaddr.type.replace('mmg','MMG')
  512. a = mmaddr.replace(g.proto.base_coin.lower()+':','')
  513. s = '{} address {} in tracking wallet'.format(m,a)
  514. if label: msg(u"Added label '{}' to {}".format(label,s))
  515. else: msg(u'Removed label from {}'.format(s))
  516. return True
  517. @write_mode
  518. def remove_label(self,mmaddr):
  519. self.add_label(mmaddr,'')
  520. @write_mode
  521. def remove_address(self,addr):
  522. raise NotImplementedError,'address removal not implemented for coin {}'.format(g.coin)
  523. class TwGetBalance(MMGenObject):
  524. fs = '{w:13} {u:<16} {p:<16} {c}\n'
  525. def __new__(cls,*args,**kwargs):
  526. return MMGenObject.__new__(altcoin_subclass(cls,'tw','TwGetBalance'),*args,**kwargs)
  527. def __init__(self,minconf,quiet):
  528. rpc_init()
  529. self.minconf = minconf
  530. self.quiet = quiet
  531. self.data = dict([(k,[g.proto.coin_amt('0')] * 4) for k in 'TOTAL','Non-MMGen','Non-wallet'])
  532. self.create_data()
  533. def create_data(self):
  534. # 0: unconfirmed, 1: below minconf, 2: confirmed, 3: spendable
  535. for d in g.rpch.listunspent(0):
  536. try: lbl = TwLabel(d['account'],on_fail='silent')
  537. except: lbl = None
  538. if lbl:
  539. if lbl.mmid.type == 'mmgen':
  540. key = lbl.mmid.obj.sid
  541. if key not in self.data:
  542. self.data[key] = [g.proto.coin_amt('0')] * 4
  543. else: key = 'Non-MMGen'
  544. else: key = 'Non-wallet'
  545. conf_level = 0 if not d['confirmations'] else 1 if d['confirmations'] < self.minconf else 2
  546. self.data['TOTAL'][conf_level] += d['amount']
  547. self.data[key][conf_level] += d['amount']
  548. if d['spendable']:
  549. self.data[key][3] += d['amount']
  550. def format(self):
  551. if self.quiet:
  552. o = str(self.data['TOTAL'][2] if self.data else 0) + '\n'
  553. else:
  554. o = self.fs.format( w='Wallet',
  555. u=' Unconfirmed',
  556. p=' <{} confirms'.format(self.minconf),
  557. c=' >={} confirms'.format(self.minconf))
  558. for key in sorted(self.data):
  559. if not any(self.data[key]): continue
  560. o += self.fs.format(**dict(zip(
  561. ('w','u','p','c'),
  562. [key+':'] + [a.fmt(color=True,suf=' '+g.dcoin) for a in self.data[key]]
  563. )))
  564. for key,vals in self.data.items():
  565. if key == 'TOTAL': continue
  566. if vals[3]:
  567. o += red('Warning: this wallet contains PRIVATE KEYS for {} outputs!\n'.format(key))
  568. return o