store.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  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. # Licensed under the GNU General Public License, Version 3:
  6. # https://www.gnu.org/licenses
  7. # Public project repositories:
  8. # https://github.com/mmgen/mmgen-wallet
  9. # https://gitlab.com/mmgen/mmgen-wallet
  10. """
  11. tw.store: Tracking wallet control class with store
  12. """
  13. import json
  14. from pathlib import Path
  15. from ..base_obj import AsyncInit
  16. from ..obj import TwComment
  17. from ..util import msg, die, cached_property
  18. from ..addr import is_coin_addr, is_mmgen_id, CoinAddr
  19. from .shared import TwMMGenID, TwLabel
  20. from .ctl import TwCtl, write_mode, label_addr_pair
  21. class TwCtlWithStore(TwCtl, metaclass=AsyncInit):
  22. caps = ('batch',)
  23. tw_subdir = None
  24. tw_fn = 'tracking-wallet.json'
  25. aggressive_sync = False
  26. async def __init__(
  27. self,
  28. cfg,
  29. proto,
  30. *,
  31. mode = 'r',
  32. token_addr = None,
  33. no_rpc = False,
  34. no_wallet_init = False,
  35. rpc_ignore_wallet = False):
  36. await super().__init__(cfg, proto, mode=mode, no_rpc=no_rpc, rpc_ignore_wallet=rpc_ignore_wallet)
  37. self.cur_balances = {} # cache balances to prevent repeated lookups per program invocation
  38. if cfg.cached_balances:
  39. self.use_cached_balances = True
  40. self.tw_dir = type(self).get_tw_dir(self.cfg, self.proto)
  41. self.tw_path = self.tw_dir / self.tw_fn
  42. if no_wallet_init:
  43. return
  44. self.init_from_wallet_file()
  45. if self.data['coin'] != self.proto.coin:
  46. fs = 'Tracking wallet coin ({}) does not match current coin ({})!'
  47. die('WalletFileError', fs.format(self.data['coin'], self.proto.coin))
  48. self.conv_types(self.data[self.data_key])
  49. def __del__(self):
  50. """
  51. TwCtl instances opened in write or import mode must be explicitly destroyed with ‘del
  52. twuo.twctl’ and the like to ensure the instance is deleted and wallet is written before
  53. global vars are destroyed by the interpreter at shutdown.
  54. Not that this code can only be debugged by examining the program output, as exceptions
  55. are ignored within __del__():
  56. /usr/share/doc/python3.6-doc/html/reference/datamodel.html#object.__del__
  57. Since no exceptions are raised, errors will not be caught by the test suite.
  58. """
  59. if getattr(self, 'mode', None) == 'w': # mode attr might not exist in this state
  60. self.write()
  61. elif self.cfg.debug:
  62. msg('read-only wallet, doing nothing')
  63. @classmethod
  64. def get_tw_dir(cls, cfg, proto):
  65. return Path(
  66. cfg.data_dir_root,
  67. cfg.test_user,
  68. 'altcoins',
  69. proto.coin.lower(),
  70. ('' if proto.network == 'mainnet' else proto.network),
  71. (cls.tw_subdir or ''))
  72. def upgrade_wallet_maybe(self):
  73. pass
  74. def conv_types(self, ad):
  75. for k, v in ad.items():
  76. if k not in ('params', 'coin'):
  77. v['mmid'] = TwMMGenID(self.proto, v['mmid'])
  78. v['comment'] = TwComment(v['comment'])
  79. def init_empty(self):
  80. self.data = {
  81. 'coin': self.proto.coin,
  82. 'network': self.proto.network.upper(),
  83. 'addresses': {}}
  84. def init_from_wallet_file(self):
  85. from ..fileutil import check_or_create_dir, get_data_from_file
  86. check_or_create_dir(self.tw_dir)
  87. try:
  88. self.orig_data = get_data_from_file(self.cfg, self.tw_path, quiet=True)
  89. self.data = json.loads(self.orig_data)
  90. except:
  91. try:
  92. self.tw_path.stat()
  93. except:
  94. self.orig_data = ''
  95. self.init_empty()
  96. self.force_write()
  97. else:
  98. die('WalletFileError', f'File ‘{self.tw_path}’ exists but does not contain valid JSON data')
  99. else:
  100. self.upgrade_wallet_maybe()
  101. # ensure that wallet file is written when user exits via KeyboardInterrupt:
  102. if self.mode == 'w':
  103. import atexit
  104. def del_twctl(twctl):
  105. self.cfg._util.dmsg(f'Running exit handler del_twctl() for {twctl!r}')
  106. del twctl
  107. atexit.register(del_twctl, self)
  108. @write_mode
  109. async def batch_import_address(self, args_list):
  110. return [await self.import_address(a, label=b, rescan=c) for a, b, c in args_list]
  111. async def rescan_addresses(self, coin_addrs):
  112. pass
  113. @write_mode
  114. async def import_address(self, addr, *, label, rescan=False):
  115. r = self.data_root
  116. if addr in r:
  117. if self.check_import_mmid(addr, r[addr]['mmid'], label.mmid):
  118. r[addr]['mmid'] = label.mmid
  119. if label.comment: # overwrite existing comment only if new comment not empty
  120. r[addr]['comment'] = label.comment
  121. else:
  122. r[addr] = {'mmid': label.mmid, 'comment': label.comment}
  123. @write_mode
  124. async def remove_address(self, addr):
  125. r = self.data_root
  126. if is_coin_addr(self.proto, addr):
  127. have_match = lambda k: k == addr
  128. elif is_mmgen_id(self.proto, addr):
  129. have_match = lambda k: r[k]['mmid'] == addr
  130. else:
  131. die(1, f'{addr!r} is not an Ethereum address or MMGen ID')
  132. for k in r:
  133. if have_match(k):
  134. # return the addr resolved to mmid if possible
  135. ret = r[k]['mmid'] if is_mmgen_id(self.proto, r[k]['mmid']) else addr
  136. del r[k]
  137. self.write()
  138. return ret
  139. msg(f'Address {addr!r} not found in {self.data_root_desc!r} section of tracking wallet')
  140. return None
  141. @write_mode
  142. async def set_label(self, coinaddr, lbl):
  143. for addr, d in list(self.data_root.items()):
  144. if addr == coinaddr:
  145. d['comment'] = lbl.comment
  146. self.write()
  147. return True
  148. msg(f'Address {coinaddr!r} not found in {self.data_root_desc!r} section of tracking wallet')
  149. return False
  150. @property
  151. def sorted_list(self):
  152. return sorted([{
  153. 'addr': x[0],
  154. 'mmid': x[1]['mmid'],
  155. 'comment': x[1]['comment']
  156. } for x in self.data_root.items() if x[0] not in ('params', 'coin')],
  157. key = lambda x: x['mmid'].sort_key + x['addr'])
  158. @property
  159. def mmid_ordered_dict(self):
  160. return dict((x['mmid'], {'addr': x['addr'], 'comment': x['comment']}) for x in self.sorted_list)
  161. async def get_label_addr_pairs(self):
  162. return [label_addr_pair(
  163. TwLabel(self.proto, f"{mmid} {d['comment']}"),
  164. CoinAddr(self.proto, d['addr'])
  165. ) for mmid, d in self.mmid_ordered_dict.items()]
  166. @cached_property
  167. def used_addrs(self):
  168. from decimal import Decimal
  169. # TODO: for now, consider used addrs to be addrs with balance
  170. return ({k for k, v in self.data['addresses'].items() if Decimal(v.get('balance', 0))})
  171. @property
  172. def data_root(self):
  173. return self.data[self.data_key]
  174. @property
  175. def data_root_desc(self):
  176. return self.data_key
  177. def cache_balance(self, addr, bal, *, session_cache, data_root, force=False):
  178. if force or addr not in session_cache:
  179. session_cache[addr] = str(bal)
  180. if addr in data_root:
  181. data_root[addr]['balance'] = str(bal)
  182. if self.aggressive_sync:
  183. self.write()
  184. def get_cached_balance(self, addr, session_cache, data_root):
  185. if addr in session_cache:
  186. return self.proto.coin_amt(session_cache[addr])
  187. if self.use_cached_balances:
  188. return self.proto.coin_amt(
  189. data_root[addr]['balance'] if addr in data_root and 'balance' in data_root[addr]
  190. else '0')
  191. async def get_balance(self, addr, *, force_rpc=False, block='latest'):
  192. ret = None if force_rpc else self.get_cached_balance(addr, self.cur_balances, self.data_root)
  193. if ret is None:
  194. ret = await self.rpc_get_balance(addr, block=block)
  195. if ret is not None:
  196. self.cache_balance(addr, ret, session_cache=self.cur_balances, data_root=self.data_root)
  197. return ret
  198. def force_write(self):
  199. mode_save = self.mode
  200. self.mode = 'w'
  201. self.write()
  202. self.mode = mode_save
  203. @write_mode
  204. def write_changed(self, data, quiet):
  205. from ..fileutil import write_data_to_file
  206. write_data_to_file(
  207. self.cfg,
  208. self.tw_path,
  209. data,
  210. desc = f'{self.base_desc} data',
  211. ask_overwrite = False,
  212. ignore_opt_outdir = True,
  213. quiet = quiet,
  214. check_data = True, # die if wallet has been altered by another program
  215. cmp_data = self.orig_data)
  216. self.orig_data = data
  217. def write(self, *, quiet=True):
  218. self.cfg._util.dmsg(f'write(): checking if {self.desc} data has changed')
  219. wdata = json.dumps(self.data)
  220. if self.orig_data != wdata:
  221. self.write_changed(wdata, quiet=quiet)
  222. elif self.cfg.debug:
  223. msg('Data is unchanged\n')