tw.py 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2020 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. import json
  22. from collections import namedtuple
  23. from .exception import *
  24. from .common import *
  25. from .obj import *
  26. from .tx import is_mmgen_id,is_coin_addr
  27. from .rpc import rpc_init
  28. CUR_HOME,ERASE_ALL = '\033[H','\033[0J'
  29. def CUR_RIGHT(n): return '\033[{}C'.format(n)
  30. def get_tw_label(proto,s):
  31. """
  32. raise an exception on a malformed comment, return None on an empty or invalid label
  33. """
  34. try:
  35. return TwLabel(proto,s)
  36. except BadTwComment:
  37. raise
  38. except:
  39. return None
  40. _date_formatter = {
  41. 'days': lambda rpc,secs: (rpc.cur_date - secs) // 86400,
  42. 'date': lambda rpc,secs: '{}-{:02}-{:02}'.format(*time.gmtime(secs)[:3])[2:],
  43. 'date_time': lambda rpc,secs: '{}-{:02}-{:02} {:02}:{:02}'.format(*time.gmtime(secs)[:5]),
  44. }
  45. async def _set_dates(rpc,us):
  46. if us and us[0].date is None:
  47. # 'blocktime' differs from 'time', is same as getblockheader['time']
  48. dates = [o['blocktime'] for o in await rpc.gathered_call('gettransaction',[(o.txid,) for o in us])]
  49. for idx,o in enumerate(us):
  50. o.date = dates[idx]
  51. if os.getenv('MMGEN_BOGUS_WALLET_DATA'):
  52. # 1831006505 (09 Jan 2028) = projected time of block 1000000
  53. _date_formatter['days'] = lambda rpc,secs: (1831006505 - secs) // 86400
  54. async def _set_dates(rpc,us):
  55. for o in us:
  56. o.date = 1831006505 - int(9.7 * 60 * (o.confs - 1))
  57. class TwUnspentOutputs(MMGenObject,metaclass=aInitMeta):
  58. def __new__(cls,proto,*args,**kwargs):
  59. return MMGenObject.__new__(altcoin_subclass(cls,proto,'tw'))
  60. txid_w = 64
  61. disp_type = 'btc'
  62. can_group = True
  63. hdr_fmt = 'UNSPENT OUTPUTS (sort order: {}) Total {}: {}'
  64. desc = 'unspent outputs'
  65. item_desc = 'unspent output'
  66. dump_fn_pfx = 'listunspent'
  67. prompt_fs = 'Total to spend, excluding fees: {} {}\n\n'
  68. prompt = """
  69. Sort options: [t]xid, [a]mount, a[d]dress, [A]ge, [r]everse, [M]mgen addr
  70. Display options: toggle [D]ays/date, show [g]roup, show [m]mgen addr, r[e]draw
  71. Actions: [q]uit view, [p]rint to file, pager [v]iew, [w]ide view, add [l]abel:
  72. """
  73. key_mappings = {
  74. 't':'s_txid','a':'s_amt','d':'s_addr','A':'s_age','r':'d_reverse','M':'s_twmmid',
  75. 'D':'d_days','g':'d_group','m':'d_mmid','e':'d_redraw',
  76. 'q':'a_quit','p':'a_print','v':'a_view','w':'a_view_wide','l':'a_lbl_add' }
  77. col_adj = 38
  78. age_fmts = ('confs','block','days','date','date_time')
  79. age_fmts_date_dependent = ('days','date','date_time')
  80. age_fmts_interactive = ('confs','block','days','date')
  81. _age_fmt = 'confs'
  82. class MMGenTwOutputList(list,MMGenObject): pass
  83. class MMGenTwUnspentOutput(MMGenListItem):
  84. txid = ListItemAttr('CoinTxID')
  85. vout = ListItemAttr(int,typeconv=False)
  86. amt = ImmutableAttr(None)
  87. amt2 = ListItemAttr(None)
  88. label = ListItemAttr('TwComment',reassign_ok=True)
  89. twmmid = ImmutableAttr('TwMMGenID',include_proto=True)
  90. addr = ImmutableAttr('CoinAddr',include_proto=True)
  91. confs = ImmutableAttr(int,typeconv=False)
  92. date = ListItemAttr(int,typeconv=False,reassign_ok=True)
  93. scriptPubKey = ImmutableAttr('HexStr')
  94. skip = ListItemAttr(str,typeconv=False,reassign_ok=True)
  95. # required by gen_unspent(); setting valid_attrs explicitly is also more efficient
  96. valid_attrs = {'txid','vout','amt','amt2','label','twmmid','addr','confs','date','scriptPubKey','skip'}
  97. invalid_attrs = {'proto'}
  98. def __init__(self,proto,**kwargs):
  99. self.__dict__['proto'] = proto
  100. MMGenListItem.__init__(self,**kwargs)
  101. class conv_funcs:
  102. def amt(self,value):
  103. return self.proto.coin_amt(value)
  104. def amt2(self,value):
  105. return self.proto.coin_amt(value)
  106. async def __ainit__(self,proto,minconf=1,addrs=[]):
  107. self.proto = proto
  108. self.unspent = self.MMGenTwOutputList()
  109. self.fmt_display = ''
  110. self.fmt_print = ''
  111. self.cols = None
  112. self.reverse = False
  113. self.group = False
  114. self.show_mmid = True
  115. self.minconf = minconf
  116. self.addrs = addrs
  117. self.sort_key = 'age'
  118. self.disp_prec = self.get_display_precision()
  119. self.rpc = await rpc_init(proto)
  120. self.wallet = await TrackingWallet(proto,mode='w')
  121. if self.disp_type == 'token':
  122. self.proto.tokensym = self.wallet.symbol
  123. @property
  124. def age_fmt(self):
  125. return self._age_fmt
  126. @age_fmt.setter
  127. def age_fmt(self,val):
  128. if val not in self.age_fmts:
  129. raise BadAgeFormat(f'{val!r}: invalid age format (must be one of {self.age_fmts!r})')
  130. self._age_fmt = val
  131. def get_display_precision(self):
  132. return self.proto.coin_amt.max_prec
  133. @property
  134. def total(self):
  135. return sum(i.amt for i in self.unspent)
  136. async def get_unspent_rpc(self):
  137. # bitcoin-cli help listunspent:
  138. # Arguments:
  139. # 1. minconf (numeric, optional, default=1) The minimum confirmations to filter
  140. # 2. maxconf (numeric, optional, default=9999999) The maximum confirmations to filter
  141. # 3. addresses (json array, optional, default=empty array) A json array of bitcoin addresses
  142. # 4. include_unsafe (boolean, optional, default=true) Include outputs that are not safe to spend
  143. # 5. query_options (json object, optional) JSON with query options
  144. # for now, self.addrs is just an empty list for Bitcoin and friends
  145. add_args = (9999999,self.addrs) if self.addrs else ()
  146. return await self.rpc.call('listunspent',self.minconf,*add_args)
  147. async def get_unspent_data(self,sort_key=None,reverse_sort=False):
  148. if g.bogus_wallet_data: # for debugging and test suite
  149. us_raw = json.loads(get_data_from_file(g.bogus_wallet_data),parse_float=Decimal)
  150. else:
  151. us_raw = await self.get_unspent_rpc()
  152. if not us_raw:
  153. die(0,fmt(f"""
  154. No spendable outputs found! Import addresses with balances into your
  155. watch-only wallet using '{g.proj_name.lower()}-addrimport' and then re-run this program.
  156. """).strip())
  157. lbl_id = ('account','label')['label_api' in self.rpc.caps]
  158. def gen_unspent():
  159. for o in us_raw:
  160. if not lbl_id in o:
  161. continue # coinbase outputs have no account field
  162. l = get_tw_label(self.proto,o[lbl_id])
  163. if l:
  164. o.update({
  165. 'twmmid': l.mmid,
  166. 'label': l.comment or '',
  167. 'amt': self.proto.coin_amt(o['amount']),
  168. 'addr': CoinAddr(self.proto,o['address']),
  169. 'confs': o['confirmations']
  170. })
  171. yield self.MMGenTwUnspentOutput(
  172. self.proto,
  173. **{ k:v for k,v in o.items() if k in self.MMGenTwUnspentOutput.valid_attrs } )
  174. self.unspent = self.MMGenTwOutputList(gen_unspent())
  175. if not self.unspent:
  176. die(1, f'No tracked {self.item_desc}s in tracking wallet!')
  177. self.do_sort(key=sort_key,reverse=reverse_sort)
  178. def do_sort(self,key=None,reverse=False):
  179. sort_funcs = {
  180. 'addr': lambda i: i.addr,
  181. 'age': lambda i: 0 - i.confs,
  182. 'amt': lambda i: i.amt,
  183. 'txid': lambda i: '{} {:04}'.format(i.txid,i.vout),
  184. 'twmmid': lambda i: i.twmmid.sort_key
  185. }
  186. key = key or self.sort_key
  187. if key not in sort_funcs:
  188. die(1,f'{key!r}: invalid sort key. Valid options: {" ".join(sort_funcs.keys())}')
  189. self.sort_key = key
  190. assert type(reverse) == bool
  191. self.unspent.sort(key=sort_funcs[key],reverse=reverse or self.reverse)
  192. def sort_info(self,include_group=True):
  193. ret = ([],['Reverse'])[self.reverse]
  194. ret.append(capfirst(self.sort_key).replace('Twmmid','MMGenID'))
  195. if include_group and self.group and (self.sort_key in ('addr','txid','twmmid')):
  196. ret.append('Grouped')
  197. return ret
  198. def set_term_columns(self):
  199. from .term import get_terminal_size
  200. while True:
  201. self.cols = g.terminal_width or get_terminal_size().width
  202. if self.cols >= g.min_screen_width:
  203. break
  204. my_raw_input(
  205. 'Screen too narrow to display the tracking wallet\n'
  206. + f'Please resize your screen to at least {g.min_screen_width} characters and hit ENTER ' )
  207. def get_display_constants(self):
  208. unsp = self.unspent
  209. for i in unsp:
  210. i.skip = ''
  211. # allow for 7-digit confirmation nums
  212. col1_w = max(3,len(str(len(unsp)))+1) # num + ')'
  213. mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in unsp) or 12 # DEADBEEF:S:1
  214. max_acct_w = max(i.label.screen_width for i in unsp) + mmid_w + 1
  215. max_btcaddr_w = max(len(i.addr) for i in unsp)
  216. min_addr_w = self.cols - self.col_adj
  217. addr_w = min(max_btcaddr_w + (0,1+max_acct_w)[self.show_mmid],min_addr_w)
  218. acct_w = min(max_acct_w, max(24,addr_w-10))
  219. btaddr_w = addr_w - acct_w - 1
  220. label_w = acct_w - mmid_w - 1
  221. tx_w = min(self.txid_w,self.cols-addr_w-29-col1_w) # min=6 TODO
  222. txdots = ('','..')[tx_w < self.txid_w]
  223. dc = namedtuple('display_constants',['col1_w','mmid_w','addr_w','btaddr_w','label_w','tx_w','txdots'])
  224. return dc(col1_w,mmid_w,addr_w,btaddr_w,label_w,tx_w,txdots)
  225. async def format_for_display(self):
  226. unsp = self.unspent
  227. if self.age_fmt in self.age_fmts_date_dependent:
  228. await _set_dates(self.rpc,unsp)
  229. self.set_term_columns()
  230. c = getattr(self,'display_constants',None)
  231. if not c:
  232. c = self.display_constants = self.get_display_constants()
  233. if self.group and (self.sort_key in ('addr','txid','twmmid')):
  234. for a,b in [(unsp[i],unsp[i+1]) for i in range(len(unsp)-1)]:
  235. for k in ('addr','txid','twmmid'):
  236. if self.sort_key == k and getattr(a,k) == getattr(b,k):
  237. b.skip = (k,'addr')[k=='twmmid']
  238. def gen_output():
  239. yield self.hdr_fmt.format(' '.join(self.sort_info()),self.proto.dcoin,self.total.hl())
  240. if self.proto.chain_name != 'mainnet':
  241. yield 'Chain: '+green(self.proto.chain_name.upper())
  242. fs = { 'btc': ' {n:%s} {t:%s} {v:2} {a} {A} {c:<}' % (c.col1_w,c.tx_w),
  243. 'eth': ' {n:%s} {a} {A}' % c.col1_w,
  244. 'token': ' {n:%s} {a} {A} {A2}' % c.col1_w }[self.disp_type]
  245. fs_hdr = ' {n:%s} {t:%s} {a} {A} {c:<}' % (c.col1_w,c.tx_w) if self.disp_type == 'btc' else fs
  246. date_hdr = {
  247. 'confs': 'Confs',
  248. 'block': 'Block',
  249. 'days': 'Age(d)',
  250. 'date': 'Date',
  251. 'date_time': 'Date',
  252. }
  253. yield fs_hdr.format(
  254. n = 'Num',
  255. t = 'TXid'.ljust(c.tx_w - 2) + ' Vout',
  256. a = 'Address'.ljust(c.addr_w),
  257. A = 'Amt({})'.format(self.proto.dcoin).ljust(self.disp_prec+5),
  258. A2 = ' Amt({})'.format(self.proto.coin).ljust(self.disp_prec+4),
  259. c = date_hdr[self.age_fmt],
  260. ).rstrip()
  261. for n,i in enumerate(unsp):
  262. addr_dots = '|' + '.'*(c.addr_w-1)
  263. mmid_disp = MMGenID.fmtc('.'*c.mmid_w if i.skip=='addr'
  264. else i.twmmid if i.twmmid.type=='mmgen'
  265. else 'Non-{}'.format(g.proj_name),width=c.mmid_w,color=True)
  266. if self.show_mmid:
  267. addr_out = '{} {}{}'.format((
  268. type(i.addr).fmtc(addr_dots,width=c.btaddr_w,color=True) if i.skip == 'addr' else
  269. i.addr.fmt(width=c.btaddr_w,color=True)
  270. ),
  271. mmid_disp,
  272. (' ' + i.label.fmt(width=c.label_w,color=True)) if c.label_w > 0 else ''
  273. )
  274. else:
  275. addr_out = (
  276. type(i.addr).fmtc(addr_dots,width=c.addr_w,color=True) if i.skip=='addr' else
  277. i.addr.fmt(width=c.addr_w,color=True) )
  278. yield fs.format(
  279. n = str(n+1)+')',
  280. t = (
  281. '' if not i.txid else
  282. ' ' * (c.tx_w-4) + '|...' if i.skip == 'txid' else
  283. i.txid[:c.tx_w-len(c.txdots)] + c.txdots ),
  284. v = i.vout,
  285. a = addr_out,
  286. A = i.amt.fmt(color=True,prec=self.disp_prec),
  287. A2 = (i.amt2.fmt(color=True,prec=self.disp_prec) if i.amt2 is not None else ''),
  288. c = self.age_disp(i,self.age_fmt),
  289. ).rstrip()
  290. self.fmt_display = '\n'.join(gen_output()) + '\n'
  291. return self.fmt_display
  292. async def format_for_printing(self,color=False,show_confs=True):
  293. if self.age_fmt in self.age_fmts_date_dependent:
  294. await _set_dates(self.rpc,self.unspent)
  295. addr_w = max(len(i.addr) for i in self.unspent)
  296. mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in self.unspent) or 12 # DEADBEEF:S:1
  297. amt_w = self.proto.coin_amt.max_prec + 5
  298. cfs = '{c:<8} ' if show_confs else ''
  299. fs = {
  300. 'btc': (' {n:4} {t:%s} {a} {m} {A:%s} ' + cfs + '{b:<8} {D:<19} {l}') % (self.txid_w+3,amt_w),
  301. 'eth': ' {n:4} {a} {m} {A:%s} {l}' % amt_w,
  302. 'token': ' {n:4} {a} {m} {A:%s} {A2:%s} {l}' % (amt_w,amt_w)
  303. }[self.disp_type]
  304. def gen_output():
  305. yield fs.format(
  306. n = 'Num',
  307. t = 'Tx ID,Vout',
  308. a = 'Address'.ljust(addr_w),
  309. m = 'MMGen ID'.ljust(mmid_w),
  310. A = 'Amount({})'.format(self.proto.dcoin),
  311. A2 = 'Amount({})'.format(self.proto.coin),
  312. c = 'Confs', # skipped for eth
  313. b = 'Block', # skipped for eth
  314. D = 'Date',
  315. l = 'Label' )
  316. max_lbl_len = max([len(i.label) for i in self.unspent if i.label] or [2])
  317. for n,i in enumerate(self.unspent):
  318. yield fs.format(
  319. n = str(n+1) + ')',
  320. t = '{},{}'.format(
  321. ('|'+'.'*63 if i.skip == 'txid' and self.group else i.txid),
  322. i.vout ),
  323. a = (
  324. '|'+'.' * addr_w if i.skip == 'addr' and self.group else
  325. i.addr.fmt(color=color,width=addr_w) ),
  326. m = MMGenID.fmtc(
  327. (i.twmmid if i.twmmid.type == 'mmgen' else f'Non-{g.proj_name}'),
  328. width = mmid_w,
  329. color = color ),
  330. A = i.amt.fmt(color=color),
  331. A2 = ( i.amt2.fmt(color=color) if i.amt2 is not None else '' ),
  332. c = i.confs,
  333. b = self.rpc.blockcount - (i.confs - 1),
  334. D = self.age_disp(i,'date_time'),
  335. l = i.label.hl(color=color) if i.label else
  336. TwComment.fmtc(
  337. s = '',
  338. color = color,
  339. nullrepl = '-',
  340. width = max_lbl_len )
  341. ).rstrip()
  342. fs2 = '{} (block #{}, {} UTC)\n{}Sort order: {}\n{}\n\nTotal {}: {}\n'
  343. self.fmt_print = fs2.format(
  344. capfirst(self.desc),
  345. self.rpc.blockcount,
  346. make_timestr(self.rpc.cur_date),
  347. ('' if self.proto.chain_name == 'mainnet' else
  348. 'Chain: {}\n'.format(green(self.proto.chain_name.upper())) ),
  349. ' '.join(self.sort_info(include_group=False)),
  350. '\n'.join(gen_output()),
  351. self.proto.dcoin,
  352. self.total.hl(color=color) )
  353. return self.fmt_print
  354. def display_total(self):
  355. msg('\nTotal unspent: {} {} ({} output{})'.format(
  356. self.total.hl(),
  357. self.proto.dcoin,
  358. len(self.unspent),
  359. suf(self.unspent) ))
  360. def get_idx_from_user(self,action):
  361. msg('')
  362. while True:
  363. ret = my_raw_input(f'Enter {self.item_desc} number (or RETURN to return to main menu): ')
  364. if ret == '': return (None,None) if action == 'a_lbl_add' else None
  365. n = get_obj(AddrIdx,n=ret,silent=True)
  366. if not n or n < 1 or n > len(self.unspent):
  367. msg(f'Choice must be a single number between 1 and {len(self.unspent)}')
  368. else:
  369. if action == 'a_lbl_add':
  370. while True:
  371. s = my_raw_input("Enter label text (or 'q' to return to main menu): ")
  372. if s == 'q':
  373. return None,None
  374. elif s == '':
  375. if keypress_confirm(
  376. f'Removing label for {self.item_desc} #{n}. Is this what you want?'):
  377. return n,s
  378. elif s:
  379. if get_obj(TwComment,s=s):
  380. return n,s
  381. else:
  382. if action == 'a_addr_delete':
  383. fs = 'Removing {} #{} from tracking wallet. Is this what you want?'
  384. elif action == 'a_balance_refresh':
  385. fs = 'Refreshing tracking wallet {} #{}. Is this what you want?'
  386. if keypress_confirm(fs.format(self.item_desc,n)):
  387. return n
  388. async def view_and_sort(self,tx):
  389. from .term import get_char
  390. prompt = self.prompt.strip() + '\b'
  391. no_output,oneshot_msg = False,None
  392. while True:
  393. msg_r('' if no_output else '\n\n' if opt.no_blank else CUR_HOME+ERASE_ALL)
  394. reply = get_char(
  395. '' if no_output else await self.format_for_display()+'\n'+(oneshot_msg or '')+prompt,
  396. immed_chars=''.join(self.key_mappings.keys())
  397. )
  398. no_output = False
  399. oneshot_msg = '' if oneshot_msg else None # tristate, saves previous state
  400. if reply not in self.key_mappings:
  401. msg_r('\ninvalid keypress ')
  402. time.sleep(0.5)
  403. continue
  404. action = self.key_mappings[reply]
  405. if action[:2] == 's_':
  406. self.do_sort(action[2:])
  407. if action == 's_twmmid': self.show_mmid = True
  408. elif action == 'd_days':
  409. af = self.age_fmts_interactive
  410. self.age_fmt = af[(af.index(self.age_fmt) + 1) % len(af)]
  411. elif action == 'd_mmid':
  412. self.show_mmid = not self.show_mmid
  413. elif action == 'd_group':
  414. if self.can_group:
  415. self.group = not self.group
  416. elif action == 'd_redraw':
  417. pass
  418. elif action == 'd_reverse':
  419. self.unspent.reverse()
  420. self.reverse = not self.reverse
  421. elif action == 'a_quit':
  422. msg('')
  423. return self.unspent
  424. elif action == 'a_balance_refresh':
  425. idx = self.get_idx_from_user(action)
  426. if idx:
  427. e = self.unspent[idx-1]
  428. bal = await self.wallet.get_balance(e.addr,force_rpc=True)
  429. await self.get_unspent_data()
  430. oneshot_msg = yellow(f'{self.proto.dcoin} balance for account #{idx} refreshed\n\n')
  431. self.display_constants = self.get_display_constants()
  432. elif action == 'a_lbl_add':
  433. idx,lbl = self.get_idx_from_user(action)
  434. if idx:
  435. e = self.unspent[idx-1]
  436. if await self.wallet.add_label(e.twmmid,lbl,addr=e.addr):
  437. await self.get_unspent_data()
  438. oneshot_msg = yellow('Label {} {} #{}\n\n'.format(
  439. ('added to' if lbl else 'removed from'),
  440. self.item_desc,
  441. idx ))
  442. else:
  443. oneshot_msg = red('Label could not be added\n\n')
  444. self.display_constants = self.get_display_constants()
  445. elif action == 'a_addr_delete':
  446. idx = self.get_idx_from_user(action)
  447. if idx:
  448. e = self.unspent[idx-1]
  449. if await self.wallet.remove_address(e.addr):
  450. await self.get_unspent_data()
  451. oneshot_msg = yellow(f'{capfirst(self.item_desc)} #{idx} removed\n\n')
  452. else:
  453. oneshot_msg = red('Address could not be removed\n\n')
  454. self.display_constants = self.get_display_constants()
  455. elif action == 'a_print':
  456. of = '{}-{}[{}].out'.format(
  457. self.dump_fn_pfx,
  458. self.proto.dcoin,
  459. ','.join(self.sort_info(include_group=False)).lower() )
  460. msg('')
  461. try:
  462. write_data_to_file(
  463. of,
  464. await self.format_for_printing(),
  465. desc = f'{self.desc} listing' )
  466. except UserNonConfirmation as e:
  467. oneshot_msg = red(f'File {of!r} not overwritten by user request\n\n')
  468. else:
  469. oneshot_msg = yellow(f'Data written to {of!r}\n\n')
  470. elif action in ('a_view','a_view_wide'):
  471. do_pager(
  472. self.fmt_display if action == 'a_view' else
  473. await self.format_for_printing(color=True) )
  474. if g.platform == 'linux' and oneshot_msg == None:
  475. msg_r(CUR_RIGHT(len(prompt.split('\n')[-1])-2))
  476. no_output = True
  477. def age_disp(self,o,age_fmt):
  478. if age_fmt == 'confs':
  479. return o.confs
  480. elif age_fmt == 'block':
  481. return self.rpc.blockcount - (o.confs - 1)
  482. else:
  483. return _date_formatter[age_fmt](self.rpc,o.date)
  484. class TwAddrList(MMGenDict,metaclass=aInitMeta):
  485. has_age = True
  486. age_fmts = TwUnspentOutputs.age_fmts
  487. age_disp = TwUnspentOutputs.age_disp
  488. def __new__(cls,proto,*args,**kwargs):
  489. return MMGenDict.__new__(altcoin_subclass(cls,proto,'tw'),*args,**kwargs)
  490. def __init__(self,proto,*args,**kwargs):
  491. pass
  492. async def __ainit__(self,proto,usr_addr_list,minconf,showempty,showbtcaddrs,all_labels,wallet=None):
  493. def check_dup_mmid(acct_labels):
  494. mmid_prev,err = None,False
  495. for mmid in sorted(a.mmid for a in acct_labels if a):
  496. if mmid == mmid_prev:
  497. err = True
  498. msg(f'Duplicate MMGen ID ({mmid}) discovered in tracking wallet!\n')
  499. mmid_prev = mmid
  500. if err: rdie(3,'Tracking wallet is corrupted!')
  501. def check_addr_array_lens(acct_pairs):
  502. err = False
  503. for label,addrs in acct_pairs:
  504. if not label: continue
  505. if len(addrs) != 1:
  506. err = True
  507. if len(addrs) == 0:
  508. msg(f'Label {label!r}: has no associated address!')
  509. else:
  510. msg(f'{addrs!r}: more than one {proto.coin} address in account!')
  511. if err: rdie(3,'Tracking wallet is corrupted!')
  512. self.rpc = await rpc_init(proto)
  513. self.total = proto.coin_amt('0')
  514. self.proto = proto
  515. lbl_id = ('account','label')['label_api' in self.rpc.caps]
  516. for d in await self.rpc.call('listunspent',0):
  517. if not lbl_id in d: continue # skip coinbase outputs with missing account
  518. if d['confirmations'] < minconf: continue
  519. label = get_tw_label(proto,d[lbl_id])
  520. if label:
  521. lm = label.mmid
  522. if usr_addr_list and (lm not in usr_addr_list):
  523. continue
  524. if lm in self:
  525. if self[lm]['addr'] != d['address']:
  526. die(2,'duplicate {} address ({}) for this MMGen address! ({})'.format(
  527. proto.coin,
  528. d['address'],
  529. self[lm]['addr'] ))
  530. else:
  531. lm.confs = d['confirmations']
  532. lm.txid = d['txid']
  533. lm.date = None
  534. self[lm] = {
  535. 'amt': proto.coin_amt('0'),
  536. 'lbl': label,
  537. 'addr': CoinAddr(proto,d['address']) }
  538. self[lm]['amt'] += d['amount']
  539. self.total += d['amount']
  540. # We use listaccounts only for empty addresses, as it shows false positive balances
  541. if showempty or all_labels:
  542. # for compatibility with old mmids, must use raw RPC rather than native data for matching
  543. # args: minconf,watchonly, MUST use keys() so we get list, not dict
  544. if 'label_api' in self.rpc.caps:
  545. acct_list = await self.rpc.call('listlabels')
  546. aa = await self.rpc.batch_call('getaddressesbylabel',[(k,) for k in acct_list])
  547. acct_addrs = [list(a.keys()) for a in aa]
  548. else:
  549. acct_list = list((await self.rpc.call('listaccounts',0,True)).keys()) # raw list, no 'L'
  550. acct_addrs = await self.rpc.batch_call('getaddressesbyaccount',[(a,) for a in acct_list]) # use raw list here
  551. acct_labels = MMGenList([get_tw_label(proto,a) for a in acct_list])
  552. check_dup_mmid(acct_labels)
  553. assert len(acct_list) == len(acct_addrs),(
  554. 'listaccounts() and getaddressesbyaccount() not equal in length')
  555. addr_pairs = list(zip(acct_labels,acct_addrs))
  556. check_addr_array_lens(addr_pairs)
  557. for label,addr_arr in addr_pairs:
  558. if not label: continue
  559. if all_labels and not showempty and not label.comment: continue
  560. if usr_addr_list and (label.mmid not in usr_addr_list): continue
  561. if label.mmid not in self:
  562. self[label.mmid] = { 'amt':proto.coin_amt('0'), 'lbl':label, 'addr':'' }
  563. if showbtcaddrs:
  564. self[label.mmid]['addr'] = CoinAddr(proto,addr_arr[0])
  565. def raw_list(self):
  566. return [((k if k.type == 'mmgen' else 'Non-MMGen'),self[k]['addr'],self[k]['amt']) for k in self]
  567. def coinaddr_list(self):
  568. return [self[k]['addr'] for k in self]
  569. async def format(self,showbtcaddrs,sort,show_age,age_fmt):
  570. if not self.has_age:
  571. show_age = False
  572. if age_fmt not in self.age_fmts:
  573. raise BadAgeFormat(f'{age_fmt!r}: invalid age format (must be one of {self.age_fmts!r})')
  574. fs = '{mid}' + ('',' {addr}')[showbtcaddrs] + ' {cmt} {amt}' + ('',' {age}')[show_age]
  575. mmaddrs = [k for k in self.keys() if k.type == 'mmgen']
  576. max_mmid_len = max(len(k) for k in mmaddrs) + 2 if mmaddrs else 10
  577. max_cmt_width = max(max(v['lbl'].comment.screen_width for v in self.values()),7)
  578. addr_width = max(len(self[mmid]['addr']) for mmid in self)
  579. max_fp_len = max([len(a.split('.')[1]) for a in [str(v['amt']) for v in self.values()] if '.' in a] or [1])
  580. def sort_algo(j):
  581. if sort and 'age' in sort:
  582. return '{}_{:>012}_{}'.format(
  583. j.obj.rsplit(':',1)[0],
  584. # Hack, but OK for the foreseeable future:
  585. (1000000000-(j.confs or 0) if hasattr(j,'confs') else 0),
  586. j.sort_key)
  587. else:
  588. return j.sort_key
  589. mmids = sorted(self,key=sort_algo,reverse=bool(sort and 'reverse' in sort))
  590. if show_age:
  591. await _set_dates(self.rpc,[o for o in mmids if hasattr(o,'confs')])
  592. def gen_output():
  593. if self.proto.chain_name != 'mainnet':
  594. yield 'Chain: '+green(self.proto.chain_name.upper())
  595. yield fs.format(
  596. mid=MMGenID.fmtc('MMGenID',width=max_mmid_len),
  597. addr=(CoinAddr.fmtc('ADDRESS',width=addr_width) if showbtcaddrs else None),
  598. cmt=TwComment.fmtc('COMMENT',width=max_cmt_width+1),
  599. amt='BALANCE'.ljust(max_fp_len+4),
  600. age=age_fmt.upper(),
  601. ).rstrip()
  602. al_id_save = None
  603. for mmid in mmids:
  604. if mmid.type == 'mmgen':
  605. if al_id_save and al_id_save != mmid.obj.al_id:
  606. yield ''
  607. al_id_save = mmid.obj.al_id
  608. mmid_disp = mmid
  609. else:
  610. if al_id_save:
  611. yield ''
  612. al_id_save = None
  613. mmid_disp = 'Non-MMGen'
  614. e = self[mmid]
  615. yield fs.format(
  616. mid=MMGenID.fmtc(mmid_disp,width=max_mmid_len,color=True),
  617. addr=(e['addr'].fmt(color=True,width=addr_width) if showbtcaddrs else None),
  618. cmt=e['lbl'].comment.fmt(width=max_cmt_width,color=True,nullrepl='-'),
  619. amt=e['amt'].fmt('4.{}'.format(max(max_fp_len,3)),color=True),
  620. age=self.age_disp(mmid,age_fmt) if show_age and hasattr(mmid,'confs') else '-'
  621. ).rstrip()
  622. yield '\nTOTAL: {} {}'.format(
  623. self.total.hl(color=True),
  624. self.proto.dcoin )
  625. return '\n'.join(gen_output())
  626. class TrackingWallet(MMGenObject,metaclass=aInitMeta):
  627. caps = ('rescan','batch')
  628. data_key = 'addresses'
  629. use_tw_file = False
  630. aggressive_sync = False
  631. importing = False
  632. def __new__(cls,proto,*args,**kwargs):
  633. return MMGenObject.__new__(altcoin_subclass(cls,proto,'tw'))
  634. async def __ainit__(self,proto,mode='r',token_addr=None):
  635. assert mode in ('r','w','i'), "{!r}: wallet mode must be 'r','w' or 'i'".format(mode)
  636. if mode == 'i':
  637. self.importing = True
  638. mode = 'w'
  639. if g.debug:
  640. print_stack_trace(f'TW INIT {mode!r} {self!r}')
  641. self.rpc = await rpc_init(proto) # TODO: create on demand - only certain ops require RPC
  642. self.proto = proto
  643. self.mode = mode
  644. self.desc = self.base_desc = f'{self.proto.name} tracking wallet'
  645. if self.use_tw_file:
  646. self.init_from_wallet_file()
  647. else:
  648. self.init_empty()
  649. if self.data['coin'] != self.proto.coin: # TODO remove?
  650. raise WalletFileError(
  651. 'Tracking wallet coin ({}) does not match current coin ({})!'.format(
  652. self.data['coin'],
  653. self.proto.coin ))
  654. self.conv_types(self.data[self.data_key])
  655. self.cur_balances = {} # cache balances to prevent repeated lookups per program invocation
  656. def init_empty(self):
  657. self.data = { 'coin': self.proto.coin, 'addresses': {} }
  658. def init_from_wallet_file(self):
  659. tw_dir = (
  660. os.path.join(g.data_dir) if self.proto.coin == 'BTC' else
  661. os.path.join(
  662. g.data_dir_root,
  663. 'altcoins',
  664. self.proto.coin.lower(),
  665. ('' if self.proto.network == 'mainnet' else 'testnet')
  666. ))
  667. self.tw_fn = os.path.join(tw_dir,'tracking-wallet.json')
  668. check_or_create_dir(tw_dir)
  669. try:
  670. self.orig_data = get_data_from_file(self.tw_fn,quiet=True)
  671. self.data = json.loads(self.orig_data)
  672. except:
  673. try: os.stat(self.tw_fn)
  674. except:
  675. self.orig_data = ''
  676. self.init_empty()
  677. self.force_write()
  678. else:
  679. raise WalletFileError(f'File {self.tw_fn!r} exists but does not contain valid json data')
  680. else:
  681. self.upgrade_wallet_maybe()
  682. # ensure that wallet file is written when user exits via KeyboardInterrupt:
  683. if self.mode == 'w':
  684. import atexit
  685. def del_tw(tw):
  686. dmsg(f'Running exit handler del_tw() for {tw!r}')
  687. del tw
  688. atexit.register(del_tw,self)
  689. def __del__(self):
  690. """
  691. TrackingWallet instances opened in write or import mode must be explicitly destroyed
  692. with 'del tw', 'del twuo.wallet' and the like to ensure the instance is deleted and
  693. wallet is written before global vars are destroyed by the interpreter at shutdown.
  694. Not that this code can only be debugged by examining the program output, as exceptions
  695. are ignored within __del__():
  696. /usr/share/doc/python3.6-doc/html/reference/datamodel.html#object.__del__
  697. Since no exceptions are raised, errors will not be caught by the test suite.
  698. """
  699. if g.debug:
  700. print_stack_trace(f'TW DEL {self!r}')
  701. if self.mode == 'w':
  702. self.write()
  703. elif g.debug:
  704. msg('read-only wallet, doing nothing')
  705. def upgrade_wallet_maybe(self):
  706. pass
  707. def conv_types(self,ad):
  708. for k,v in ad.items():
  709. if k not in ('params','coin'):
  710. v['mmid'] = TwMMGenID(self.proto,v['mmid'])
  711. v['comment'] = TwComment(v['comment'])
  712. @property
  713. def data_root(self):
  714. return self.data[self.data_key]
  715. @property
  716. def data_root_desc(self):
  717. return self.data_key
  718. def cache_balance(self,addr,bal,session_cache,data_root,force=False):
  719. if force or addr not in session_cache:
  720. session_cache[addr] = str(bal)
  721. if addr in data_root:
  722. data_root[addr]['balance'] = str(bal)
  723. if self.aggressive_sync:
  724. self.write()
  725. def get_cached_balance(self,addr,session_cache,data_root):
  726. if addr in session_cache:
  727. return self.proto.coin_amt(session_cache[addr])
  728. if not g.cached_balances:
  729. return None
  730. if addr in data_root and 'balance' in data_root[addr]:
  731. return self.proto.coin_amt(data_root[addr]['balance'])
  732. async def get_balance(self,addr,force_rpc=False):
  733. ret = None if force_rpc else self.get_cached_balance(addr,self.cur_balances,self.data_root)
  734. if ret == None:
  735. ret = await self.rpc_get_balance(addr)
  736. self.cache_balance(addr,ret,self.cur_balances,self.data_root)
  737. return ret
  738. async def rpc_get_balance(self,addr):
  739. raise NotImplementedError('not implemented')
  740. @property
  741. def sorted_list(self):
  742. return sorted(
  743. [ { 'addr':x[0],
  744. 'mmid':x[1]['mmid'],
  745. 'comment':x[1]['comment'] }
  746. for x in self.data_root.items() if x[0] not in ('params','coin') ],
  747. key=lambda x: x['mmid'].sort_key+x['addr'] )
  748. @property
  749. def mmid_ordered_dict(self):
  750. return dict((x['mmid'],{'addr':x['addr'],'comment':x['comment']}) for x in self.sorted_list)
  751. @write_mode
  752. async def import_address(self,addr,label,rescan):
  753. return await self.rpc.call('importaddress',addr,label,rescan,timeout=(False,3600)[rescan])
  754. @write_mode
  755. def batch_import_address(self,arg_list):
  756. return self.rpc.batch_call('importaddress',arg_list)
  757. def force_write(self):
  758. mode_save = self.mode
  759. self.mode = 'w'
  760. self.write()
  761. self.mode = mode_save
  762. @write_mode
  763. def write_changed(self,data):
  764. write_data_to_file(
  765. self.tw_fn,
  766. data,
  767. desc = f'{self.base_desc} data',
  768. ask_overwrite = False,
  769. ignore_opt_outdir = True,
  770. quiet = True,
  771. check_data = True,
  772. cmp_data = self.orig_data )
  773. self.orig_data = data
  774. def write(self): # use 'check_data' to check wallet hasn't been altered by another program
  775. if not self.use_tw_file:
  776. dmsg("'use_tw_file' is False, doing nothing")
  777. return
  778. dmsg(f'write(): checking if {self.desc} data has changed')
  779. wdata = json.dumps(self.data)
  780. if self.orig_data != wdata:
  781. if g.debug:
  782. print_stack_trace('TW DATA CHANGED {!r}'.format(self))
  783. print_diff(self.orig_data,wdata,from_json=True)
  784. self.write_changed(wdata)
  785. elif g.debug:
  786. msg('Data is unchanged\n')
  787. async def is_in_wallet(self,addr):
  788. return addr in (await TwAddrList(self.proto,[],0,True,True,True,wallet=self)).coinaddr_list()
  789. @write_mode
  790. async def set_label(self,coinaddr,lbl):
  791. # bitcoin-abc 'setlabel' RPC is broken, so use old 'importaddress' method to set label
  792. # broken behavior: new label is set OK, but old label gets attached to another address
  793. if 'label_api' in self.rpc.caps and self.proto.coin != 'BCH':
  794. args = ('setlabel',coinaddr,lbl)
  795. else:
  796. # NOTE: this works because importaddress() removes the old account before
  797. # associating the new account with the address.
  798. # RPC args: addr,label,rescan[=true],p2sh[=none]
  799. args = ('importaddress',coinaddr,lbl,False)
  800. try:
  801. return await self.rpc.call(*args)
  802. except Exception as e:
  803. rmsg(e.args[0])
  804. return False
  805. # returns on failure
  806. @write_mode
  807. async def add_label(self,arg1,label='',addr=None,silent=False,on_fail='return'):
  808. assert on_fail in ('return','raise'), 'add_label_chk1'
  809. mmaddr,coinaddr = None,None
  810. if is_coin_addr(self.proto,addr or arg1):
  811. coinaddr = get_obj(CoinAddr,proto=self.proto,addr=addr or arg1)
  812. if is_mmgen_id(self.proto,arg1):
  813. mmaddr = TwMMGenID(self.proto,arg1)
  814. if mmaddr and not coinaddr:
  815. from .addr import TwAddrData
  816. coinaddr = (await TwAddrData(self.proto)).mmaddr2coinaddr(mmaddr)
  817. try:
  818. if not is_mmgen_id(self.proto,arg1):
  819. assert coinaddr, f'Invalid coin address for this chain: {arg1}'
  820. assert coinaddr, f'{g.proj_name} address {mmaddr!r} not found in tracking wallet'
  821. assert await self.is_in_wallet(coinaddr), f'Address {coinaddr!r} not found in tracking wallet'
  822. except Exception as e:
  823. msg(str(e))
  824. return False
  825. # Allow for the possibility that BTC addr of MMGen addr was entered.
  826. # Do reverse lookup, so that MMGen addr will not be marked as non-MMGen.
  827. if not mmaddr:
  828. from .addr import TwAddrData
  829. mmaddr = (await TwAddrData(proto=self.proto)).coinaddr2mmaddr(coinaddr)
  830. if not mmaddr:
  831. mmaddr = f'{self.proto.base_coin.lower()}:{coinaddr}'
  832. mmaddr = TwMMGenID(self.proto,mmaddr)
  833. cmt = TwComment(label) if on_fail=='raise' else get_obj(TwComment,s=label)
  834. if cmt in (False,None):
  835. return False
  836. lbl_txt = mmaddr + (' ' + cmt if cmt else '')
  837. lbl = (
  838. TwLabel(self.proto,lbl_txt) if on_fail == 'raise' else
  839. get_obj(TwLabel,proto=self.proto,text=lbl_txt) )
  840. if await self.set_label(coinaddr,lbl) == False:
  841. if not silent:
  842. msg( 'Label could not be {}'.format('added' if label else 'removed') )
  843. return False
  844. else:
  845. desc = '{} address {} in tracking wallet'.format(
  846. mmaddr.type.replace('mmg','MMG'),
  847. mmaddr.replace(self.proto.base_coin.lower()+':','') )
  848. if label:
  849. msg(f'Added label {label!r} to {desc}')
  850. else:
  851. msg(f'Removed label from {desc}')
  852. return True
  853. @write_mode
  854. async def remove_label(self,mmaddr):
  855. await self.add_label(mmaddr,'')
  856. @write_mode
  857. async def remove_address(self,addr):
  858. raise NotImplementedError(f'address removal not implemented for coin {self.proto.coin}')
  859. class TwGetBalance(MMGenObject,metaclass=aInitMeta):
  860. fs = '{w:13} {u:<16} {p:<16} {c}'
  861. def __new__(cls,proto,*args,**kwargs):
  862. return MMGenObject.__new__(altcoin_subclass(cls,proto,'tw'))
  863. async def __ainit__(self,proto,minconf,quiet):
  864. self.minconf = minconf
  865. self.quiet = quiet
  866. self.data = {k:[proto.coin_amt('0')] * 4 for k in ('TOTAL','Non-MMGen','Non-wallet')}
  867. self.rpc = await rpc_init(proto)
  868. self.proto = proto
  869. await self.create_data()
  870. async def create_data(self):
  871. # 0: unconfirmed, 1: below minconf, 2: confirmed, 3: spendable (privkey in wallet)
  872. lbl_id = ('account','label')['label_api' in self.rpc.caps]
  873. for d in await self.rpc.call('listunspent',0):
  874. lbl = get_tw_label(self.proto,d[lbl_id])
  875. if lbl:
  876. if lbl.mmid.type == 'mmgen':
  877. key = lbl.mmid.obj.sid
  878. if key not in self.data:
  879. self.data[key] = [self.proto.coin_amt('0')] * 4
  880. else:
  881. key = 'Non-MMGen'
  882. else:
  883. lbl,key = None,'Non-wallet'
  884. if not d['confirmations']:
  885. self.data['TOTAL'][0] += d['amount']
  886. self.data[key][0] += d['amount']
  887. conf_level = (1,2)[d['confirmations'] >= self.minconf]
  888. self.data['TOTAL'][conf_level] += d['amount']
  889. self.data[key][conf_level] += d['amount']
  890. if d['spendable']:
  891. self.data[key][3] += d['amount']
  892. def format(self):
  893. def gen_output():
  894. if self.proto.chain_name != 'mainnet':
  895. yield 'Chain: ' + green(self.proto.chain_name.upper())
  896. if self.quiet:
  897. yield str(self.data['TOTAL'][2] if self.data else 0)
  898. else:
  899. yield self.fs.format(
  900. w = 'Wallet',
  901. u = ' Unconfirmed',
  902. p = f' <{self.minconf} confirms',
  903. c = f' >={self.minconf} confirms' )
  904. for key in sorted(self.data):
  905. if not any(self.data[key]):
  906. continue
  907. yield self.fs.format(**dict(zip(
  908. ('w','u','p','c'),
  909. [key+':'] + [a.fmt(color=True,suf=' '+self.proto.dcoin) for a in self.data[key]]
  910. )))
  911. for key,vals in list(self.data.items()):
  912. if key == 'TOTAL':
  913. continue
  914. if vals[3]:
  915. yield red(f'Warning: this wallet contains PRIVATE KEYS for {key} outputs!')
  916. return '\n'.join(gen_output()).rstrip()