tSeparate JsonDB and WalletDB - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit 149cd9598a0f9d0e4481e41cecbab23a28fe62c7 DIR parent 8118bd1d727b0b36045f53d2b5aa30295ffa1848 HTML Author: ThomasV <thomasv@electrum.org> Date: Mon, 3 Feb 2020 12:35:50 +0100 Separate JsonDB and WalletDB Diffstat: M electrum/json_db.py | 943 +------------------------------ M electrum/lnwatcher.py | 4 ++-- M electrum/storage.py | 9 ++++----- M electrum/tests/test_wallet.py | 6 +++--- A electrum/wallet_db.py | 925 +++++++++++++++++++++++++++++++ 5 files changed, 951 insertions(+), 936 deletions(-) --- DIR diff --git a/electrum/json_db.py b/electrum/json_db.py t@@ -1,72 +1,32 @@ -#!/usr/bin/env python -# -# Electrum - lightweight Bitcoin client -# Copyright (C) 2015 Thomas Voegtlin -# -# Permission is hereby granted, free of charge, to any person -# obtaining a copy of this software and associated documentation files -# (the "Software"), to deal in the Software without restriction, -# including without limitation the rights to use, copy, modify, merge, -# publish, distribute, sublicense, and/or sell copies of the Software, -# and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS -# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN -# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -import os -import ast -import json -import copy import threading -from collections import defaultdict -from typing import Dict, Optional, List, Tuple, Set, Iterable, NamedTuple, Sequence - -from . import util, bitcoin -from .util import profiler, WalletFileException, multisig_type, TxMinedInfo, bfh -from .keystore import bip44_derivation -from .transaction import Transaction, TxOutpoint, tx_from_any, PartialTransaction +import copy +import json +from . import util from .logging import Logger -# seed_version is now used for the version of the wallet file - -OLD_SEED_VERSION = 4 # electrum versions < 2.0 -NEW_SEED_VERSION = 11 # electrum versions >= 2.0 -FINAL_SEED_VERSION = 23 # electrum >= 2.7 will set this to prevent - # old versions from overwriting new format - - JsonDBJsonEncoder = util.MyEncoder +def modifier(func): + def wrapper(self, *args, **kwargs): + with self.lock: + self._modified = True + return func(self, *args, **kwargs) + return wrapper -class TxFeesValue(NamedTuple): - fee: Optional[int] = None - is_calculated_by_us: bool = False - num_inputs: Optional[int] = None +def locked(func): + def wrapper(self, *args, **kwargs): + with self.lock: + return func(self, *args, **kwargs) + return wrapper class JsonDB(Logger): - - def __init__(self, raw, *, manual_upgrades: bool): + + def __init__(self, data): Logger.__init__(self) self.lock = threading.RLock() - self.data = {} + self.data = data self._modified = False - self._manual_upgrades = manual_upgrades - self._called_after_upgrade_tasks = False - if raw: # loading existing db - self.load_data(raw) - else: # creating new db - self.put('seed_version', FINAL_SEED_VERSION) - self._after_upgrade_tasks() def set_modified(self, b): with self.lock: t@@ -75,19 +35,6 @@ class JsonDB(Logger): def modified(self): return self._modified - def modifier(func): - def wrapper(self, *args, **kwargs): - with self.lock: - self._modified = True - return func(self, *args, **kwargs) - return wrapper - - def locked(func): - def wrapper(self, *args, **kwargs): - with self.lock: - return func(self, *args, **kwargs) - return wrapper - @locked def get(self, key, default=None): v = self.data.get(key) t@@ -120,859 +67,3 @@ class JsonDB(Logger): @locked def dump(self): return json.dumps(self.data, indent=4, sort_keys=True, cls=JsonDBJsonEncoder) - - def load_data(self, s): - try: - self.data = json.loads(s) - except: - try: - d = ast.literal_eval(s) - labels = d.get('labels', {}) - except Exception as e: - raise IOError("Cannot read wallet file") - self.data = {} - for key, value in d.items(): - try: - json.dumps(key) - json.dumps(value) - except: - self.logger.info(f'Failed to convert label to json format: {key}') - continue - self.data[key] = value - if not isinstance(self.data, dict): - raise WalletFileException("Malformed wallet file (not dict)") - - if not self._manual_upgrades and self.requires_split(): - raise WalletFileException("This wallet has multiple accounts and must be split") - - if not self.requires_upgrade(): - self._after_upgrade_tasks() - elif not self._manual_upgrades: - self.upgrade() - - def requires_split(self): - d = self.get('accounts', {}) - return len(d) > 1 - - def split_accounts(self): - result = [] - # backward compatibility with old wallets - d = self.get('accounts', {}) - if len(d) < 2: - return - wallet_type = self.get('wallet_type') - if wallet_type == 'old': - assert len(d) == 2 - data1 = copy.deepcopy(self.data) - data1['accounts'] = {'0': d['0']} - data1['suffix'] = 'deterministic' - data2 = copy.deepcopy(self.data) - data2['accounts'] = {'/x': d['/x']} - data2['seed'] = None - data2['seed_version'] = None - data2['master_public_key'] = None - data2['wallet_type'] = 'imported' - data2['suffix'] = 'imported' - result = [data1, data2] - - elif wallet_type in ['bip44', 'trezor', 'keepkey', 'ledger', 'btchip', 'digitalbitbox', 'safe_t']: - mpk = self.get('master_public_keys') - for k in d.keys(): - i = int(k) - x = d[k] - if x.get("pending"): - continue - xpub = mpk["x/%d'"%i] - new_data = copy.deepcopy(self.data) - # save account, derivation and xpub at index 0 - new_data['accounts'] = {'0': x} - new_data['master_public_keys'] = {"x/0'": xpub} - new_data['derivation'] = bip44_derivation(k) - new_data['suffix'] = k - result.append(new_data) - else: - raise WalletFileException("This wallet has multiple accounts and must be split") - return result - - def requires_upgrade(self): - return self.get_seed_version() < FINAL_SEED_VERSION - - @profiler - def upgrade(self): - self.logger.info('upgrading wallet format') - if self._called_after_upgrade_tasks: - # we need strict ordering between upgrade() and after_upgrade_tasks() - raise Exception("'after_upgrade_tasks' must NOT be called before 'upgrade'") - self._convert_imported() - self._convert_wallet_type() - self._convert_account() - self._convert_version_13_b() - self._convert_version_14() - self._convert_version_15() - self._convert_version_16() - self._convert_version_17() - self._convert_version_18() - self._convert_version_19() - self._convert_version_20() - self._convert_version_21() - self._convert_version_22() - self._convert_version_23() - self.put('seed_version', FINAL_SEED_VERSION) # just to be sure - - self._after_upgrade_tasks() - - def _after_upgrade_tasks(self): - self._called_after_upgrade_tasks = True - self._load_transactions() - - def _convert_wallet_type(self): - if not self._is_upgrade_method_needed(0, 13): - return - - wallet_type = self.get('wallet_type') - if wallet_type == 'btchip': wallet_type = 'ledger' - if self.get('keystore') or self.get('x1/') or wallet_type=='imported': - return False - assert not self.requires_split() - seed_version = self.get_seed_version() - seed = self.get('seed') - xpubs = self.get('master_public_keys') - xprvs = self.get('master_private_keys', {}) - mpk = self.get('master_public_key') - keypairs = self.get('keypairs') - key_type = self.get('key_type') - if seed_version == OLD_SEED_VERSION or wallet_type == 'old': - d = { - 'type': 'old', - 'seed': seed, - 'mpk': mpk, - } - self.put('wallet_type', 'standard') - self.put('keystore', d) - - elif key_type == 'imported': - d = { - 'type': 'imported', - 'keypairs': keypairs, - } - self.put('wallet_type', 'standard') - self.put('keystore', d) - - elif wallet_type in ['xpub', 'standard']: - xpub = xpubs["x/"] - xprv = xprvs.get("x/") - d = { - 'type': 'bip32', - 'xpub': xpub, - 'xprv': xprv, - 'seed': seed, - } - self.put('wallet_type', 'standard') - self.put('keystore', d) - - elif wallet_type in ['bip44']: - xpub = xpubs["x/0'"] - xprv = xprvs.get("x/0'") - d = { - 'type': 'bip32', - 'xpub': xpub, - 'xprv': xprv, - } - self.put('wallet_type', 'standard') - self.put('keystore', d) - - elif wallet_type in ['trezor', 'keepkey', 'ledger', 'digitalbitbox', 'safe_t']: - xpub = xpubs["x/0'"] - derivation = self.get('derivation', bip44_derivation(0)) - d = { - 'type': 'hardware', - 'hw_type': wallet_type, - 'xpub': xpub, - 'derivation': derivation, - } - self.put('wallet_type', 'standard') - self.put('keystore', d) - - elif (wallet_type == '2fa') or multisig_type(wallet_type): - for key in xpubs.keys(): - d = { - 'type': 'bip32', - 'xpub': xpubs[key], - 'xprv': xprvs.get(key), - } - if key == 'x1/' and seed: - d['seed'] = seed - self.put(key, d) - else: - raise WalletFileException('Unable to tell wallet type. Is this even a wallet file?') - # remove junk - self.put('master_public_key', None) - self.put('master_public_keys', None) - self.put('master_private_keys', None) - self.put('derivation', None) - self.put('seed', None) - self.put('keypairs', None) - self.put('key_type', None) - - def _convert_version_13_b(self): - # version 13 is ambiguous, and has an earlier and a later structure - if not self._is_upgrade_method_needed(0, 13): - return - - if self.get('wallet_type') == 'standard': - if self.get('keystore').get('type') == 'imported': - pubkeys = self.get('keystore').get('keypairs').keys() - d = {'change': []} - receiving_addresses = [] - for pubkey in pubkeys: - addr = bitcoin.pubkey_to_address('p2pkh', pubkey) - receiving_addresses.append(addr) - d['receiving'] = receiving_addresses - self.put('addresses', d) - self.put('pubkeys', None) - - self.put('seed_version', 13) - - def _convert_version_14(self): - # convert imported wallets for 3.0 - if not self._is_upgrade_method_needed(13, 13): - return - - if self.get('wallet_type') =='imported': - addresses = self.get('addresses') - if type(addresses) is list: - addresses = dict([(x, None) for x in addresses]) - self.put('addresses', addresses) - elif self.get('wallet_type') == 'standard': - if self.get('keystore').get('type')=='imported': - addresses = set(self.get('addresses').get('receiving')) - pubkeys = self.get('keystore').get('keypairs').keys() - assert len(addresses) == len(pubkeys) - d = {} - for pubkey in pubkeys: - addr = bitcoin.pubkey_to_address('p2pkh', pubkey) - assert addr in addresses - d[addr] = { - 'pubkey': pubkey, - 'redeem_script': None, - 'type': 'p2pkh' - } - self.put('addresses', d) - self.put('pubkeys', None) - self.put('wallet_type', 'imported') - self.put('seed_version', 14) - - def _convert_version_15(self): - if not self._is_upgrade_method_needed(14, 14): - return - if self.get('seed_type') == 'segwit': - # should not get here; get_seed_version should have caught this - raise Exception('unsupported derivation (development segwit, v14)') - self.put('seed_version', 15) - - def _convert_version_16(self): - # fixes issue #3193 for Imported_Wallets with addresses - # also, previous versions allowed importing any garbage as an address - # which we now try to remove, see pr #3191 - if not self._is_upgrade_method_needed(15, 15): - return - - def remove_address(addr): - def remove_from_dict(dict_name): - d = self.get(dict_name, None) - if d is not None: - d.pop(addr, None) - self.put(dict_name, d) - - def remove_from_list(list_name): - lst = self.get(list_name, None) - if lst is not None: - s = set(lst) - s -= {addr} - self.put(list_name, list(s)) - - # note: we don't remove 'addr' from self.get('addresses') - remove_from_dict('addr_history') - remove_from_dict('labels') - remove_from_dict('payment_requests') - remove_from_list('frozen_addresses') - - if self.get('wallet_type') == 'imported': - addresses = self.get('addresses') - assert isinstance(addresses, dict) - addresses_new = dict() - for address, details in addresses.items(): - if not bitcoin.is_address(address): - remove_address(address) - continue - if details is None: - addresses_new[address] = {} - else: - addresses_new[address] = details - self.put('addresses', addresses_new) - - self.put('seed_version', 16) - - def _convert_version_17(self): - # delete pruned_txo; construct spent_outpoints - if not self._is_upgrade_method_needed(16, 16): - return - - self.put('pruned_txo', None) - - transactions = self.get('transactions', {}) # txid -> raw_tx - spent_outpoints = defaultdict(dict) - for txid, raw_tx in transactions.items(): - tx = Transaction(raw_tx) - for txin in tx.inputs(): - if txin.is_coinbase_input(): - continue - prevout_hash = txin.prevout.txid.hex() - prevout_n = txin.prevout.out_idx - spent_outpoints[prevout_hash][str(prevout_n)] = txid - self.put('spent_outpoints', spent_outpoints) - - self.put('seed_version', 17) - - def _convert_version_18(self): - # delete verified_tx3 as its structure changed - if not self._is_upgrade_method_needed(17, 17): - return - self.put('verified_tx3', None) - self.put('seed_version', 18) - - def _convert_version_19(self): - # delete tx_fees as its structure changed - if not self._is_upgrade_method_needed(18, 18): - return - self.put('tx_fees', None) - self.put('seed_version', 19) - - def _convert_version_20(self): - # store 'derivation' (prefix) and 'root_fingerprint' in all xpub-based keystores. - # store explicit None values if we cannot retroactively determine them - if not self._is_upgrade_method_needed(19, 19): - return - - from .bip32 import BIP32Node, convert_bip32_intpath_to_strpath - # note: This upgrade method reimplements bip32.root_fp_and_der_prefix_from_xkey. - # This is done deliberately, to avoid introducing that method as a dependency to this upgrade. - for ks_name in ('keystore', *['x{}/'.format(i) for i in range(1, 16)]): - ks = self.get(ks_name, None) - if ks is None: continue - xpub = ks.get('xpub', None) - if xpub is None: continue - bip32node = BIP32Node.from_xkey(xpub) - # derivation prefix - derivation_prefix = ks.get('derivation', None) - if derivation_prefix is None: - assert bip32node.depth >= 0, bip32node.depth - if bip32node.depth == 0: - derivation_prefix = 'm' - elif bip32node.depth == 1: - child_number_int = int.from_bytes(bip32node.child_number, 'big') - derivation_prefix = convert_bip32_intpath_to_strpath([child_number_int]) - ks['derivation'] = derivation_prefix - # root fingerprint - root_fingerprint = ks.get('ckcc_xfp', None) - if root_fingerprint is not None: - root_fingerprint = root_fingerprint.to_bytes(4, byteorder="little", signed=False).hex().lower() - if root_fingerprint is None: - if bip32node.depth == 0: - root_fingerprint = bip32node.calc_fingerprint_of_this_node().hex().lower() - elif bip32node.depth == 1: - root_fingerprint = bip32node.fingerprint.hex() - ks['root_fingerprint'] = root_fingerprint - ks.pop('ckcc_xfp', None) - self.put(ks_name, ks) - - self.put('seed_version', 20) - - def _convert_version_21(self): - if not self._is_upgrade_method_needed(20, 20): - return - channels = self.get('channels') - if channels: - for channel in channels: - channel['state'] = 'OPENING' - self.put('channels', channels) - self.put('seed_version', 21) - - def _convert_version_22(self): - # construct prevouts_by_scripthash - if not self._is_upgrade_method_needed(21, 21): - return - - from .bitcoin import script_to_scripthash - transactions = self.get('transactions', {}) # txid -> raw_tx - prevouts_by_scripthash = defaultdict(list) - for txid, raw_tx in transactions.items(): - tx = Transaction(raw_tx) - for idx, txout in enumerate(tx.outputs()): - outpoint = f"{txid}:{idx}" - scripthash = script_to_scripthash(txout.scriptpubkey.hex()) - prevouts_by_scripthash[scripthash].append((outpoint, txout.value)) - self.put('prevouts_by_scripthash', prevouts_by_scripthash) - - self.put('seed_version', 22) - - def _convert_version_23(self): - if not self._is_upgrade_method_needed(22, 22): - return - channels = self.get('channels', []) - LOCAL = 1 - REMOTE = -1 - for c in channels: - # move revocation store from remote_config - r = c['remote_config'].pop('revocation_store') - c['revocation_store'] = r - # convert fee updates - log = c.get('log', {}) - for sub in LOCAL, REMOTE: - l = log[str(sub)]['fee_updates'] - d = {} - for i, fu in enumerate(l): - d[str(i)] = { - 'rate':fu['rate'], - 'ctn_local':fu['ctns'][str(LOCAL)], - 'ctn_remote':fu['ctns'][str(REMOTE)] - } - log[str(int(sub))]['fee_updates'] = d - self.data['channels'] = channels - - self.data['seed_version'] = 23 - - - def _convert_imported(self): - if not self._is_upgrade_method_needed(0, 13): - return - - # '/x' is the internal ID for imported accounts - d = self.get('accounts', {}).get('/x', {}).get('imported',{}) - if not d: - return False - addresses = [] - keypairs = {} - for addr, v in d.items(): - pubkey, privkey = v - if privkey: - keypairs[pubkey] = privkey - else: - addresses.append(addr) - if addresses and keypairs: - raise WalletFileException('mixed addresses and privkeys') - elif addresses: - self.put('addresses', addresses) - self.put('accounts', None) - elif keypairs: - self.put('wallet_type', 'standard') - self.put('key_type', 'imported') - self.put('keypairs', keypairs) - self.put('accounts', None) - else: - raise WalletFileException('no addresses or privkeys') - - def _convert_account(self): - if not self._is_upgrade_method_needed(0, 13): - return - self.put('accounts', None) - - def _is_upgrade_method_needed(self, min_version, max_version): - assert min_version <= max_version - cur_version = self.get_seed_version() - if cur_version > max_version: - return False - elif cur_version < min_version: - raise WalletFileException( - 'storage upgrade: unexpected version {} (should be {}-{})' - .format(cur_version, min_version, max_version)) - else: - return True - - @locked - def get_seed_version(self): - seed_version = self.get('seed_version') - if not seed_version: - seed_version = OLD_SEED_VERSION if len(self.get('master_public_key','')) == 128 else NEW_SEED_VERSION - if seed_version > FINAL_SEED_VERSION: - raise WalletFileException('This version of Electrum is too old to open this wallet.\n' - '(highest supported storage version: {}, version of this file: {})' - .format(FINAL_SEED_VERSION, seed_version)) - if seed_version==14 and self.get('seed_type') == 'segwit': - self._raise_unsupported_version(seed_version) - if seed_version >=12: - return seed_version - if seed_version not in [OLD_SEED_VERSION, NEW_SEED_VERSION]: - self._raise_unsupported_version(seed_version) - return seed_version - - def _raise_unsupported_version(self, seed_version): - msg = f"Your wallet has an unsupported seed version: {seed_version}." - if seed_version in [5, 7, 8, 9, 10, 14]: - msg += "\n\nTo open this wallet, try 'git checkout seed_v%d'"%seed_version - if seed_version == 6: - # version 1.9.8 created v6 wallets when an incorrect seed was entered in the restore dialog - msg += '\n\nThis file was created because of a bug in version 1.9.8.' - if self.get('master_public_keys') is None and self.get('master_private_keys') is None and self.get('imported_keys') is None: - # pbkdf2 (at that time an additional dependency) was not included with the binaries, and wallet creation aborted. - msg += "\nIt does not contain any keys, and can safely be removed." - else: - # creation was complete if electrum was run from source - msg += "\nPlease open this file with Electrum 1.9.8, and move your coins to a new wallet." - raise WalletFileException(msg) - - @locked - def get_txi_addresses(self, tx_hash) -> List[str]: - """Returns list of is_mine addresses that appear as inputs in tx.""" - return list(self.txi.get(tx_hash, {}).keys()) - - @locked - def get_txo_addresses(self, tx_hash) -> List[str]: - """Returns list of is_mine addresses that appear as outputs in tx.""" - return list(self.txo.get(tx_hash, {}).keys()) - - @locked - def get_txi_addr(self, tx_hash, address) -> Iterable[Tuple[str, int]]: - """Returns an iterable of (prev_outpoint, value).""" - return self.txi.get(tx_hash, {}).get(address, []).copy() - - @locked - def get_txo_addr(self, tx_hash, address) -> Iterable[Tuple[int, int, bool]]: - """Returns an iterable of (output_index, value, is_coinbase).""" - return self.txo.get(tx_hash, {}).get(address, []).copy() - - @modifier - def add_txi_addr(self, tx_hash, addr, ser, v): - if tx_hash not in self.txi: - self.txi[tx_hash] = {} - d = self.txi[tx_hash] - if addr not in d: - # note that as this is a set, we can ignore "duplicates" - d[addr] = set() - d[addr].add((ser, v)) - - @modifier - def add_txo_addr(self, tx_hash, addr, n, v, is_coinbase): - if tx_hash not in self.txo: - self.txo[tx_hash] = {} - d = self.txo[tx_hash] - if addr not in d: - # note that as this is a set, we can ignore "duplicates" - d[addr] = set() - d[addr].add((n, v, is_coinbase)) - - @locked - def list_txi(self): - return list(self.txi.keys()) - - @locked - def list_txo(self): - return list(self.txo.keys()) - - @modifier - def remove_txi(self, tx_hash): - self.txi.pop(tx_hash, None) - - @modifier - def remove_txo(self, tx_hash): - self.txo.pop(tx_hash, None) - - @locked - def list_spent_outpoints(self): - return [(h, n) - for h in self.spent_outpoints.keys() - for n in self.get_spent_outpoints(h) - ] - - @locked - def get_spent_outpoints(self, prevout_hash): - return list(self.spent_outpoints.get(prevout_hash, {}).keys()) - - @locked - def get_spent_outpoint(self, prevout_hash, prevout_n): - prevout_n = str(prevout_n) - return self.spent_outpoints.get(prevout_hash, {}).get(prevout_n) - - @modifier - def remove_spent_outpoint(self, prevout_hash, prevout_n): - prevout_n = str(prevout_n) - self.spent_outpoints[prevout_hash].pop(prevout_n, None) - if not self.spent_outpoints[prevout_hash]: - self.spent_outpoints.pop(prevout_hash) - - @modifier - def set_spent_outpoint(self, prevout_hash, prevout_n, tx_hash): - prevout_n = str(prevout_n) - if prevout_hash not in self.spent_outpoints: - self.spent_outpoints[prevout_hash] = {} - self.spent_outpoints[prevout_hash][prevout_n] = tx_hash - - @modifier - def add_prevout_by_scripthash(self, scripthash: str, *, prevout: TxOutpoint, value: int) -> None: - assert isinstance(prevout, TxOutpoint) - if scripthash not in self._prevouts_by_scripthash: - self._prevouts_by_scripthash[scripthash] = set() - self._prevouts_by_scripthash[scripthash].add((prevout.to_str(), value)) - - @modifier - def remove_prevout_by_scripthash(self, scripthash: str, *, prevout: TxOutpoint, value: int) -> None: - assert isinstance(prevout, TxOutpoint) - self._prevouts_by_scripthash[scripthash].discard((prevout.to_str(), value)) - if not self._prevouts_by_scripthash[scripthash]: - self._prevouts_by_scripthash.pop(scripthash) - - @locked - def get_prevouts_by_scripthash(self, scripthash: str) -> Set[Tuple[TxOutpoint, int]]: - prevouts_and_values = self._prevouts_by_scripthash.get(scripthash, set()) - return {(TxOutpoint.from_str(prevout), value) for prevout, value in prevouts_and_values} - - @modifier - def add_transaction(self, tx_hash: str, tx: Transaction) -> None: - assert isinstance(tx, Transaction), tx - # note that tx might be a PartialTransaction - if not tx_hash: - raise Exception("trying to add tx to db without txid") - if tx_hash != tx.txid(): - raise Exception(f"trying to add tx to db with inconsistent txid: {tx_hash} != {tx.txid()}") - # don't allow overwriting complete tx with partial tx - tx_we_already_have = self.transactions.get(tx_hash, None) - if tx_we_already_have is None or isinstance(tx_we_already_have, PartialTransaction): - self.transactions[tx_hash] = tx - - @modifier - def remove_transaction(self, tx_hash) -> Optional[Transaction]: - return self.transactions.pop(tx_hash, None) - - @locked - def get_transaction(self, tx_hash: str) -> Optional[Transaction]: - return self.transactions.get(tx_hash) - - @locked - def list_transactions(self): - return list(self.transactions.keys()) - - @locked - def get_history(self): - return list(self.history.keys()) - - def is_addr_in_history(self, addr): - # does not mean history is non-empty! - return addr in self.history - - @locked - def get_addr_history(self, addr): - return self.history.get(addr, []) - - @modifier - def set_addr_history(self, addr, hist): - self.history[addr] = hist - - @modifier - def remove_addr_history(self, addr): - self.history.pop(addr, None) - - @locked - def list_verified_tx(self): - return list(self.verified_tx.keys()) - - @locked - def get_verified_tx(self, txid): - if txid not in self.verified_tx: - return None - height, timestamp, txpos, header_hash = self.verified_tx[txid] - return TxMinedInfo(height=height, - conf=None, - timestamp=timestamp, - txpos=txpos, - header_hash=header_hash) - - @modifier - def add_verified_tx(self, txid, info): - self.verified_tx[txid] = (info.height, info.timestamp, info.txpos, info.header_hash) - - @modifier - def remove_verified_tx(self, txid): - self.verified_tx.pop(txid, None) - - def is_in_verified_tx(self, txid): - return txid in self.verified_tx - - @modifier - def add_tx_fee_from_server(self, txid: str, fee_sat: Optional[int]) -> None: - # note: when called with (fee_sat is None), rm currently saved value - if txid not in self.tx_fees: - self.tx_fees[txid] = TxFeesValue() - tx_fees_value = self.tx_fees[txid] - if tx_fees_value.is_calculated_by_us: - return - self.tx_fees[txid] = tx_fees_value._replace(fee=fee_sat, is_calculated_by_us=False) - - @modifier - def add_tx_fee_we_calculated(self, txid: str, fee_sat: Optional[int]) -> None: - if fee_sat is None: - return - if txid not in self.tx_fees: - self.tx_fees[txid] = TxFeesValue() - self.tx_fees[txid] = self.tx_fees[txid]._replace(fee=fee_sat, is_calculated_by_us=True) - - @locked - def get_tx_fee(self, txid: str, *, trust_server=False) -> Optional[int]: - """Returns tx_fee.""" - tx_fees_value = self.tx_fees.get(txid) - if tx_fees_value is None: - return None - if not trust_server and not tx_fees_value.is_calculated_by_us: - return None - return tx_fees_value.fee - - @modifier - def add_num_inputs_to_tx(self, txid: str, num_inputs: int) -> None: - if txid not in self.tx_fees: - self.tx_fees[txid] = TxFeesValue() - self.tx_fees[txid] = self.tx_fees[txid]._replace(num_inputs=num_inputs) - - @locked - def get_num_all_inputs_of_tx(self, txid: str) -> Optional[int]: - tx_fees_value = self.tx_fees.get(txid) - if tx_fees_value is None: - return None - return tx_fees_value.num_inputs - - @locked - def get_num_ismine_inputs_of_tx(self, txid: str) -> int: - txins = self.txi.get(txid, {}) - return sum([len(tupls) for addr, tupls in txins.items()]) - - @modifier - def remove_tx_fee(self, txid): - self.tx_fees.pop(txid, None) - - @locked - def get_data_ref(self, name): - # Warning: interacts un-intuitively with 'put': certain parts - # of 'data' will have pointers saved as separate variables. - if name not in self.data: - self.data[name] = {} - return self.data[name] - - @locked - def num_change_addresses(self): - return len(self.change_addresses) - - @locked - def num_receiving_addresses(self): - return len(self.receiving_addresses) - - @locked - def get_change_addresses(self, *, slice_start=None, slice_stop=None): - # note: slicing makes a shallow copy - return self.change_addresses[slice_start:slice_stop] - - @locked - def get_receiving_addresses(self, *, slice_start=None, slice_stop=None): - # note: slicing makes a shallow copy - return self.receiving_addresses[slice_start:slice_stop] - - @modifier - def add_change_address(self, addr): - self._addr_to_addr_index[addr] = (1, len(self.change_addresses)) - self.change_addresses.append(addr) - - @modifier - def add_receiving_address(self, addr): - self._addr_to_addr_index[addr] = (0, len(self.receiving_addresses)) - self.receiving_addresses.append(addr) - - @locked - def get_address_index(self, address) -> Optional[Sequence[int]]: - return self._addr_to_addr_index.get(address) - - @modifier - def add_imported_address(self, addr, d): - self.imported_addresses[addr] = d - - @modifier - def remove_imported_address(self, addr): - self.imported_addresses.pop(addr) - - @locked - def has_imported_address(self, addr: str) -> bool: - return addr in self.imported_addresses - - @locked - def get_imported_addresses(self) -> Sequence[str]: - return list(sorted(self.imported_addresses.keys())) - - @locked - def get_imported_address(self, addr): - return self.imported_addresses.get(addr) - - def load_addresses(self, wallet_type): - """ called from Abstract_Wallet.__init__ """ - if wallet_type == 'imported': - self.imported_addresses = self.get_data_ref('addresses') # type: Dict[str, dict] - else: - self.get_data_ref('addresses') - for name in ['receiving', 'change']: - if name not in self.data['addresses']: - self.data['addresses'][name] = [] - self.change_addresses = self.data['addresses']['change'] - self.receiving_addresses = self.data['addresses']['receiving'] - self._addr_to_addr_index = {} # type: Dict[str, Sequence[int]] # key: address, value: (is_change, index) - for i, addr in enumerate(self.receiving_addresses): - self._addr_to_addr_index[addr] = (0, i) - for i, addr in enumerate(self.change_addresses): - self._addr_to_addr_index[addr] = (1, i) - - @profiler - def _load_transactions(self): - # references in self.data - # TODO make all these private - # txid -> address -> set of (prev_outpoint, value) - self.txi = self.get_data_ref('txi') # type: Dict[str, Dict[str, Set[Tuple[str, int]]]] - # txid -> address -> set of (output_index, value, is_coinbase) - self.txo = self.get_data_ref('txo') # type: Dict[str, Dict[str, Set[Tuple[int, int, bool]]]] - self.transactions = self.get_data_ref('transactions') # type: Dict[str, Transaction] - self.spent_outpoints = self.get_data_ref('spent_outpoints') # txid -> output_index -> next_txid - self.history = self.get_data_ref('addr_history') # address -> list of (txid, height) - self.verified_tx = self.get_data_ref('verified_tx3') # txid -> (height, timestamp, txpos, header_hash) - self.tx_fees = self.get_data_ref('tx_fees') # type: Dict[str, TxFeesValue] - # scripthash -> set of (outpoint, value) - self._prevouts_by_scripthash = self.get_data_ref('prevouts_by_scripthash') # type: Dict[str, Set[Tuple[str, int]]] - # convert raw transactions to Transaction objects - for tx_hash, raw_tx in self.transactions.items(): - # note: for performance, "deserialize=False" so that we will deserialize these on-demand - self.transactions[tx_hash] = tx_from_any(raw_tx, deserialize=False) - # convert txi, txo: list to set - for t in self.txi, self.txo: - for d in t.values(): - for addr, lst in d.items(): - d[addr] = set([tuple(x) for x in lst]) - # convert prevouts_by_scripthash: list to set, list to tuple - for scripthash, lst in self._prevouts_by_scripthash.items(): - self._prevouts_by_scripthash[scripthash] = {(prevout, value) for prevout, value in lst} - # remove unreferenced tx - for tx_hash in list(self.transactions.keys()): - if not self.get_txi_addresses(tx_hash) and not self.get_txo_addresses(tx_hash): - self.logger.info(f"removing unreferenced tx: {tx_hash}") - self.transactions.pop(tx_hash) - # remove unreferenced outpoints - for prevout_hash in self.spent_outpoints.keys(): - d = self.spent_outpoints[prevout_hash] - for prevout_n, spending_txid in list(d.items()): - if spending_txid not in self.transactions: - self.logger.info("removing unreferenced spent outpoint") - d.pop(prevout_n) - # convert tx_fees tuples to NamedTuples - for tx_hash, tuple_ in self.tx_fees.items(): - self.tx_fees[tx_hash] = TxFeesValue(*tuple_) - - @modifier - def clear_history(self): - self.txi.clear() - self.txo.clear() - self.spent_outpoints.clear() - self.transactions.clear() - self.history.clear() - self.verified_tx.clear() - self.tx_fees.clear() DIR diff --git a/electrum/lnwatcher.py b/electrum/lnwatcher.py t@@ -13,7 +13,7 @@ from enum import IntEnum, auto from typing import NamedTuple, Dict from .sql_db import SqlDB, sql -from .json_db import JsonDB +from .wallet_db import WalletDB from .util import bh2u, bfh, log_exceptions, ignore_exceptions from . import wallet from .storage import WalletStorage t@@ -139,7 +139,7 @@ class LNWatcher(AddressSynchronizer): LOGGING_SHORTCUT = 'W' def __init__(self, network: 'Network'): - AddressSynchronizer.__init__(self, JsonDB({}, manual_upgrades=False)) + AddressSynchronizer.__init__(self, WalletDB({}, manual_upgrades=False)) self.config = network.config self.channels = {} self.network = network DIR diff --git a/electrum/storage.py b/electrum/storage.py t@@ -34,7 +34,7 @@ from . import ecc from .util import profiler, InvalidPassword, WalletFileException, bfh, standardize_path from .plugin import run_hook, plugin_loaders -from .json_db import JsonDB +from .wallet_db import WalletDB from .logging import Logger t@@ -62,7 +62,6 @@ class WalletStorage(Logger): self._file_exists = bool(self.path and os.path.exists(self.path)) self._manual_upgrades = manual_upgrades - DB_Class = JsonDB self.logger.info(f"wallet path {self.path}") self.pubkey = None self._test_read_write_permissions(self.path) t@@ -71,12 +70,12 @@ class WalletStorage(Logger): self.raw = f.read() self._encryption_version = self._init_encryption_version() if not self.is_encrypted(): - self.db = DB_Class(self.raw, manual_upgrades=manual_upgrades) + self.db = WalletDB(self.raw, manual_upgrades=manual_upgrades) self.load_plugins() else: self._encryption_version = StorageEncryptionVersion.PLAINTEXT # avoid new wallets getting 'upgraded' - self.db = DB_Class('', manual_upgrades=False) + self.db = WalletDB('', manual_upgrades=False) @classmethod def _test_read_write_permissions(cls, path): t@@ -214,7 +213,7 @@ class WalletStorage(Logger): s = None self.pubkey = ec_key.get_public_key_hex() s = s.decode('utf8') - self.db = JsonDB(s, manual_upgrades=self._manual_upgrades) + self.db = WalletDB(s, manual_upgrades=self._manual_upgrades) self.load_plugins() def encrypt_before_writing(self, plaintext: str) -> str: DIR diff --git a/electrum/tests/test_wallet.py b/electrum/tests/test_wallet.py t@@ -8,13 +8,13 @@ import time from io import StringIO from electrum.storage import WalletStorage -from electrum.json_db import FINAL_SEED_VERSION +from electrum.wallet_db import FINAL_SEED_VERSION from electrum.wallet import (Abstract_Wallet, Standard_Wallet, create_new_wallet, restore_wallet_from_text, Imported_Wallet) from electrum.exchange_rate import ExchangeBase, FxThread from electrum.util import TxMinedInfo from electrum.bitcoin import COIN -from electrum.json_db import JsonDB +from electrum.wallet_db import WalletDB from electrum.simple_config import SimpleConfig from . import ElectrumTestCase t@@ -100,7 +100,7 @@ class FakeWallet: def __init__(self, fiat_value): super().__init__() self.fiat_value = fiat_value - self.db = JsonDB("{}", manual_upgrades=True) + self.db = WalletDB("{}", manual_upgrades=True) self.db.transactions = self.db.verified_tx = {'abc':'Tx'} def get_tx_height(self, txid): DIR diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py t@@ -0,0 +1,925 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2015 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import os +import ast +import json +import copy +import threading +from collections import defaultdict +from typing import Dict, Optional, List, Tuple, Set, Iterable, NamedTuple, Sequence + +from . import util, bitcoin +from .util import profiler, WalletFileException, multisig_type, TxMinedInfo, bfh +from .keystore import bip44_derivation +from .transaction import Transaction, TxOutpoint, tx_from_any, PartialTransaction +from .json_db import JsonDB, locked, modifier + +# seed_version is now used for the version of the wallet file + +OLD_SEED_VERSION = 4 # electrum versions < 2.0 +NEW_SEED_VERSION = 11 # electrum versions >= 2.0 +FINAL_SEED_VERSION = 23 # electrum >= 2.7 will set this to prevent + # old versions from overwriting new format + + + + +class TxFeesValue(NamedTuple): + fee: Optional[int] = None + is_calculated_by_us: bool = False + num_inputs: Optional[int] = None + + + + + +class WalletDB(JsonDB): + + def __init__(self, raw, *, manual_upgrades: bool): + JsonDB.__init__(self, {}) + self._manual_upgrades = manual_upgrades + self._called_after_upgrade_tasks = False + if raw: # loading existing db + self.load_data(raw) + else: # creating new db + self.put('seed_version', FINAL_SEED_VERSION) + self._after_upgrade_tasks() + + + def load_data(self, s): + try: + self.data = json.loads(s) + except: + try: + d = ast.literal_eval(s) + labels = d.get('labels', {}) + except Exception as e: + raise IOError("Cannot read wallet file") + self.data = {} + for key, value in d.items(): + try: + json.dumps(key) + json.dumps(value) + except: + self.logger.info(f'Failed to convert label to json format: {key}') + continue + self.data[key] = value + if not isinstance(self.data, dict): + raise WalletFileException("Malformed wallet file (not dict)") + + if not self._manual_upgrades and self.requires_split(): + raise WalletFileException("This wallet has multiple accounts and must be split") + + if not self.requires_upgrade(): + self._after_upgrade_tasks() + elif not self._manual_upgrades: + self.upgrade() + + def requires_split(self): + d = self.get('accounts', {}) + return len(d) > 1 + + def split_accounts(self): + result = [] + # backward compatibility with old wallets + d = self.get('accounts', {}) + if len(d) < 2: + return + wallet_type = self.get('wallet_type') + if wallet_type == 'old': + assert len(d) == 2 + data1 = copy.deepcopy(self.data) + data1['accounts'] = {'0': d['0']} + data1['suffix'] = 'deterministic' + data2 = copy.deepcopy(self.data) + data2['accounts'] = {'/x': d['/x']} + data2['seed'] = None + data2['seed_version'] = None + data2['master_public_key'] = None + data2['wallet_type'] = 'imported' + data2['suffix'] = 'imported' + result = [data1, data2] + + elif wallet_type in ['bip44', 'trezor', 'keepkey', 'ledger', 'btchip', 'digitalbitbox', 'safe_t']: + mpk = self.get('master_public_keys') + for k in d.keys(): + i = int(k) + x = d[k] + if x.get("pending"): + continue + xpub = mpk["x/%d'"%i] + new_data = copy.deepcopy(self.data) + # save account, derivation and xpub at index 0 + new_data['accounts'] = {'0': x} + new_data['master_public_keys'] = {"x/0'": xpub} + new_data['derivation'] = bip44_derivation(k) + new_data['suffix'] = k + result.append(new_data) + else: + raise WalletFileException("This wallet has multiple accounts and must be split") + return result + + def requires_upgrade(self): + return self.get_seed_version() < FINAL_SEED_VERSION + + @profiler + def upgrade(self): + self.logger.info('upgrading wallet format') + if self._called_after_upgrade_tasks: + # we need strict ordering between upgrade() and after_upgrade_tasks() + raise Exception("'after_upgrade_tasks' must NOT be called before 'upgrade'") + self._convert_imported() + self._convert_wallet_type() + self._convert_account() + self._convert_version_13_b() + self._convert_version_14() + self._convert_version_15() + self._convert_version_16() + self._convert_version_17() + self._convert_version_18() + self._convert_version_19() + self._convert_version_20() + self._convert_version_21() + self._convert_version_22() + self._convert_version_23() + self.put('seed_version', FINAL_SEED_VERSION) # just to be sure + + self._after_upgrade_tasks() + + def _after_upgrade_tasks(self): + self._called_after_upgrade_tasks = True + self._load_transactions() + + def _convert_wallet_type(self): + if not self._is_upgrade_method_needed(0, 13): + return + + wallet_type = self.get('wallet_type') + if wallet_type == 'btchip': wallet_type = 'ledger' + if self.get('keystore') or self.get('x1/') or wallet_type=='imported': + return False + assert not self.requires_split() + seed_version = self.get_seed_version() + seed = self.get('seed') + xpubs = self.get('master_public_keys') + xprvs = self.get('master_private_keys', {}) + mpk = self.get('master_public_key') + keypairs = self.get('keypairs') + key_type = self.get('key_type') + if seed_version == OLD_SEED_VERSION or wallet_type == 'old': + d = { + 'type': 'old', + 'seed': seed, + 'mpk': mpk, + } + self.put('wallet_type', 'standard') + self.put('keystore', d) + + elif key_type == 'imported': + d = { + 'type': 'imported', + 'keypairs': keypairs, + } + self.put('wallet_type', 'standard') + self.put('keystore', d) + + elif wallet_type in ['xpub', 'standard']: + xpub = xpubs["x/"] + xprv = xprvs.get("x/") + d = { + 'type': 'bip32', + 'xpub': xpub, + 'xprv': xprv, + 'seed': seed, + } + self.put('wallet_type', 'standard') + self.put('keystore', d) + + elif wallet_type in ['bip44']: + xpub = xpubs["x/0'"] + xprv = xprvs.get("x/0'") + d = { + 'type': 'bip32', + 'xpub': xpub, + 'xprv': xprv, + } + self.put('wallet_type', 'standard') + self.put('keystore', d) + + elif wallet_type in ['trezor', 'keepkey', 'ledger', 'digitalbitbox', 'safe_t']: + xpub = xpubs["x/0'"] + derivation = self.get('derivation', bip44_derivation(0)) + d = { + 'type': 'hardware', + 'hw_type': wallet_type, + 'xpub': xpub, + 'derivation': derivation, + } + self.put('wallet_type', 'standard') + self.put('keystore', d) + + elif (wallet_type == '2fa') or multisig_type(wallet_type): + for key in xpubs.keys(): + d = { + 'type': 'bip32', + 'xpub': xpubs[key], + 'xprv': xprvs.get(key), + } + if key == 'x1/' and seed: + d['seed'] = seed + self.put(key, d) + else: + raise WalletFileException('Unable to tell wallet type. Is this even a wallet file?') + # remove junk + self.put('master_public_key', None) + self.put('master_public_keys', None) + self.put('master_private_keys', None) + self.put('derivation', None) + self.put('seed', None) + self.put('keypairs', None) + self.put('key_type', None) + + def _convert_version_13_b(self): + # version 13 is ambiguous, and has an earlier and a later structure + if not self._is_upgrade_method_needed(0, 13): + return + + if self.get('wallet_type') == 'standard': + if self.get('keystore').get('type') == 'imported': + pubkeys = self.get('keystore').get('keypairs').keys() + d = {'change': []} + receiving_addresses = [] + for pubkey in pubkeys: + addr = bitcoin.pubkey_to_address('p2pkh', pubkey) + receiving_addresses.append(addr) + d['receiving'] = receiving_addresses + self.put('addresses', d) + self.put('pubkeys', None) + + self.put('seed_version', 13) + + def _convert_version_14(self): + # convert imported wallets for 3.0 + if not self._is_upgrade_method_needed(13, 13): + return + + if self.get('wallet_type') =='imported': + addresses = self.get('addresses') + if type(addresses) is list: + addresses = dict([(x, None) for x in addresses]) + self.put('addresses', addresses) + elif self.get('wallet_type') == 'standard': + if self.get('keystore').get('type')=='imported': + addresses = set(self.get('addresses').get('receiving')) + pubkeys = self.get('keystore').get('keypairs').keys() + assert len(addresses) == len(pubkeys) + d = {} + for pubkey in pubkeys: + addr = bitcoin.pubkey_to_address('p2pkh', pubkey) + assert addr in addresses + d[addr] = { + 'pubkey': pubkey, + 'redeem_script': None, + 'type': 'p2pkh' + } + self.put('addresses', d) + self.put('pubkeys', None) + self.put('wallet_type', 'imported') + self.put('seed_version', 14) + + def _convert_version_15(self): + if not self._is_upgrade_method_needed(14, 14): + return + if self.get('seed_type') == 'segwit': + # should not get here; get_seed_version should have caught this + raise Exception('unsupported derivation (development segwit, v14)') + self.put('seed_version', 15) + + def _convert_version_16(self): + # fixes issue #3193 for Imported_Wallets with addresses + # also, previous versions allowed importing any garbage as an address + # which we now try to remove, see pr #3191 + if not self._is_upgrade_method_needed(15, 15): + return + + def remove_address(addr): + def remove_from_dict(dict_name): + d = self.get(dict_name, None) + if d is not None: + d.pop(addr, None) + self.put(dict_name, d) + + def remove_from_list(list_name): + lst = self.get(list_name, None) + if lst is not None: + s = set(lst) + s -= {addr} + self.put(list_name, list(s)) + + # note: we don't remove 'addr' from self.get('addresses') + remove_from_dict('addr_history') + remove_from_dict('labels') + remove_from_dict('payment_requests') + remove_from_list('frozen_addresses') + + if self.get('wallet_type') == 'imported': + addresses = self.get('addresses') + assert isinstance(addresses, dict) + addresses_new = dict() + for address, details in addresses.items(): + if not bitcoin.is_address(address): + remove_address(address) + continue + if details is None: + addresses_new[address] = {} + else: + addresses_new[address] = details + self.put('addresses', addresses_new) + + self.put('seed_version', 16) + + def _convert_version_17(self): + # delete pruned_txo; construct spent_outpoints + if not self._is_upgrade_method_needed(16, 16): + return + + self.put('pruned_txo', None) + + transactions = self.get('transactions', {}) # txid -> raw_tx + spent_outpoints = defaultdict(dict) + for txid, raw_tx in transactions.items(): + tx = Transaction(raw_tx) + for txin in tx.inputs(): + if txin.is_coinbase_input(): + continue + prevout_hash = txin.prevout.txid.hex() + prevout_n = txin.prevout.out_idx + spent_outpoints[prevout_hash][str(prevout_n)] = txid + self.put('spent_outpoints', spent_outpoints) + + self.put('seed_version', 17) + + def _convert_version_18(self): + # delete verified_tx3 as its structure changed + if not self._is_upgrade_method_needed(17, 17): + return + self.put('verified_tx3', None) + self.put('seed_version', 18) + + def _convert_version_19(self): + # delete tx_fees as its structure changed + if not self._is_upgrade_method_needed(18, 18): + return + self.put('tx_fees', None) + self.put('seed_version', 19) + + def _convert_version_20(self): + # store 'derivation' (prefix) and 'root_fingerprint' in all xpub-based keystores. + # store explicit None values if we cannot retroactively determine them + if not self._is_upgrade_method_needed(19, 19): + return + + from .bip32 import BIP32Node, convert_bip32_intpath_to_strpath + # note: This upgrade method reimplements bip32.root_fp_and_der_prefix_from_xkey. + # This is done deliberately, to avoid introducing that method as a dependency to this upgrade. + for ks_name in ('keystore', *['x{}/'.format(i) for i in range(1, 16)]): + ks = self.get(ks_name, None) + if ks is None: continue + xpub = ks.get('xpub', None) + if xpub is None: continue + bip32node = BIP32Node.from_xkey(xpub) + # derivation prefix + derivation_prefix = ks.get('derivation', None) + if derivation_prefix is None: + assert bip32node.depth >= 0, bip32node.depth + if bip32node.depth == 0: + derivation_prefix = 'm' + elif bip32node.depth == 1: + child_number_int = int.from_bytes(bip32node.child_number, 'big') + derivation_prefix = convert_bip32_intpath_to_strpath([child_number_int]) + ks['derivation'] = derivation_prefix + # root fingerprint + root_fingerprint = ks.get('ckcc_xfp', None) + if root_fingerprint is not None: + root_fingerprint = root_fingerprint.to_bytes(4, byteorder="little", signed=False).hex().lower() + if root_fingerprint is None: + if bip32node.depth == 0: + root_fingerprint = bip32node.calc_fingerprint_of_this_node().hex().lower() + elif bip32node.depth == 1: + root_fingerprint = bip32node.fingerprint.hex() + ks['root_fingerprint'] = root_fingerprint + ks.pop('ckcc_xfp', None) + self.put(ks_name, ks) + + self.put('seed_version', 20) + + def _convert_version_21(self): + if not self._is_upgrade_method_needed(20, 20): + return + channels = self.get('channels') + if channels: + for channel in channels: + channel['state'] = 'OPENING' + self.put('channels', channels) + self.put('seed_version', 21) + + def _convert_version_22(self): + # construct prevouts_by_scripthash + if not self._is_upgrade_method_needed(21, 21): + return + + from .bitcoin import script_to_scripthash + transactions = self.get('transactions', {}) # txid -> raw_tx + prevouts_by_scripthash = defaultdict(list) + for txid, raw_tx in transactions.items(): + tx = Transaction(raw_tx) + for idx, txout in enumerate(tx.outputs()): + outpoint = f"{txid}:{idx}" + scripthash = script_to_scripthash(txout.scriptpubkey.hex()) + prevouts_by_scripthash[scripthash].append((outpoint, txout.value)) + self.put('prevouts_by_scripthash', prevouts_by_scripthash) + + self.put('seed_version', 22) + + def _convert_version_23(self): + if not self._is_upgrade_method_needed(22, 22): + return + channels = self.get('channels', []) + LOCAL = 1 + REMOTE = -1 + for c in channels: + # move revocation store from remote_config + r = c['remote_config'].pop('revocation_store') + c['revocation_store'] = r + # convert fee updates + log = c.get('log', {}) + for sub in LOCAL, REMOTE: + l = log[str(sub)]['fee_updates'] + d = {} + for i, fu in enumerate(l): + d[str(i)] = { + 'rate':fu['rate'], + 'ctn_local':fu['ctns'][str(LOCAL)], + 'ctn_remote':fu['ctns'][str(REMOTE)] + } + log[str(int(sub))]['fee_updates'] = d + self.data['channels'] = channels + + self.data['seed_version'] = 23 + + + def _convert_imported(self): + if not self._is_upgrade_method_needed(0, 13): + return + + # '/x' is the internal ID for imported accounts + d = self.get('accounts', {}).get('/x', {}).get('imported',{}) + if not d: + return False + addresses = [] + keypairs = {} + for addr, v in d.items(): + pubkey, privkey = v + if privkey: + keypairs[pubkey] = privkey + else: + addresses.append(addr) + if addresses and keypairs: + raise WalletFileException('mixed addresses and privkeys') + elif addresses: + self.put('addresses', addresses) + self.put('accounts', None) + elif keypairs: + self.put('wallet_type', 'standard') + self.put('key_type', 'imported') + self.put('keypairs', keypairs) + self.put('accounts', None) + else: + raise WalletFileException('no addresses or privkeys') + + def _convert_account(self): + if not self._is_upgrade_method_needed(0, 13): + return + self.put('accounts', None) + + def _is_upgrade_method_needed(self, min_version, max_version): + assert min_version <= max_version + cur_version = self.get_seed_version() + if cur_version > max_version: + return False + elif cur_version < min_version: + raise WalletFileException( + 'storage upgrade: unexpected version {} (should be {}-{})' + .format(cur_version, min_version, max_version)) + else: + return True + + @locked + def get_seed_version(self): + seed_version = self.get('seed_version') + if not seed_version: + seed_version = OLD_SEED_VERSION if len(self.get('master_public_key','')) == 128 else NEW_SEED_VERSION + if seed_version > FINAL_SEED_VERSION: + raise WalletFileException('This version of Electrum is too old to open this wallet.\n' + '(highest supported storage version: {}, version of this file: {})' + .format(FINAL_SEED_VERSION, seed_version)) + if seed_version==14 and self.get('seed_type') == 'segwit': + self._raise_unsupported_version(seed_version) + if seed_version >=12: + return seed_version + if seed_version not in [OLD_SEED_VERSION, NEW_SEED_VERSION]: + self._raise_unsupported_version(seed_version) + return seed_version + + def _raise_unsupported_version(self, seed_version): + msg = f"Your wallet has an unsupported seed version: {seed_version}." + if seed_version in [5, 7, 8, 9, 10, 14]: + msg += "\n\nTo open this wallet, try 'git checkout seed_v%d'"%seed_version + if seed_version == 6: + # version 1.9.8 created v6 wallets when an incorrect seed was entered in the restore dialog + msg += '\n\nThis file was created because of a bug in version 1.9.8.' + if self.get('master_public_keys') is None and self.get('master_private_keys') is None and self.get('imported_keys') is None: + # pbkdf2 (at that time an additional dependency) was not included with the binaries, and wallet creation aborted. + msg += "\nIt does not contain any keys, and can safely be removed." + else: + # creation was complete if electrum was run from source + msg += "\nPlease open this file with Electrum 1.9.8, and move your coins to a new wallet." + raise WalletFileException(msg) + + @locked + def get_txi_addresses(self, tx_hash) -> List[str]: + """Returns list of is_mine addresses that appear as inputs in tx.""" + return list(self.txi.get(tx_hash, {}).keys()) + + @locked + def get_txo_addresses(self, tx_hash) -> List[str]: + """Returns list of is_mine addresses that appear as outputs in tx.""" + return list(self.txo.get(tx_hash, {}).keys()) + + @locked + def get_txi_addr(self, tx_hash, address) -> Iterable[Tuple[str, int]]: + """Returns an iterable of (prev_outpoint, value).""" + return self.txi.get(tx_hash, {}).get(address, []).copy() + + @locked + def get_txo_addr(self, tx_hash, address) -> Iterable[Tuple[int, int, bool]]: + """Returns an iterable of (output_index, value, is_coinbase).""" + return self.txo.get(tx_hash, {}).get(address, []).copy() + + @modifier + def add_txi_addr(self, tx_hash, addr, ser, v): + if tx_hash not in self.txi: + self.txi[tx_hash] = {} + d = self.txi[tx_hash] + if addr not in d: + # note that as this is a set, we can ignore "duplicates" + d[addr] = set() + d[addr].add((ser, v)) + + @modifier + def add_txo_addr(self, tx_hash, addr, n, v, is_coinbase): + if tx_hash not in self.txo: + self.txo[tx_hash] = {} + d = self.txo[tx_hash] + if addr not in d: + # note that as this is a set, we can ignore "duplicates" + d[addr] = set() + d[addr].add((n, v, is_coinbase)) + + @locked + def list_txi(self): + return list(self.txi.keys()) + + @locked + def list_txo(self): + return list(self.txo.keys()) + + @modifier + def remove_txi(self, tx_hash): + self.txi.pop(tx_hash, None) + + @modifier + def remove_txo(self, tx_hash): + self.txo.pop(tx_hash, None) + + @locked + def list_spent_outpoints(self): + return [(h, n) + for h in self.spent_outpoints.keys() + for n in self.get_spent_outpoints(h) + ] + + @locked + def get_spent_outpoints(self, prevout_hash): + return list(self.spent_outpoints.get(prevout_hash, {}).keys()) + + @locked + def get_spent_outpoint(self, prevout_hash, prevout_n): + prevout_n = str(prevout_n) + return self.spent_outpoints.get(prevout_hash, {}).get(prevout_n) + + @modifier + def remove_spent_outpoint(self, prevout_hash, prevout_n): + prevout_n = str(prevout_n) + self.spent_outpoints[prevout_hash].pop(prevout_n, None) + if not self.spent_outpoints[prevout_hash]: + self.spent_outpoints.pop(prevout_hash) + + @modifier + def set_spent_outpoint(self, prevout_hash, prevout_n, tx_hash): + prevout_n = str(prevout_n) + if prevout_hash not in self.spent_outpoints: + self.spent_outpoints[prevout_hash] = {} + self.spent_outpoints[prevout_hash][prevout_n] = tx_hash + + @modifier + def add_prevout_by_scripthash(self, scripthash: str, *, prevout: TxOutpoint, value: int) -> None: + assert isinstance(prevout, TxOutpoint) + if scripthash not in self._prevouts_by_scripthash: + self._prevouts_by_scripthash[scripthash] = set() + self._prevouts_by_scripthash[scripthash].add((prevout.to_str(), value)) + + @modifier + def remove_prevout_by_scripthash(self, scripthash: str, *, prevout: TxOutpoint, value: int) -> None: + assert isinstance(prevout, TxOutpoint) + self._prevouts_by_scripthash[scripthash].discard((prevout.to_str(), value)) + if not self._prevouts_by_scripthash[scripthash]: + self._prevouts_by_scripthash.pop(scripthash) + + @locked + def get_prevouts_by_scripthash(self, scripthash: str) -> Set[Tuple[TxOutpoint, int]]: + prevouts_and_values = self._prevouts_by_scripthash.get(scripthash, set()) + return {(TxOutpoint.from_str(prevout), value) for prevout, value in prevouts_and_values} + + @modifier + def add_transaction(self, tx_hash: str, tx: Transaction) -> None: + assert isinstance(tx, Transaction), tx + # note that tx might be a PartialTransaction + if not tx_hash: + raise Exception("trying to add tx to db without txid") + if tx_hash != tx.txid(): + raise Exception(f"trying to add tx to db with inconsistent txid: {tx_hash} != {tx.txid()}") + # don't allow overwriting complete tx with partial tx + tx_we_already_have = self.transactions.get(tx_hash, None) + if tx_we_already_have is None or isinstance(tx_we_already_have, PartialTransaction): + self.transactions[tx_hash] = tx + + @modifier + def remove_transaction(self, tx_hash) -> Optional[Transaction]: + return self.transactions.pop(tx_hash, None) + + @locked + def get_transaction(self, tx_hash: str) -> Optional[Transaction]: + return self.transactions.get(tx_hash) + + @locked + def list_transactions(self): + return list(self.transactions.keys()) + + @locked + def get_history(self): + return list(self.history.keys()) + + def is_addr_in_history(self, addr): + # does not mean history is non-empty! + return addr in self.history + + @locked + def get_addr_history(self, addr): + return self.history.get(addr, []) + + @modifier + def set_addr_history(self, addr, hist): + self.history[addr] = hist + + @modifier + def remove_addr_history(self, addr): + self.history.pop(addr, None) + + @locked + def list_verified_tx(self): + return list(self.verified_tx.keys()) + + @locked + def get_verified_tx(self, txid): + if txid not in self.verified_tx: + return None + height, timestamp, txpos, header_hash = self.verified_tx[txid] + return TxMinedInfo(height=height, + conf=None, + timestamp=timestamp, + txpos=txpos, + header_hash=header_hash) + + @modifier + def add_verified_tx(self, txid, info): + self.verified_tx[txid] = (info.height, info.timestamp, info.txpos, info.header_hash) + + @modifier + def remove_verified_tx(self, txid): + self.verified_tx.pop(txid, None) + + def is_in_verified_tx(self, txid): + return txid in self.verified_tx + + @modifier + def add_tx_fee_from_server(self, txid: str, fee_sat: Optional[int]) -> None: + # note: when called with (fee_sat is None), rm currently saved value + if txid not in self.tx_fees: + self.tx_fees[txid] = TxFeesValue() + tx_fees_value = self.tx_fees[txid] + if tx_fees_value.is_calculated_by_us: + return + self.tx_fees[txid] = tx_fees_value._replace(fee=fee_sat, is_calculated_by_us=False) + + @modifier + def add_tx_fee_we_calculated(self, txid: str, fee_sat: Optional[int]) -> None: + if fee_sat is None: + return + if txid not in self.tx_fees: + self.tx_fees[txid] = TxFeesValue() + self.tx_fees[txid] = self.tx_fees[txid]._replace(fee=fee_sat, is_calculated_by_us=True) + + @locked + def get_tx_fee(self, txid: str, *, trust_server=False) -> Optional[int]: + """Returns tx_fee.""" + tx_fees_value = self.tx_fees.get(txid) + if tx_fees_value is None: + return None + if not trust_server and not tx_fees_value.is_calculated_by_us: + return None + return tx_fees_value.fee + + @modifier + def add_num_inputs_to_tx(self, txid: str, num_inputs: int) -> None: + if txid not in self.tx_fees: + self.tx_fees[txid] = TxFeesValue() + self.tx_fees[txid] = self.tx_fees[txid]._replace(num_inputs=num_inputs) + + @locked + def get_num_all_inputs_of_tx(self, txid: str) -> Optional[int]: + tx_fees_value = self.tx_fees.get(txid) + if tx_fees_value is None: + return None + return tx_fees_value.num_inputs + + @locked + def get_num_ismine_inputs_of_tx(self, txid: str) -> int: + txins = self.txi.get(txid, {}) + return sum([len(tupls) for addr, tupls in txins.items()]) + + @modifier + def remove_tx_fee(self, txid): + self.tx_fees.pop(txid, None) + + @locked + def get_data_ref(self, name): + # Warning: interacts un-intuitively with 'put': certain parts + # of 'data' will have pointers saved as separate variables. + if name not in self.data: + self.data[name] = {} + return self.data[name] + + @locked + def num_change_addresses(self): + return len(self.change_addresses) + + @locked + def num_receiving_addresses(self): + return len(self.receiving_addresses) + + @locked + def get_change_addresses(self, *, slice_start=None, slice_stop=None): + # note: slicing makes a shallow copy + return self.change_addresses[slice_start:slice_stop] + + @locked + def get_receiving_addresses(self, *, slice_start=None, slice_stop=None): + # note: slicing makes a shallow copy + return self.receiving_addresses[slice_start:slice_stop] + + @modifier + def add_change_address(self, addr): + self._addr_to_addr_index[addr] = (1, len(self.change_addresses)) + self.change_addresses.append(addr) + + @modifier + def add_receiving_address(self, addr): + self._addr_to_addr_index[addr] = (0, len(self.receiving_addresses)) + self.receiving_addresses.append(addr) + + @locked + def get_address_index(self, address) -> Optional[Sequence[int]]: + return self._addr_to_addr_index.get(address) + + @modifier + def add_imported_address(self, addr, d): + self.imported_addresses[addr] = d + + @modifier + def remove_imported_address(self, addr): + self.imported_addresses.pop(addr) + + @locked + def has_imported_address(self, addr: str) -> bool: + return addr in self.imported_addresses + + @locked + def get_imported_addresses(self) -> Sequence[str]: + return list(sorted(self.imported_addresses.keys())) + + @locked + def get_imported_address(self, addr): + return self.imported_addresses.get(addr) + + def load_addresses(self, wallet_type): + """ called from Abstract_Wallet.__init__ """ + if wallet_type == 'imported': + self.imported_addresses = self.get_data_ref('addresses') # type: Dict[str, dict] + else: + self.get_data_ref('addresses') + for name in ['receiving', 'change']: + if name not in self.data['addresses']: + self.data['addresses'][name] = [] + self.change_addresses = self.data['addresses']['change'] + self.receiving_addresses = self.data['addresses']['receiving'] + self._addr_to_addr_index = {} # type: Dict[str, Sequence[int]] # key: address, value: (is_change, index) + for i, addr in enumerate(self.receiving_addresses): + self._addr_to_addr_index[addr] = (0, i) + for i, addr in enumerate(self.change_addresses): + self._addr_to_addr_index[addr] = (1, i) + + @profiler + def _load_transactions(self): + # references in self.data + # TODO make all these private + # txid -> address -> set of (prev_outpoint, value) + self.txi = self.get_data_ref('txi') # type: Dict[str, Dict[str, Set[Tuple[str, int]]]] + # txid -> address -> set of (output_index, value, is_coinbase) + self.txo = self.get_data_ref('txo') # type: Dict[str, Dict[str, Set[Tuple[int, int, bool]]]] + self.transactions = self.get_data_ref('transactions') # type: Dict[str, Transaction] + self.spent_outpoints = self.get_data_ref('spent_outpoints') # txid -> output_index -> next_txid + self.history = self.get_data_ref('addr_history') # address -> list of (txid, height) + self.verified_tx = self.get_data_ref('verified_tx3') # txid -> (height, timestamp, txpos, header_hash) + self.tx_fees = self.get_data_ref('tx_fees') # type: Dict[str, TxFeesValue] + # scripthash -> set of (outpoint, value) + self._prevouts_by_scripthash = self.get_data_ref('prevouts_by_scripthash') # type: Dict[str, Set[Tuple[str, int]]] + # convert raw transactions to Transaction objects + for tx_hash, raw_tx in self.transactions.items(): + # note: for performance, "deserialize=False" so that we will deserialize these on-demand + self.transactions[tx_hash] = tx_from_any(raw_tx, deserialize=False) + # convert txi, txo: list to set + for t in self.txi, self.txo: + for d in t.values(): + for addr, lst in d.items(): + d[addr] = set([tuple(x) for x in lst]) + # convert prevouts_by_scripthash: list to set, list to tuple + for scripthash, lst in self._prevouts_by_scripthash.items(): + self._prevouts_by_scripthash[scripthash] = {(prevout, value) for prevout, value in lst} + # remove unreferenced tx + for tx_hash in list(self.transactions.keys()): + if not self.get_txi_addresses(tx_hash) and not self.get_txo_addresses(tx_hash): + self.logger.info(f"removing unreferenced tx: {tx_hash}") + self.transactions.pop(tx_hash) + # remove unreferenced outpoints + for prevout_hash in self.spent_outpoints.keys(): + d = self.spent_outpoints[prevout_hash] + for prevout_n, spending_txid in list(d.items()): + if spending_txid not in self.transactions: + self.logger.info("removing unreferenced spent outpoint") + d.pop(prevout_n) + # convert tx_fees tuples to NamedTuples + for tx_hash, tuple_ in self.tx_fees.items(): + self.tx_fees[tx_hash] = TxFeesValue(*tuple_) + + @modifier + def clear_history(self): + self.txi.clear() + self.txo.clear() + self.spent_outpoints.clear() + self.transactions.clear() + self.history.clear() + self.verified_tx.clear() + self.tx_fees.clear()