tw.py 35 KB

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