tw.py 14 KB

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