tw.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  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: '%s %03s' % (i.txid,i.vout),
  95. 'mmid': 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'
  115. m2 = 'Please resize your screen to at least {} characters and hit ENTER '
  116. my_raw_input(m1+'\n'+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. # Field widths
  125. min_mmid_w = 12 # DEADBEEF:S:1
  126. mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in unsp) or min_mmid_w
  127. max_acct_w = max(len(i.label) for i in unsp) + mmid_w + 1
  128. addr_w = min(35+(0,1+max_acct_w)[self.show_mmid],self.cols-45)
  129. acct_w = min(max_acct_w, max(24,int(addr_w-10)))
  130. btaddr_w = addr_w - acct_w - 1
  131. label_w = acct_w - mmid_w - 1
  132. tx_w = max(11,min(64, self.cols-addr_w-32))
  133. txdots = ('','...')[tx_w < 64]
  134. fs = ' %-4s %-{}s %-2s %s %s %s'.format(tx_w)
  135. for i in unsp: i.skip = None
  136. if self.group and (self.sort_key in ('addr','txid','twmmid')):
  137. for a,b in [(unsp[i],unsp[i+1]) for i in range(len(unsp)-1)]:
  138. for k in ('addr','txid','twmmid'):
  139. if self.sort_key == k and getattr(a,k) == getattr(b,k):
  140. b.skip = (k,'addr')[k=='twmmid']
  141. hdr_fmt = 'UNSPENT OUTPUTS (sort order: {}) Total {}: {}'
  142. out = [hdr_fmt.format(' '.join(self.sort_info()),g.coin,self.total.hl())]
  143. if g.chain in ('testnet','regtest'):
  144. out += [green('Chain: {}'.format(g.chain.upper()))]
  145. af = CoinAddr.fmtc('Address',width=addr_w+1)
  146. cf = ('Conf.','Age(d)')[self.show_days]
  147. out += [fs % ('Num','TX id'.ljust(tx_w - 5) + ' Vout','',af,'Amt({}) '.format(g.coin),cf)]
  148. for n,i in enumerate(unsp):
  149. addr_dots = '|' + '.'*33
  150. mmid_disp = MMGenID.fmtc('.'*mmid_w if i.skip=='addr'
  151. else i.twmmid if i.twmmid.type=='mmgen'
  152. else 'Non-{}'.format(g.proj_name),width=mmid_w,color=True)
  153. if self.show_mmid:
  154. addr_out = '%s %s' % (
  155. type(i.addr).fmtc(addr_dots,width=btaddr_w,color=True) if i.skip == 'addr' \
  156. else i.addr.fmt(width=btaddr_w,color=True),
  157. '{} {}'.format(mmid_disp,i.label.fmt(width=label_w,color=True) \
  158. if label_w > 0 else '')
  159. )
  160. else:
  161. addr_out = type(i.addr).fmtc(addr_dots,width=addr_w,color=True) \
  162. if i.skip=='addr' else i.addr.fmt(width=addr_w,color=True)
  163. tx = ' ' * (tx_w-4) + '|...' if i.skip == 'txid' \
  164. else i.txid[:tx_w-len(txdots)]+txdots
  165. out.append(fs % (str(n+1)+')',tx,i.vout,addr_out,i.amt.fmt(color=True),
  166. i.days if self.show_days else i.confs))
  167. self.fmt_display = '\n'.join(out) + '\n'
  168. # unsp.pdie()
  169. return self.fmt_display
  170. def format_for_printing(self,color=False):
  171. fs = ' %-4s %-67s %s %s %s %-8s %-6s %s'
  172. out = [fs % ('Num','Tx ID,Vout','Address'.ljust(34),'MMGen ID'.ljust(15),
  173. 'Amount({})'.format(g.coin),'Conf.','Age(d)', 'Label')]
  174. max_lbl_len = max([len(i.label) for i in self.unspent if i.label] or [1])
  175. for n,i in enumerate(self.unspent):
  176. addr = '|'+'.' * 34 if i.skip == 'addr' and self.group else i.addr.fmt(color=color)
  177. tx = '|'+'.' * 63 if i.skip == 'txid' and self.group else str(i.txid)
  178. s = fs % (str(n+1)+')', tx+','+str(i.vout),addr,
  179. MMGenID.fmtc(i.twmmid if i.twmmid.type=='mmgen'
  180. else 'Non-{}'.format(g.proj_name),width=14,color=color),
  181. i.amt.fmt(color=color),i.confs,i.days,
  182. i.label.hl(color=color) if i.label else
  183. TwComment.fmtc('',color=color,nullrepl='-',width=max_lbl_len))
  184. out.append(s.rstrip())
  185. fs = 'Unspent outputs ({} UTC)\nSort order: {}\n\n{}\n\nTotal {}: {}\n'
  186. self.fmt_print = fs.format(
  187. make_timestr(),
  188. ' '.join(self.sort_info(include_group=False)),
  189. '\n'.join(out),
  190. g.coin,
  191. self.total.hl(color=color))
  192. return self.fmt_print
  193. def display_total(self):
  194. fs = '\nTotal unspent: {} {} ({} outputs)'
  195. msg(fs.format(self.total.hl(),g.coin,len(self.unspent)))
  196. def get_idx_and_label_from_user(self):
  197. msg('')
  198. while True:
  199. ret = my_raw_input("Enter unspent output number (or 'q' to return to main menu): ")
  200. if ret == 'q': return None,None
  201. n = AddrIdx(ret,on_fail='silent') # hacky way to test and convert to integer
  202. if not n or n < 1 or n > len(self.unspent):
  203. msg('Choice must be a single number between 1 and %s' % len(self.unspent))
  204. # elif not self.unspent[n-1].mmid:
  205. # msg('Address #%s is not an %s address. No label can be added to it' %
  206. # (n,g.proj_name))
  207. else:
  208. while True:
  209. s = my_raw_input("Enter label text (or 'q' to return to main menu): ")
  210. if s == 'q':
  211. return None,None
  212. elif s == '':
  213. if keypress_confirm(
  214. "Removing label for address #%s. Is this what you want?" % n):
  215. return n,s
  216. elif s:
  217. if TwComment(s,on_fail='return'):
  218. return n,s
  219. def view_and_sort(self,tx):
  220. fs = 'Total to spend, excluding fees: {} {}\n\n'
  221. txos = fs.format(tx.sum_outputs().hl(),g.coin) if tx.outputs else ''
  222. prompt = """
  223. {}Sort options: [t]xid, [a]mount, a[d]dress, [A]ge, [r]everse, [M]mgen addr
  224. Display options: show [D]ays, [g]roup, show [m]mgen addr, r[e]draw screen
  225. """.format(txos).strip()
  226. self.display()
  227. msg(prompt)
  228. from mmgen.term import get_char
  229. p = "'q'=quit view, 'p'=print to file, 'v'=pager view, 'w'=wide view, 'l'=add label:\b"
  230. while True:
  231. reply = get_char(p, immed_chars='atDdAMrgmeqpvw')
  232. if reply == 'a': self.do_sort('amt')
  233. elif reply == 'A': self.do_sort('age')
  234. elif reply == 'd': self.do_sort('addr')
  235. elif reply == 'D': self.show_days = not self.show_days
  236. elif reply == 'e': msg('\n%s\n%s\n%s' % (self.fmt_display,prompt,p))
  237. elif reply == 'g': self.group = not self.group
  238. elif reply == 'l':
  239. idx,lbl = self.get_idx_and_label_from_user()
  240. if idx:
  241. e = self.unspent[idx-1]
  242. if type(self).add_label(e.twmmid,lbl,addr=e.addr):
  243. self.get_unspent_data()
  244. self.do_sort()
  245. msg('%s\n%s\n%s' % (self.fmt_display,prompt,p))
  246. else:
  247. msg('Label could not be added\n%s\n%s' % (prompt,p))
  248. elif reply == 'M': self.do_sort('mmid'); self.show_mmid = True
  249. elif reply == 'm': self.show_mmid = not self.show_mmid
  250. elif reply == 'p':
  251. msg('')
  252. of = 'listunspent[%s].out' % ','.join(self.sort_info(include_group=False)).lower()
  253. write_data_to_file(of,self.format_for_printing(),'unspent outputs listing')
  254. m = yellow("Data written to '%s'" % of)
  255. msg('\n%s\n%s\n\n%s' % (self.fmt_display,m,prompt))
  256. continue
  257. elif reply == 'q': return self.unspent
  258. elif reply == 'r': self.unspent.reverse(); self.reverse = not self.reverse
  259. elif reply == 't': self.do_sort('txid')
  260. elif reply == 'v':
  261. do_pager(self.fmt_display)
  262. continue
  263. elif reply == 'w':
  264. do_pager(self.format_for_printing(color=True))
  265. continue
  266. else:
  267. msg('\nInvalid input')
  268. continue
  269. msg('\n')
  270. self.display()
  271. msg(prompt)
  272. # returns on failure
  273. @classmethod
  274. def add_label(cls,arg1,label='',addr=None,silent=False):
  275. from mmgen.tx import is_mmgen_id,is_coin_addr
  276. mmaddr,coinaddr = None,None
  277. if is_coin_addr(addr or arg1):
  278. coinaddr = CoinAddr(addr or arg1,on_fail='return')
  279. if is_mmgen_id(arg1):
  280. mmaddr = TwMMGenID(arg1)
  281. if not coinaddr and not mmaddr:
  282. msg("Address '{}' invalid or not found in tracking wallet".format(addr or arg1))
  283. return False
  284. if not coinaddr:
  285. from mmgen.addr import AddrData
  286. coinaddr = AddrData(source='tw').mmaddr2coinaddr(mmaddr)
  287. if not coinaddr:
  288. msg("{} address '{}' not found in tracking wallet".format(g.proj_name,mmaddr))
  289. return False
  290. # Checked that the user isn't importing a random address
  291. if not coinaddr.is_in_tracking_wallet():
  292. msg("Address '{}' not in tracking wallet".format(coinaddr))
  293. return False
  294. if not coinaddr.is_for_chain(g.chain):
  295. msg("Address '{}' not valid for chain {}".format(coinaddr,g.chain.upper()))
  296. return False
  297. # Allow for the possibility that BTC addr of MMGen addr was entered.
  298. # Do reverse lookup, so that MMGen addr will not be marked as non-MMGen.
  299. if not mmaddr:
  300. from mmgen.addr import AddrData
  301. ad = AddrData(source='tw')
  302. mmaddr = ad.coinaddr2mmaddr(coinaddr)
  303. if not mmaddr: mmaddr = '{}:{}'.format(g.proto.base_coin.lower(),coinaddr)
  304. mmaddr = TwMMGenID(mmaddr)
  305. cmt = TwComment(label,on_fail='return')
  306. if cmt in (False,None): return False
  307. lbl = TwLabel(mmaddr + ('',' '+cmt)[bool(cmt)]) # label is ASCII for now
  308. # NOTE: this works because importaddress() removes the old account before
  309. # associating the new account with the address.
  310. # Will be replaced by setlabel() with new RPC label API
  311. # RPC args: addr,label,rescan[=true],p2sh[=none]
  312. ret = g.rpch.importaddress(coinaddr,lbl,False,on_fail='return')
  313. from mmgen.rpc import rpc_error,rpc_errmsg
  314. if rpc_error(ret):
  315. msg('From {}: {}'.format(g.proto.daemon_name,rpc_errmsg(ret)))
  316. if not silent:
  317. msg('Label could not be {}'.format(('removed','added')[bool(label)]))
  318. return False
  319. else:
  320. m = mmaddr.type.replace('mmg','MMG')
  321. a = mmaddr.replace(g.proto.base_coin.lower()+':','')
  322. s = '{} address {} in tracking wallet'.format(m,a)
  323. if label: msg("Added label '{}' to {}".format(label,s))
  324. else: msg('Removed label from {}'.format(s))
  325. return True
  326. @classmethod
  327. def remove_label(cls,mmaddr): cls.add_label(mmaddr,'')