tw.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. #!/usr/bin/env python
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2016 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.term import get_char
  24. def parse_tw_acct_label(s):
  25. ret = s.split(None,1)
  26. if ret and MMGenID(ret[0],on_fail='silent'):
  27. if len(ret) == 2:
  28. return tuple(ret)
  29. else:
  30. return ret[0],None
  31. else:
  32. return None,None
  33. class MMGenTWOutput(MMGenListItem):
  34. attrs_reassign = 'label','skip'
  35. attrs = 'txid','vout','amt','label','mmid','addr','confs','scriptPubKey','days','skip'
  36. label = MMGenListItemAttr('label','MMGenAddrLabel')
  37. class MMGenTrackingWallet(MMGenObject):
  38. wmsg = {
  39. 'no_spendable_outputs': """
  40. No spendable outputs found! Import addresses with balances into your
  41. watch-only wallet using '{}-addrimport' and then re-run this program.
  42. """.strip().format(g.proj_name)
  43. }
  44. sort_keys = 'addr','age','amt','txid','mmid'
  45. def __init__(self):
  46. self.unspent = []
  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.get_data()
  55. self.sort_key = 'age'
  56. self.do_sort()
  57. self.total = self.get_total_btc()
  58. def get_total_btc(self):
  59. return sum([i.amt for i in self.unspent])
  60. def get_data(self):
  61. if g.bogus_wallet_data: # for debugging purposes only
  62. us_rpc = eval(get_data_from_file(g.bogus_wallet_data))
  63. else:
  64. us_rpc = bitcoin_connection().listunspent()
  65. # write_data_to_file('bogus_unspent.json', repr(us), 'bogus unspent data')
  66. # sys.exit()
  67. if not us_rpc: die(2,self.wmsg['no_spendable_outputs'])
  68. for o in us_rpc:
  69. o['mmid'],o['label'] = parse_tw_acct_label(o['account']) if 'account' in o else ('','')
  70. o['days'] = int(o['confirmations'] * g.mins_per_block / (60*24))
  71. o['amt'] = o['amount'] # TODO
  72. o['addr'] = o['address']
  73. o['confs'] = o['confirmations']
  74. self.unspent = [MMGenTWOutput(**dict([(k,v) for k,v in o.items() if k in MMGenTWOutput.attrs and o[k] not in (None,'')])) for o in us_rpc]
  75. # die(1,''.join([pp_format(i)+'\n' for i in us_rpc]))
  76. # die(1,''.join([str(i)+'\n' for i in self.unspent]))
  77. def s_addr(self,i): return i.addr
  78. def s_age(self,i): return 0 - i.confs
  79. def s_amt(self,i): return i.amt
  80. def s_txid(self,i): return '%s %03s' % (i.txid,i.vout)
  81. def s_mmid(self,i):
  82. if i.mmid:
  83. return '{}:{:>0{w}}'.format(
  84. *i.mmid.split(':'), w=AddrIdx.max_digits)
  85. else: return 'G' + (i.label or '')
  86. def do_sort(self,key=None,reverse=None):
  87. if not key: key = self.sort_key
  88. assert key
  89. self.sort_key = key
  90. if key not in self.sort_keys:
  91. fs = "'{}': invalid sort key. Valid keys: [{}]"
  92. die(2,fs.format(key,' '.join(self.sort_keys)))
  93. if reverse == None: reverse = self.reverse
  94. self.unspent.sort(key=getattr(self,'s_'+key),reverse=reverse)
  95. def sort_info(self,include_group=True):
  96. ret = ([],['Reverse'])[self.reverse]
  97. ret.append(self.sort_key.capitalize().replace('Mmid','MMGenID'))
  98. if include_group and self.group and (self.sort_key in ('addr','txid','mmid')):
  99. ret.append('Grouped')
  100. return ret
  101. def set_term_columns(self):
  102. from mmgen.term import get_terminal_size
  103. while True:
  104. self.cols = get_terminal_size()[0]
  105. if self.cols >= g.min_screen_width: break
  106. m1 = 'Screen too narrow to display the tracking wallet'
  107. m2 = 'Please resize your screen to at least {} characters and hit ENTER '
  108. my_raw_input(m1+'\n'+m2.format(g.min_screen_width))
  109. def display(self):
  110. msg(self.format_for_display())
  111. def format_for_display(self):
  112. unsp = [MMGenTWOutput(**i.__dict__) for i in self.unspent]
  113. self.set_term_columns()
  114. for i in unsp:
  115. if i.label == None: i.label = ''
  116. i.skip = ''
  117. mmid_w = max(len(i.mmid or '') for i in unsp) or 10
  118. max_acct_len = max([len((i.mmid or '')+i.label)+1 for i in unsp])
  119. addr_w = min(34+((1+max_acct_len) if self.show_mmid else 0),self.cols-46) + 6
  120. acct_w = min(max_acct_len, max(24,int(addr_w-10)))
  121. btaddr_w = addr_w - acct_w - 1
  122. label_w = acct_w - mmid_w - 1
  123. tx_w = max(11,min(64, self.cols-addr_w-32))
  124. txdots = ('','...')[tx_w < 64]
  125. fs = ' %-4s %-' + str(tx_w) + 's %-2s %s %s %s'
  126. table_hdr = fs % ('Num',
  127. 'TX id'.ljust(tx_w - 5) + ' Vout',
  128. '',
  129. BTCAddr.fmtc('Address',width=addr_w+1),
  130. 'Amt(BTC) ',
  131. ('Conf.','Age(d)')[self.show_days])
  132. if self.group and (self.sort_key in ('addr','txid','mmid')):
  133. for a,b in [(unsp[i],unsp[i+1]) for i in range(len(unsp)-1)]:
  134. for k in ('addr','txid','mmid'):
  135. if self.sort_key == k and getattr(a,k) == getattr(b,k):
  136. b.skip = (k,'addr')[k=='mmid']
  137. hdr_fmt = 'UNSPENT OUTPUTS (sort order: %s) Total BTC: %s'
  138. out = [hdr_fmt % (' '.join(self.sort_info()), self.total.hl()), table_hdr]
  139. for n,i in enumerate(unsp):
  140. addr_dots = '|' + '.'*33
  141. mmid_disp = (MMGenID.hlc('.'*mmid_w) \
  142. if i.skip=='addr' else i.mmid.fmt(width=mmid_w,color=True)) \
  143. if i.mmid else ' ' * mmid_w
  144. if self.show_mmid and i.mmid:
  145. addr_out = '%s %s' % (
  146. type(i.addr).fmtc(addr_dots,width=btaddr_w,color=True) if i.skip == 'addr' \
  147. else i.addr.fmt(width=btaddr_w,color=True),
  148. '{} {}'.format(mmid_disp,i.label.fmt(width=label_w,color=True) if label_w > 0 else '')
  149. )
  150. else:
  151. addr_out = type(i.addr).fmtc(addr_dots,width=addr_w,color=True) if i.skip=='addr' \
  152. else i.addr.fmt(width=addr_w,color=True)
  153. tx = ' ' * (tx_w-4) + '|...' if i.skip == 'txid' \
  154. else i.txid[:tx_w-len(txdots)]+txdots
  155. out.append(fs % (str(n+1)+')',tx,i.vout,addr_out,i.amt.fmt(color=True),
  156. i.days if self.show_days else i.confs))
  157. self.fmt_display = '\n'.join(out) + '\n'
  158. return self.fmt_display
  159. def format_for_printing(self,color=False):
  160. fs = ' %-4s %-67s %s %s %s %-8s %-6s %s'
  161. out = [fs % ('Num','Tx ID,Vout','Address'.ljust(34),'MMGen ID'.ljust(15),
  162. 'Amount(BTC)','Conf.','Age(d)', 'Label')]
  163. max_lbl_len = max(len(i.label) for i in self.unspent if i.label) or 1
  164. for n,i in enumerate(self.unspent):
  165. addr = '=' if i.skip == 'addr' and self.group else i.addr.fmt(color=color)
  166. tx = ' ' * 63 + '=' if i.skip == 'txid' and self.group else str(i.txid)
  167. s = fs % (str(n+1)+')', tx+','+str(i.vout),addr,
  168. (i.mmid.fmt(width=14,color=color) if i.mmid else
  169. MMGenID.fmtc('',width=14,nullrepl='-',color=color)),
  170. i.amt.fmt(color=color),i.confs,i.days,
  171. i.label.hl(color=color) if i.label else
  172. MMGenAddrLabel.fmtc('',color=color,nullrepl='-',width=max_lbl_len))
  173. out.append(s.rstrip())
  174. fs = 'Unspent outputs ({} UTC)\nSort order: {}\n\n{}\n\nTotal BTC: {}\n'
  175. self.fmt_print = fs.format(
  176. make_timestr(),
  177. ' '.join(self.sort_info(include_group=False)),
  178. '\n'.join(out),
  179. self.total.hl(color=color))
  180. return self.fmt_print
  181. def display_total(self):
  182. fs = '\nTotal unspent: %s BTC (%s outputs)'
  183. msg(fs % (self.total.hl(),len(self.unspent)))
  184. def get_idx_and_label_from_user(self):
  185. msg('')
  186. while True:
  187. ret = my_raw_input("Enter unspent output number (or 'q' to return to main menu): ")
  188. if ret == 'q': return None,None
  189. n = AddrIdx(ret,on_fail='silent') # hacky way to test and convert to integer
  190. if not n or n < 1 or n > len(self.unspent):
  191. msg('Choice must be a single number between 1 and %s' % len(self.unspent))
  192. elif not self.unspent[n-1].mmid:
  193. msg('Address #%s is not an %s address. No label can be added to it' %
  194. (n,g.proj_name))
  195. else:
  196. while True:
  197. s = my_raw_input("Enter label text (or 'q' to return to main menu): ")
  198. if s == 'q':
  199. return None,None
  200. elif s == '':
  201. if keypress_confirm(
  202. "Removing label for address #%s. Is this what you want?" % n):
  203. return n,s
  204. elif s:
  205. if MMGenAddrLabel(s,on_fail='return'):
  206. return n,s
  207. def view_and_sort(self):
  208. from mmgen.term import do_pager
  209. prompt = """
  210. Sort options: [t]xid, [a]mount, a[d]dress, [A]ge, [r]everse, [M]mgen addr
  211. Display options: show [D]ays, [g]roup, show [m]mgen addr, r[e]draw screen
  212. """.strip()
  213. self.display()
  214. msg(prompt)
  215. p = "'q'=quit view, 'p'=print to file, 'v'=pager view, 'w'=wide view, 'l'=add label:\b"
  216. while True:
  217. reply = get_char(p, immed_chars='atDdAMrgmeqpvw')
  218. if reply == 'a': self.do_sort('amt')
  219. elif reply == 'A': self.do_sort('age')
  220. elif reply == 'd': self.do_sort('addr')
  221. elif reply == 'D': self.show_days = not self.show_days
  222. elif reply == 'e': msg('\n%s\n%s\n%s' % (self.fmt_display,prompt,p))
  223. elif reply == 'g': self.group = not self.group
  224. elif reply == 'l':
  225. idx,lbl = self.get_idx_and_label_from_user()
  226. if idx:
  227. e = self.unspent[idx-1]
  228. if type(self).add_label(e.mmid,lbl,addr=e.addr):
  229. self.get_data()
  230. self.do_sort()
  231. msg('%s\n%s\n%s' % (self.fmt_display,prompt,p))
  232. else:
  233. msg('Label could not be added\n%s\n%s' % (prompt,p))
  234. elif reply == 'M': self.do_sort('mmid'); self.show_mmid = True
  235. elif reply == 'm': self.show_mmid = not self.show_mmid
  236. elif reply == 'p':
  237. msg('')
  238. of = 'listunspent[%s].out' % ','.join(self.sort_info(include_group=False)).lower()
  239. write_data_to_file(of,self.format_for_printing(),'unspent outputs listing')
  240. m = yellow("Data written to '%s'" % of)
  241. msg('\n%s\n%s\n\n%s' % (self.fmt_display,m,prompt))
  242. continue
  243. elif reply == 'q': return self.unspent
  244. elif reply == 'r': self.unspent.reverse(); self.reverse = not self.reverse
  245. elif reply == 't': self.do_sort('txid')
  246. elif reply == 'v':
  247. do_pager(self.fmt_display)
  248. continue
  249. elif reply == 'w':
  250. do_pager(self.format_for_printing(color=True))
  251. continue
  252. else:
  253. msg('\nInvalid input')
  254. continue
  255. msg('\n')
  256. self.display()
  257. msg(prompt)
  258. # returns on failure
  259. @classmethod
  260. def add_label(cls,mmaddr,label='',addr=None):
  261. mmaddr = MMGenID(mmaddr)
  262. if addr: # called from view_and_sort()
  263. if not BTCAddr(addr,on_fail='return'): return False
  264. else:
  265. from mmgen.addr import AddrData
  266. addr = AddrData(source='tw').mmaddr2btcaddr(mmaddr)
  267. if not addr:
  268. msg('{} address {} not found in tracking wallet'.format(g.proj_name,mmaddr))
  269. return False
  270. label = MMGenAddrLabel(label,on_fail='return')
  271. if not label and label != '': return False
  272. acct = mmaddr + (' ' + label if label else '') # label is ASCII for now
  273. # return on failure - args: addr,label,rescan,p2sh
  274. ret = bitcoin_connection().importaddress(addr,acct,False,on_fail='return')
  275. from mmgen.rpc import rpc_error,rpc_errmsg
  276. if rpc_error(ret): msg('From bitcoind: ' + rpc_errmsg(ret))
  277. return not rpc_error(ret)
  278. @classmethod
  279. def remove_label(cls,mmaddr): cls.add_label(mmaddr,'')