ctl.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2023 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.ctl: Tracking wallet control class for the MMGen suite
  20. """
  21. import json
  22. from collections import namedtuple
  23. from ..util import msg,msg_r,suf,die
  24. from ..base_obj import AsyncInit
  25. from ..objmethods import MMGenObject
  26. from ..obj import TwComment,get_obj
  27. from ..addr import CoinAddr,is_mmgen_id,is_coin_addr
  28. from ..rpc import rpc_init
  29. from .shared import TwMMGenID,TwLabel
  30. addr_info = namedtuple('addr_info',['twmmid','coinaddr'])
  31. # decorator for TwCtl
  32. def write_mode(orig_func):
  33. def f(self,*args,**kwargs):
  34. if self.mode != 'w':
  35. die(1,'{} opened in read-only mode: cannot execute method {}()'.format(
  36. type(self).__name__,
  37. locals()['orig_func'].__name__
  38. ))
  39. return orig_func(self,*args,**kwargs)
  40. return f
  41. class TwCtl(MMGenObject,metaclass=AsyncInit):
  42. caps = ('rescan','batch')
  43. data_key = 'addresses'
  44. use_tw_file = False
  45. aggressive_sync = False
  46. importing = False
  47. def __new__(cls,cfg,proto,*args,**kwargs):
  48. return MMGenObject.__new__(proto.base_proto_subclass(cls,'tw.ctl'))
  49. async def __init__(
  50. self,
  51. cfg,
  52. proto,
  53. mode = 'r',
  54. token_addr = None,
  55. rpc_ignore_wallet = False):
  56. assert mode in ('r','w','i'), f"{mode!r}: wallet mode must be 'r','w' or 'i'"
  57. if mode == 'i':
  58. self.importing = True
  59. mode = 'w'
  60. # TODO: create on demand - only certain ops require RPC
  61. self.cfg = cfg
  62. self.rpc = await rpc_init( cfg, proto, ignore_wallet=rpc_ignore_wallet )
  63. self.proto = proto
  64. self.mode = mode
  65. self.desc = self.base_desc = f'{self.proto.name} tracking wallet'
  66. if self.use_tw_file:
  67. self.init_from_wallet_file()
  68. else:
  69. self.init_empty()
  70. if self.data['coin'] != self.proto.coin: # TODO remove?
  71. die( 'WalletFileError',
  72. 'Tracking wallet coin ({}) does not match current coin ({})!'.format(
  73. self.data['coin'],
  74. self.proto.coin ))
  75. self.conv_types(self.data[self.data_key])
  76. self.cur_balances = {} # cache balances to prevent repeated lookups per program invocation
  77. def init_from_wallet_file(self):
  78. import os
  79. tw_dir = (
  80. os.path.join(self.cfg.data_dir) if self.proto.coin == 'BTC' else
  81. os.path.join(
  82. self.cfg.data_dir_root,
  83. 'altcoins',
  84. self.proto.coin.lower(),
  85. ('' if self.proto.network == 'mainnet' else 'testnet')
  86. ))
  87. self.tw_fn = os.path.join(tw_dir,'tracking-wallet.json')
  88. from ..fileutil import check_or_create_dir,get_data_from_file
  89. check_or_create_dir(tw_dir)
  90. try:
  91. self.orig_data = get_data_from_file( self.cfg, self.tw_fn, quiet=True )
  92. self.data = json.loads(self.orig_data)
  93. except:
  94. try:
  95. os.stat(self.tw_fn)
  96. except:
  97. self.orig_data = ''
  98. self.init_empty()
  99. self.force_write()
  100. else:
  101. die( 'WalletFileError', f'File {self.tw_fn!r} exists but does not contain valid json data' )
  102. else:
  103. self.upgrade_wallet_maybe()
  104. # ensure that wallet file is written when user exits via KeyboardInterrupt:
  105. if self.mode == 'w':
  106. import atexit
  107. def del_twctl(twctl):
  108. self.cfg._util.dmsg(f'Running exit handler del_twctl() for {twctl!r}')
  109. del twctl
  110. atexit.register(del_twctl,self)
  111. def __del__(self):
  112. """
  113. TwCtl instances opened in write or import mode must be explicitly destroyed with ‘del
  114. twuo.twctl’ and the like to ensure the instance is deleted and wallet is written before
  115. global vars are destroyed by the interpreter at shutdown.
  116. Not that this code can only be debugged by examining the program output, as exceptions
  117. are ignored within __del__():
  118. /usr/share/doc/python3.6-doc/html/reference/datamodel.html#object.__del__
  119. Since no exceptions are raised, errors will not be caught by the test suite.
  120. """
  121. if getattr(self,'mode',None) == 'w': # mode attr might not exist in this state
  122. self.write()
  123. elif self.cfg.debug:
  124. msg('read-only wallet, doing nothing')
  125. def conv_types(self,ad):
  126. for k,v in ad.items():
  127. if k not in ('params','coin'):
  128. v['mmid'] = TwMMGenID(self.proto,v['mmid'])
  129. v['comment'] = TwComment(v['comment'])
  130. @property
  131. def data_root(self):
  132. return self.data[self.data_key]
  133. @property
  134. def data_root_desc(self):
  135. return self.data_key
  136. def cache_balance(self,addr,bal,session_cache,data_root,force=False):
  137. if force or addr not in session_cache:
  138. session_cache[addr] = str(bal)
  139. if addr in data_root:
  140. data_root[addr]['balance'] = str(bal)
  141. if self.aggressive_sync:
  142. self.write()
  143. def get_cached_balance(self,addr,session_cache,data_root):
  144. if addr in session_cache:
  145. return self.proto.coin_amt(session_cache[addr])
  146. if not self.cfg.cached_balances:
  147. return None
  148. if addr in data_root and 'balance' in data_root[addr]:
  149. return self.proto.coin_amt(data_root[addr]['balance'])
  150. async def get_balance(self,addr,force_rpc=False):
  151. ret = None if force_rpc else self.get_cached_balance(addr,self.cur_balances,self.data_root)
  152. if ret == None:
  153. ret = await self.rpc_get_balance(addr)
  154. self.cache_balance(addr,ret,self.cur_balances,self.data_root)
  155. return ret
  156. def force_write(self):
  157. mode_save = self.mode
  158. self.mode = 'w'
  159. self.write()
  160. self.mode = mode_save
  161. @write_mode
  162. def write_changed(self,data,quiet):
  163. from ..fileutil import write_data_to_file
  164. write_data_to_file(
  165. self.cfg,
  166. self.tw_fn,
  167. data,
  168. desc = f'{self.base_desc} data',
  169. ask_overwrite = False,
  170. ignore_opt_outdir = True,
  171. quiet = quiet,
  172. check_data = True, # die if wallet has been altered by another program
  173. cmp_data = self.orig_data )
  174. self.orig_data = data
  175. def write(self,quiet=True):
  176. if not self.use_tw_file:
  177. self.cfg._util.dmsg("'use_tw_file' is False, doing nothing")
  178. return
  179. self.cfg._util.dmsg(f'write(): checking if {self.desc} data has changed')
  180. wdata = json.dumps(self.data)
  181. if self.orig_data != wdata:
  182. self.write_changed(wdata,quiet=quiet)
  183. elif self.cfg.debug:
  184. msg('Data is unchanged\n')
  185. async def resolve_address(self,addrspec):
  186. twmmid,coinaddr = (None,None)
  187. if is_coin_addr(self.proto,addrspec):
  188. coinaddr = get_obj(CoinAddr,proto=self.proto,addr=addrspec)
  189. elif is_mmgen_id(self.proto,addrspec):
  190. twmmid = TwMMGenID(self.proto,addrspec)
  191. else:
  192. msg(f'{addrspec!r}: invalid address for this network')
  193. return None
  194. from .rpc import TwRPC
  195. pairs = await TwRPC(proto=self.proto,rpc=self.rpc,twctl=self).get_addr_label_pairs(twmmid)
  196. if not pairs:
  197. msg(f'MMGen address {twmmid!r} not found in tracking wallet')
  198. return None
  199. pairs_data = dict((label.mmid,addr) for label,addr in pairs)
  200. if twmmid and not coinaddr:
  201. coinaddr = pairs_data[twmmid]
  202. # Allow for the possibility that BTC addr of MMGen addr was entered.
  203. # Do reverse lookup, so that MMGen addr will not be marked as non-MMGen.
  204. if not twmmid:
  205. for mmid,addr in pairs_data.items():
  206. if coinaddr == addr:
  207. twmmid = mmid
  208. break
  209. else:
  210. msg(f'Coin address {addrspec!r} not found in tracking wallet')
  211. return None
  212. return addr_info(twmmid,coinaddr)
  213. # returns on failure
  214. @write_mode
  215. async def set_comment(self,addrspec,comment='',trusted_coinaddr=None,silent=False):
  216. res = (
  217. addr_info(addrspec,trusted_coinaddr) if trusted_coinaddr
  218. else await self.resolve_address(addrspec) )
  219. if not res:
  220. return False
  221. comment = get_obj(TwComment,s=comment)
  222. if comment == False:
  223. return False
  224. lbl = get_obj(
  225. TwLabel,
  226. proto = self.proto,
  227. text = res.twmmid + (' ' + comment if comment else ''))
  228. if lbl == False:
  229. return False
  230. if await self.set_label(res.coinaddr,lbl):
  231. # redundant paranoia step:
  232. from .rpc import TwRPC
  233. pairs = await TwRPC(proto=self.proto,rpc=self.rpc,twctl=self).get_addr_label_pairs(res.twmmid)
  234. assert pairs[0][0].comment == comment, f'{pairs[0][0].comment!r} != {comment!r}'
  235. if not silent:
  236. desc = '{} address {} in tracking wallet'.format(
  237. res.twmmid.type.replace('mmgen','MMGen'),
  238. res.twmmid.addr.hl() )
  239. msg(
  240. 'Added label {} to {}'.format(comment.hl2(encl='‘’'),desc) if comment else
  241. 'Removed label from {}'.format(desc) )
  242. return True
  243. else:
  244. if not silent:
  245. msg( 'Label could not be {}'.format('added' if comment else 'removed') )
  246. return False
  247. @write_mode
  248. async def remove_comment(self,mmaddr):
  249. await self.set_comment(mmaddr,'')
  250. async def import_address_common(self,data,batch=False,gather=False):
  251. async def do_import(address,comment,message):
  252. try:
  253. res = await self.import_address( address, comment )
  254. self.cfg._util.qmsg(message)
  255. return res
  256. except Exception as e:
  257. die(2,f'\nImport of address {address!r} failed: {e.args[0]!r}')
  258. _d = namedtuple( 'formatted_import_data', data[0]._fields + ('mmid_disp',))
  259. pfx = self.proto.base_coin.lower() + ':'
  260. fdata = [ _d(*d, 'non-MMGen' if d.twmmid.startswith(pfx) else d.twmmid ) for d in data ]
  261. fs = '{:%s}: {:%s} {:%s} - OK' % (
  262. len(str(len(fdata))) * 2 + 1,
  263. max(len(d.addr) for d in fdata),
  264. max(len(d.mmid_disp) for d in fdata) + 2
  265. )
  266. nAddrs = len(data)
  267. out = [( # create list, not generator, so we know data is valid before starting import
  268. CoinAddr( self.proto, d.addr ),
  269. TwLabel( self.proto, d.twmmid + (f' {d.comment}' if d.comment else '') ),
  270. fs.format( f'{n}/{nAddrs}', d.addr, f'({d.mmid_disp})' )
  271. ) for n,d in enumerate(fdata,1)]
  272. if batch:
  273. msg_r(f'Batch importing {len(out)} address{suf(data,"es")}...')
  274. ret = await self.batch_import_address((a,b) for a,b,c in out)
  275. msg(f'done\n{len(ret)} addresses imported')
  276. else:
  277. if gather: # this seems to provide little performance benefit
  278. import asyncio
  279. await asyncio.gather(*(do_import(*d) for d in out))
  280. else:
  281. for d in out:
  282. await do_import(*d)
  283. msg('Address import completed OK')