tw.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  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.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 MMGenTWOutput(MMGenListItem):
  33. attrs_reassign = 'label','skip'
  34. attrs = 'txid','vout','amt','label','mmid','addr','confs','scriptPubKey','days','skip'
  35. label = MMGenListItemAttr('label','MMGenAddrLabel')
  36. class MMGenTrackingWallet(MMGenObject):
  37. wmsg = {
  38. 'no_spendable_outputs': """
  39. No spendable outputs found! Import addresses with balances into your
  40. watch-only wallet using '{}-addrimport' and then re-run this program.
  41. """.strip().format(g.proj_name)
  42. }
  43. sort_keys = 'addr','age','amt','txid','mmid'
  44. def __init__(self,minconf=1):
  45. self.unspent = []
  46. self.fmt_display = ''
  47. self.fmt_print = ''
  48. self.cols = None
  49. self.reverse = False
  50. self.group = False
  51. self.show_days = True
  52. self.show_mmid = True
  53. self.minconf = minconf
  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(self.minconf)
  65. # write_data_to_file('bogus_unspent.json', repr(us), 'bogus unspent data')
  66. # sys.exit()
  67. if not us_rpc: die(0,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. if not opt.no_blank: msg(CUR_HOME+ERASE_ALL)
  111. msg(self.format_for_display())
  112. def format_for_display(self):
  113. unsp = [MMGenTWOutput(**i.__dict__) for i in self.unspent]
  114. self.set_term_columns()
  115. for i in unsp:
  116. if i.label == None: i.label = ''
  117. i.skip = ''
  118. mmid_w = max(len(i.mmid or '') for i in unsp) or 10
  119. max_acct_len = max([len((i.mmid or '')+i.label)+1 for i in unsp])
  120. addr_w = min(34+((1+max_acct_len) if self.show_mmid else 0),self.cols-46) + 6
  121. acct_w = min(max_acct_len, max(24,int(addr_w-10)))
  122. btaddr_w = addr_w - acct_w - 1
  123. label_w = acct_w - mmid_w - 1
  124. tx_w = max(11,min(64, self.cols-addr_w-32))
  125. txdots = ('','...')[tx_w < 64]
  126. fs = ' %-4s %-' + str(tx_w) + 's %-2s %s %s %s'
  127. table_hdr = fs % ('Num',
  128. 'TX id'.ljust(tx_w - 5) + ' Vout',
  129. '',
  130. BTCAddr.fmtc('Address',width=addr_w+1),
  131. 'Amt(BTC) ',
  132. ('Conf.','Age(d)')[self.show_days])
  133. if self.group and (self.sort_key in ('addr','txid','mmid')):
  134. for a,b in [(unsp[i],unsp[i+1]) for i in range(len(unsp)-1)]:
  135. for k in ('addr','txid','mmid'):
  136. if self.sort_key == k and getattr(a,k) == getattr(b,k):
  137. b.skip = (k,'addr')[k=='mmid']
  138. hdr_fmt = 'UNSPENT OUTPUTS (sort order: %s) Total BTC: %s'
  139. out = [hdr_fmt % (' '.join(self.sort_info()), self.total.hl()), table_hdr]
  140. for n,i in enumerate(unsp):
  141. addr_dots = '|' + '.'*33
  142. mmid_disp = MMGenID.fmtc('.'*mmid_w if i.skip=='addr'
  143. else i.mmid or 'Non-{}'.format(g.proj_name),width=mmid_w,color=True)
  144. if self.show_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) \
  149. if label_w > 0 else '')
  150. )
  151. else:
  152. addr_out = type(i.addr).fmtc(addr_dots,width=addr_w,color=True) \
  153. if i.skip=='addr' else i.addr.fmt(width=addr_w,color=True)
  154. tx = ' ' * (tx_w-4) + '|...' if i.skip == 'txid' \
  155. else i.txid[:tx_w-len(txdots)]+txdots
  156. out.append(fs % (str(n+1)+')',tx,i.vout,addr_out,i.amt.fmt(color=True),
  157. i.days if self.show_days else i.confs))
  158. self.fmt_display = '\n'.join(out) + '\n'
  159. return self.fmt_display
  160. def format_for_printing(self,color=False):
  161. fs = ' %-4s %-67s %s %s %s %-8s %-6s %s'
  162. out = [fs % ('Num','Tx ID,Vout','Address'.ljust(34),'MMGen ID'.ljust(15),
  163. 'Amount(BTC)','Conf.','Age(d)', 'Label')]
  164. max_lbl_len = max([len(i.label) for i in self.unspent if i.label] or [1])
  165. for n,i in enumerate(self.unspent):
  166. addr = '=' if i.skip == 'addr' and self.group else i.addr.fmt(color=color)
  167. tx = ' ' * 63 + '=' if i.skip == 'txid' and self.group else str(i.txid)
  168. s = fs % (str(n+1)+')', tx+','+str(i.vout),addr,
  169. MMGenID.fmtc(i.mmid or 'Non-{}'.format(g.proj_name),width=14,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. prompt = """
  209. Sort options: [t]xid, [a]mount, a[d]dress, [A]ge, [r]everse, [M]mgen addr
  210. Display options: show [D]ays, [g]roup, show [m]mgen addr, r[e]draw screen
  211. """.strip()
  212. self.display()
  213. msg(prompt)
  214. from mmgen.term import get_char
  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,arg1,label='',addr=None):
  261. from mmgen.tx import is_mmgen_id,is_btc_addr
  262. if is_mmgen_id(arg1):
  263. mmaddr = MMGenID(arg1)
  264. elif is_btc_addr(arg1): # called from 'mmgen-tool add_label'
  265. addr = arg1
  266. mmaddr = 'btc:'+arg1
  267. elif not arg1 and is_btc_addr(addr): # called from view_and_sort(), non-MMGen addr
  268. mmaddr = 'btc:'+addr
  269. else:
  270. die(3,'{}: not a BTC address or {} ID'.format(arg1,g.proj_name))
  271. if addr:
  272. if not BTCAddr(addr,on_fail='return'): return False
  273. else:
  274. from mmgen.addr import AddrData
  275. addr = AddrData(source='tw').mmaddr2btcaddr(mmaddr)
  276. if not addr:
  277. msg('{} address {} not found in tracking wallet'.format(g.proj_name,mmaddr))
  278. return False
  279. label = MMGenAddrLabel(label,on_fail='return')
  280. if not label and label != '': return False
  281. acct = mmaddr + (' ' + label if label else '') # label is ASCII for now
  282. # return on failure - args: addr,label,rescan,p2sh
  283. ret = bitcoin_connection().importaddress(addr,acct,False,on_fail='return')
  284. from mmgen.rpc import rpc_error,rpc_errmsg
  285. if rpc_error(ret): msg('From bitcoind: ' + rpc_errmsg(ret))
  286. return not rpc_error(ret)
  287. @classmethod
  288. def remove_label(cls,mmaddr): cls.add_label(mmaddr,'')