ctl.py 10 KB

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