store.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  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. 'altcoins',
  68. proto.coin.lower(),
  69. ('' if proto.network == 'mainnet' else proto.network),
  70. (cls.tw_subdir or ''))
  71. def upgrade_wallet_maybe(self):
  72. pass
  73. def conv_types(self, ad):
  74. for k, v in ad.items():
  75. if k not in ('params', 'coin'):
  76. v['mmid'] = TwMMGenID(self.proto, v['mmid'])
  77. v['comment'] = TwComment(v['comment'])
  78. def init_empty(self):
  79. self.data = {
  80. 'coin': self.proto.coin,
  81. 'network': self.proto.network.upper(),
  82. 'addresses': {}}
  83. def init_from_wallet_file(self):
  84. from ..fileutil import check_or_create_dir, get_data_from_file
  85. check_or_create_dir(self.tw_dir)
  86. try:
  87. self.orig_data = get_data_from_file(self.cfg, self.tw_path, quiet=True)
  88. self.data = json.loads(self.orig_data)
  89. except:
  90. try:
  91. self.tw_path.stat()
  92. except:
  93. self.orig_data = ''
  94. self.init_empty()
  95. self.force_write()
  96. else:
  97. die('WalletFileError', f'File ‘{self.tw_path}’ exists but does not contain valid JSON data')
  98. else:
  99. self.upgrade_wallet_maybe()
  100. # ensure that wallet file is written when user exits via KeyboardInterrupt:
  101. if self.mode == 'w':
  102. import atexit
  103. def del_twctl(twctl):
  104. self.cfg._util.dmsg(f'Running exit handler del_twctl() for {twctl!r}')
  105. del twctl
  106. atexit.register(del_twctl, self)
  107. @write_mode
  108. async def batch_import_address(self, args_list):
  109. return [await self.import_address(a, label=b, rescan=c) for a, b, c in args_list]
  110. async def rescan_addresses(self, coin_addrs):
  111. pass
  112. @write_mode
  113. async def import_address(self, addr, *, label, rescan=False):
  114. r = self.data_root
  115. if addr in r:
  116. if self.check_import_mmid(addr, r[addr]['mmid'], label.mmid):
  117. r[addr]['mmid'] = label.mmid
  118. if label.comment: # overwrite existing comment only if new comment not empty
  119. r[addr]['comment'] = label.comment
  120. else:
  121. r[addr] = {'mmid': label.mmid, 'comment': label.comment}
  122. @write_mode
  123. async def remove_address(self, addr):
  124. r = self.data_root
  125. if is_coin_addr(self.proto, addr):
  126. have_match = lambda k: k == addr
  127. elif is_mmgen_id(self.proto, addr):
  128. have_match = lambda k: r[k]['mmid'] == addr
  129. else:
  130. die(1, f'{addr!r} is not an Ethereum address or MMGen ID')
  131. for k in r:
  132. if have_match(k):
  133. # return the addr resolved to mmid if possible
  134. ret = r[k]['mmid'] if is_mmgen_id(self.proto, r[k]['mmid']) else addr
  135. del r[k]
  136. self.write()
  137. return ret
  138. msg(f'Address {addr!r} not found in {self.data_root_desc!r} section of tracking wallet')
  139. return None
  140. @write_mode
  141. async def set_label(self, coinaddr, lbl):
  142. for addr, d in list(self.data_root.items()):
  143. if addr == coinaddr:
  144. d['comment'] = lbl.comment
  145. self.write()
  146. return True
  147. msg(f'Address {coinaddr!r} not found in {self.data_root_desc!r} section of tracking wallet')
  148. return False
  149. @property
  150. def sorted_list(self):
  151. return sorted([{
  152. 'addr': x[0],
  153. 'mmid': x[1]['mmid'],
  154. 'comment': x[1]['comment']
  155. } for x in self.data_root.items() if x[0] not in ('params', 'coin')],
  156. key = lambda x: x['mmid'].sort_key + x['addr'])
  157. @property
  158. def mmid_ordered_dict(self):
  159. return dict((x['mmid'], {'addr': x['addr'], 'comment': x['comment']}) for x in self.sorted_list)
  160. async def get_label_addr_pairs(self):
  161. return [label_addr_pair(
  162. TwLabel(self.proto, f"{mmid} {d['comment']}"),
  163. CoinAddr(self.proto, d['addr'])
  164. ) for mmid, d in self.mmid_ordered_dict.items()]
  165. @cached_property
  166. def used_addrs(self):
  167. from decimal import Decimal
  168. # TODO: for now, consider used addrs to be addrs with balance
  169. return ({k for k, v in self.data['addresses'].items() if Decimal(v.get('balance', 0))})
  170. @property
  171. def data_root(self):
  172. return self.data[self.data_key]
  173. @property
  174. def data_root_desc(self):
  175. return self.data_key
  176. def cache_balance(self, addr, bal, *, session_cache, data_root, force=False):
  177. if force or addr not in session_cache:
  178. session_cache[addr] = str(bal)
  179. if addr in data_root:
  180. data_root[addr]['balance'] = str(bal)
  181. if self.aggressive_sync:
  182. self.write()
  183. def get_cached_balance(self, addr, session_cache, data_root):
  184. if addr in session_cache:
  185. return self.proto.coin_amt(session_cache[addr])
  186. if self.use_cached_balances:
  187. return self.proto.coin_amt(
  188. data_root[addr]['balance'] if addr in data_root and 'balance' in data_root[addr]
  189. else '0')
  190. async def get_balance(self, addr, *, force_rpc=False, block='latest'):
  191. ret = None if force_rpc else self.get_cached_balance(addr, self.cur_balances, self.data_root)
  192. if ret is None:
  193. ret = await self.rpc_get_balance(addr, block=block)
  194. if ret is not None:
  195. self.cache_balance(addr, ret, session_cache=self.cur_balances, data_root=self.data_root)
  196. return ret
  197. def force_write(self):
  198. mode_save = self.mode
  199. self.mode = 'w'
  200. self.write()
  201. self.mode = mode_save
  202. @write_mode
  203. def write_changed(self, data, quiet):
  204. from ..fileutil import write_data_to_file
  205. write_data_to_file(
  206. self.cfg,
  207. self.tw_path,
  208. data,
  209. desc = f'{self.base_desc} data',
  210. ask_overwrite = False,
  211. ignore_opt_outdir = True,
  212. quiet = quiet,
  213. check_data = True, # die if wallet has been altered by another program
  214. cmp_data = self.orig_data)
  215. self.orig_data = data
  216. def write(self, *, quiet=True):
  217. self.cfg._util.dmsg(f'write(): checking if {self.desc} data has changed')
  218. wdata = json.dumps(self.data)
  219. if self.orig_data != wdata:
  220. self.write_changed(wdata, quiet=quiet)
  221. elif self.cfg.debug:
  222. msg('Data is unchanged\n')