tw.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  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. class MMGenTrackingWallet(MMGenObject):
  26. class MMGenTwOutputList(list,MMGenObject): pass
  27. class MMGenTwUnspentOutput(MMGenListItem):
  28. # attrs = 'txid','vout','amt','label','twmmid','addr','confs','scriptPubKey','days','skip'
  29. txid = MMGenImmutableAttr('txid','CoinTxID')
  30. vout = MMGenImmutableAttr('vout',int,typeconv=False),
  31. amt = MMGenImmutableAttr('amt',g.proto.coin_amt.__name__),
  32. label = MMGenListItemAttr('label','TwComment',reassign_ok=True),
  33. twmmid = MMGenImmutableAttr('twmmid','TwMMGenID')
  34. addr = MMGenImmutableAttr('addr','CoinAddr'),
  35. confs = MMGenImmutableAttr('confs',int,typeconv=False),
  36. scriptPubKey = MMGenImmutableAttr('scriptPubKey','HexStr')
  37. days = MMGenListItemAttr('days',int,typeconv=False),
  38. skip = MMGenListItemAttr('skip',bool,typeconv=False,reassign_ok=True),
  39. wmsg = {
  40. 'no_spendable_outputs': """
  41. No spendable outputs found! Import addresses with balances into your
  42. watch-only wallet using '{}-addrimport' and then re-run this program.
  43. """.strip().format(g.proj_name.lower())
  44. }
  45. def __init__(self,minconf=1):
  46. self.unspent = self.MMGenTwOutputList()
  47. self.fmt_display = ''
  48. self.fmt_print = ''
  49. self.cols = None
  50. self.reverse = False
  51. self.group = False
  52. self.show_days = True
  53. self.show_mmid = True
  54. self.minconf = minconf
  55. self.get_unspent_data()
  56. self.sort_key = 'age'
  57. self.do_sort()
  58. self.total = self.get_total_coin()
  59. def get_total_coin(self):
  60. return sum(i.amt for i in self.unspent)
  61. def get_unspent_data(self):
  62. if g.bogus_wallet_data: # for debugging purposes only
  63. us_rpc = eval(get_data_from_file(g.bogus_wallet_data)) # testing, so ok
  64. else:
  65. us_rpc = g.rpch.listunspent(self.minconf)
  66. # write_data_to_file('bogus_unspent.json', repr(us), 'bogus unspent data')
  67. # sys.exit(0)
  68. if not us_rpc: die(0,self.wmsg['no_spendable_outputs'])
  69. mm_rpc = self.MMGenTwOutputList()
  70. confs_per_day = 60*60*24 / g.proto.secs_per_block
  71. for o in us_rpc:
  72. if not 'account' in o: continue # coinbase outputs have no account field
  73. l = TwLabel(o['account'],on_fail='silent')
  74. if l:
  75. o.update({
  76. 'twmmid': l.mmid,
  77. 'label': l.comment,
  78. 'days': int(o['confirmations'] / confs_per_day),
  79. 'amt': g.proto.coin_amt(o['amount']), # TODO
  80. 'addr': CoinAddr(o['address']), # TODO
  81. 'confs': o['confirmations']
  82. })
  83. mm_rpc.append(o)
  84. self.unspent = self.MMGenTwOutputList([self.MMGenTwUnspentOutput(**dict([(k,v) for k,v in o.items() if k in self.MMGenTwUnspentOutput.__dict__])) for o in mm_rpc])
  85. for u in self.unspent:
  86. if u.label == None: u.label = ''
  87. if not self.unspent:
  88. die(1,'No tracked unspent outputs in tracking wallet!')
  89. def do_sort(self,key=None,reverse=False):
  90. sort_funcs = {
  91. 'addr': lambda i: i.addr,
  92. 'age': lambda i: 0 - i.confs,
  93. 'amt': lambda i: i.amt,
  94. 'txid': lambda i: '{} {:04}'.format(i.txid,i.vout),
  95. 'twmmid': lambda i: i.twmmid.sort_key
  96. }
  97. key = key or self.sort_key
  98. if key not in sort_funcs:
  99. die(1,"'{}': invalid sort key. Valid options: {}".format(key,' '.join(sort_funcs.keys())))
  100. self.sort_key = key
  101. assert type(reverse) == bool
  102. self.unspent.sort(key=sort_funcs[key],reverse=reverse or self.reverse)
  103. def sort_info(self,include_group=True):
  104. ret = ([],['Reverse'])[self.reverse]
  105. ret.append(capfirst(self.sort_key).replace('Twmmid','MMGenID'))
  106. if include_group and self.group and (self.sort_key in ('addr','txid','twmmid')):
  107. ret.append('Grouped')
  108. return ret
  109. def set_term_columns(self):
  110. from mmgen.term import get_terminal_size
  111. while True:
  112. self.cols = get_terminal_size()[0]
  113. if self.cols >= g.min_screen_width: break
  114. m1 = 'Screen too narrow to display the tracking wallet\n'
  115. m2 = 'Please resize your screen to at least {} characters and hit ENTER '
  116. my_raw_input((m1+m2).format(g.min_screen_width))
  117. def display(self):
  118. if not opt.no_blank: msg(CUR_HOME+ERASE_ALL)
  119. msg(self.format_for_display())
  120. def format_for_display(self):
  121. unsp = self.unspent
  122. # unsp.pdie()
  123. self.set_term_columns()
  124. # allow for 7-digit confirmation nums
  125. col1_w = max(3,len(str(len(unsp)))+1) # num + ')'
  126. mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in unsp) or 12 # DEADBEEF:S:1
  127. max_acct_w = max(len(i.label) for i in unsp) + mmid_w + 1
  128. max_btcaddr_w = max(len(i.addr) for i in unsp)
  129. min_addr_w = self.cols - 38
  130. addr_w = min(max_btcaddr_w + (0,1+max_acct_w)[self.show_mmid],min_addr_w)
  131. acct_w = min(max_acct_w, max(24,addr_w-10))
  132. btaddr_w = addr_w - acct_w - 1
  133. label_w = acct_w - mmid_w - 1
  134. tx_w = min(64,self.cols-addr_w-28-col1_w) # min=7
  135. txdots = ('','..')[tx_w < 64]
  136. for i in unsp: i.skip = None
  137. if self.group and (self.sort_key in ('addr','txid','twmmid')):
  138. for a,b in [(unsp[i],unsp[i+1]) for i in range(len(unsp)-1)]:
  139. for k in ('addr','txid','twmmid'):
  140. if self.sort_key == k and getattr(a,k) == getattr(b,k):
  141. b.skip = (k,'addr')[k=='twmmid']
  142. hdr_fmt = 'UNSPENT OUTPUTS (sort order: {}) Total {}: {}'
  143. out = [hdr_fmt.format(' '.join(self.sort_info()),g.coin,self.total.hl())]
  144. if g.chain in ('testnet','regtest'):
  145. out += [green('Chain: {}'.format(g.chain.upper()))]
  146. fs = u' {:%s} {:%s} {:2} {} {} {:<}' % (col1_w,tx_w)
  147. out += [fs.format('Num',
  148. 'TXid'.ljust(tx_w - 5) + ' Vout', '',
  149. 'Address'.ljust(addr_w),
  150. 'Amt({})'.format(g.coin).ljust(12),
  151. ('Confs','Age(d)')[self.show_days])]
  152. for n,i in enumerate(unsp):
  153. addr_dots = '|' + '.'*(addr_w-1)
  154. mmid_disp = MMGenID.fmtc('.'*mmid_w if i.skip=='addr'
  155. else i.twmmid if i.twmmid.type=='mmgen'
  156. else 'Non-{}'.format(g.proj_name),width=mmid_w,color=True)
  157. if self.show_mmid:
  158. addr_out = u'{} {}'.format(
  159. type(i.addr).fmtc(addr_dots,width=btaddr_w,color=True) if i.skip == 'addr' \
  160. else i.addr.fmt(width=btaddr_w,color=True),
  161. u'{} {}'.format(mmid_disp,i.label.fmt(width=label_w,color=True) \
  162. if label_w > 0 else ''))
  163. else:
  164. addr_out = type(i.addr).fmtc(addr_dots,width=addr_w,color=True) \
  165. if i.skip=='addr' else i.addr.fmt(width=addr_w,color=True)
  166. tx = ' ' * (tx_w-4) + '|...' if i.skip == 'txid' \
  167. else i.txid[:tx_w-len(txdots)]+txdots
  168. out.append(fs.format(str(n+1)+')',tx,i.vout,addr_out,i.amt.fmt(color=True),
  169. i.days if self.show_days else i.confs))
  170. self.fmt_display = '\n'.join(out) + '\n'
  171. # unsp.pdie()
  172. return self.fmt_display
  173. def format_for_printing(self,color=False):
  174. addr_w = max(len(i.addr) for i in self.unspent)
  175. mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in self.unspent) or 12 # DEADBEEF:S:1
  176. fs = ' {:4} {:67} {} {} {:12} {:<8} {:<6} {}'
  177. out = [fs.format('Num','Tx ID,Vout',
  178. 'Address'.ljust(addr_w),
  179. 'MMGen ID'.ljust(mmid_w+1),
  180. 'Amount({})'.format(g.coin),
  181. 'Confs','Age(d)',
  182. 'Label')]
  183. max_lbl_len = max([len(i.label) for i in self.unspent if i.label] or [1])
  184. for n,i in enumerate(self.unspent):
  185. addr = '|'+'.' * addr_w if i.skip == 'addr' and self.group else i.addr.fmt(color=color,width=addr_w)
  186. tx = '|'+'.' * 63 if i.skip == 'txid' and self.group else str(i.txid)
  187. out.append(
  188. fs.format(str(n+1)+')', tx+','+str(i.vout),
  189. addr,
  190. MMGenID.fmtc(i.twmmid if i.twmmid.type=='mmgen'
  191. else 'Non-{}'.format(g.proj_name),width=mmid_w,color=color),
  192. i.amt.fmt(color=color),
  193. i.confs,i.days,
  194. i.label.hl(color=color) if i.label else
  195. TwComment.fmtc('',color=color,nullrepl='-',width=max_lbl_len)).rstrip())
  196. fs = 'Unspent outputs ({} UTC)\nSort order: {}\n\n{}\n\nTotal {}: {}\n'
  197. self.fmt_print = fs.format(
  198. make_timestr(),
  199. ' '.join(self.sort_info(include_group=False)),
  200. '\n'.join(out),
  201. g.coin,
  202. self.total.hl(color=color))
  203. return self.fmt_print
  204. def display_total(self):
  205. fs = '\nTotal unspent: {} {} ({} outputs)'
  206. msg(fs.format(self.total.hl(),g.coin,len(self.unspent)))
  207. def get_idx_and_label_from_user(self):
  208. msg('')
  209. while True:
  210. ret = my_raw_input("Enter unspent output number (or 'q' to return to main menu): ")
  211. if ret == 'q': return None,None
  212. n = AddrIdx(ret,on_fail='silent') # hacky way to test and convert to integer
  213. if not n or n < 1 or n > len(self.unspent):
  214. msg('Choice must be a single number between 1 and {}'.format(len(self.unspent)))
  215. # elif not self.unspent[n-1].mmid:
  216. # msg('Address #{} is not an {} address. No label can be added to it'.format(n,g.proj_name))
  217. else:
  218. while True:
  219. s = my_raw_input("Enter label text (or 'q' to return to main menu): ")
  220. if s == 'q':
  221. return None,None
  222. elif s == '':
  223. fs = "Removing label for address #{}. Is this what you want?"
  224. if keypress_confirm(fs.format(n)):
  225. return n,s
  226. elif s:
  227. if TwComment(s,on_fail='return'):
  228. return n,s
  229. def view_and_sort(self,tx):
  230. fs = 'Total to spend, excluding fees: {} {}\n\n'
  231. txos = fs.format(tx.sum_outputs().hl(),g.coin) if tx.outputs else ''
  232. prompt = """
  233. {}Sort options: [t]xid, [a]mount, a[d]dress, [A]ge, [r]everse, [M]mgen addr
  234. Display options: show [D]ays, [g]roup, show [m]mgen addr, r[e]draw screen
  235. """.format(txos).strip()
  236. self.display()
  237. msg(prompt)
  238. from mmgen.term import get_char
  239. p = "'q'=quit view, 'p'=print to file, 'v'=pager view, 'w'=wide view, 'l'=add label:\b"
  240. while True:
  241. reply = get_char(p, immed_chars='atDdAMrgmeqpvw')
  242. if reply == 'a': self.do_sort('amt')
  243. elif reply == 'A': self.do_sort('age')
  244. elif reply == 'd': self.do_sort('addr')
  245. elif reply == 'D': self.show_days = not self.show_days
  246. elif reply == 'e': msg('\n{}\n{}\n{}'.format(self.fmt_display,prompt,p))
  247. elif reply == 'g': self.group = not self.group
  248. elif reply == 'l':
  249. idx,lbl = self.get_idx_and_label_from_user()
  250. if idx:
  251. e = self.unspent[idx-1]
  252. if type(self).add_label(e.twmmid,lbl,addr=e.addr):
  253. self.get_unspent_data()
  254. self.do_sort()
  255. msg(u'{}\n{}\n{}'.format(self.fmt_display,prompt,p))
  256. else:
  257. msg('Label could not be added\n{}\n{}'.format(prompt,p))
  258. elif reply == 'M': self.do_sort('twmmid'); self.show_mmid = True
  259. elif reply == 'm': self.show_mmid = not self.show_mmid
  260. elif reply == 'p':
  261. msg('')
  262. of = 'listunspent[{}].out'.format(','.join(self.sort_info(include_group=False))).lower()
  263. write_data_to_file(of,self.format_for_printing(),'unspent outputs listing')
  264. m = yellow("Data written to '{}'".format(of))
  265. msg('\n{}\n{}\n\n{}'.format(self.fmt_display,m,prompt))
  266. continue
  267. elif reply == 'q': return self.unspent
  268. elif reply == 'r': self.unspent.reverse(); self.reverse = not self.reverse
  269. elif reply == 't': self.do_sort('txid')
  270. elif reply == 'v':
  271. do_pager(self.fmt_display)
  272. continue
  273. elif reply == 'w':
  274. do_pager(self.format_for_printing(color=True))
  275. continue
  276. else:
  277. msg('\nInvalid input')
  278. continue
  279. msg('\n')
  280. self.display()
  281. msg(prompt)
  282. # returns on failure
  283. @classmethod
  284. def add_label(cls,arg1,label='',addr=None,silent=False,on_fail='return'):
  285. from mmgen.tx import is_mmgen_id,is_coin_addr
  286. mmaddr,coinaddr = None,None
  287. if is_coin_addr(addr or arg1):
  288. coinaddr = CoinAddr(addr or arg1,on_fail='return')
  289. if is_mmgen_id(arg1):
  290. mmaddr = TwMMGenID(arg1)
  291. if mmaddr and not coinaddr:
  292. from mmgen.addr import AddrData
  293. coinaddr = AddrData(source='tw').mmaddr2coinaddr(mmaddr)
  294. try:
  295. if not is_mmgen_id(arg1):
  296. assert coinaddr,u"Invalid coin address for this chain: {}".format(arg1)
  297. assert coinaddr,u"{pn} address '{ma}' not found in tracking wallet"
  298. assert coinaddr.is_in_tracking_wallet(),u"Address '{ca}' not found in tracking wallet"
  299. except Exception as e:
  300. msg(e[0].format(pn=g.proj_name,ma=mmaddr,ca=coinaddr))
  301. return False
  302. # Allow for the possibility that BTC addr of MMGen addr was entered.
  303. # Do reverse lookup, so that MMGen addr will not be marked as non-MMGen.
  304. if not mmaddr:
  305. from mmgen.addr import AddrData
  306. mmaddr = AddrData(source='tw').coinaddr2mmaddr(coinaddr)
  307. if not mmaddr: mmaddr = '{}:{}'.format(g.proto.base_coin.lower(),coinaddr)
  308. mmaddr = TwMMGenID(mmaddr)
  309. cmt = TwComment(label,on_fail=on_fail)
  310. if cmt in (False,None): return False
  311. lbl = TwLabel(mmaddr + ('',' '+cmt)[bool(cmt)],on_fail=on_fail)
  312. # NOTE: this works because importaddress() removes the old account before
  313. # associating the new account with the address.
  314. # Will be replaced by setlabel() with new RPC label API
  315. # RPC args: addr,label,rescan[=true],p2sh[=none]
  316. ret = g.rpch.importaddress(coinaddr,lbl,False,on_fail='return')
  317. from mmgen.rpc import rpc_error,rpc_errmsg
  318. if rpc_error(ret):
  319. msg('From {}: {}'.format(g.proto.daemon_name,rpc_errmsg(ret)))
  320. if not silent:
  321. msg('Label could not be {}'.format(('removed','added')[bool(label)]))
  322. return False
  323. else:
  324. m = mmaddr.type.replace('mmg','MMG')
  325. a = mmaddr.replace(g.proto.base_coin.lower()+':','')
  326. s = '{} address {} in tracking wallet'.format(m,a)
  327. if label: msg(u"Added label '{}' to {}".format(label,s))
  328. else: msg(u'Removed label from {}'.format(s))
  329. return True
  330. @classmethod
  331. def remove_label(cls,mmaddr): cls.add_label(mmaddr,'')