twallet: auto-freeze small unconfirmed UTXOs - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit 27cd0780010462075151efecfacc1f72012efb97 DIR parent 2b0f156ce84a991f76f5b00d9d8d708122071623 HTML Author: SomberNight <somber.night@protonmail.com> Date: Fri, 22 Jan 2021 21:38:32 +0100 wallet: auto-freeze small unconfirmed UTXOs see #6960 Diffstat: M electrum/wallet.py | 57 ++++++++++++++++++++++++++----- M electrum/wallet_db.py | 11 ++++++++++- 2 files changed, 58 insertions(+), 10 deletions(-) --- DIR diff --git a/electrum/wallet.py b/electrum/wallet.py t@@ -287,7 +287,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): self.multiple_change = db.get('multiple_change', False) self._labels = db.get_dict('labels') self._frozen_addresses = set(db.get('frozen_addresses', [])) - self._frozen_coins = set(db.get('frozen_coins', [])) # set of txid:vout strings + self._frozen_coins = db.get_dict('frozen_coins') # type: Dict[str, bool] self.fiat_value = db.get_dict('fiat_value') self.receive_requests = db.get_dict('payment_requests') # type: Dict[str, Invoice] self.invoices = db.get_dict('invoices') # type: Dict[str, Invoice] t@@ -685,7 +685,10 @@ class Abstract_Wallet(AddressSynchronizer, ABC): def get_frozen_balance(self): with self._freeze_lock: frozen_addresses = self._frozen_addresses.copy() - frozen_coins = self._frozen_coins.copy() + # note: for coins, use is_frozen_coin instead of _frozen_coins, + # as latter only contains *manually* frozen ones + frozen_coins = {utxo.prevout.to_str() for utxo in self.get_utxos() + if self.is_frozen_coin(utxo)} if not frozen_coins: # shortcut return self.get_balance(frozen_addresses) c1, u1, x1 = self.get_balance() t@@ -1323,7 +1326,46 @@ class Abstract_Wallet(AddressSynchronizer, ABC): def is_frozen_coin(self, utxo: PartialTxInput) -> bool: prevout_str = utxo.prevout.to_str() - return prevout_str in self._frozen_coins + frozen = self._frozen_coins.get(prevout_str, None) + # note: there are three possible states for 'frozen': + # True/False if the user explicitly set it, + # None otherwise + if frozen is None: + return self._is_coin_small_and_unconfirmed(utxo) + return bool(frozen) + + def _is_coin_small_and_unconfirmed(self, utxo: PartialTxInput) -> bool: + """If true, the coin should not be spent. + The idea here is that an attacker might send us a UTXO in a + large low-fee unconfirmed tx that will ~never confirm. If we + spend it as part of a tx ourselves, that too will not confirm + (unless we use a high fee but that might not be worth it for + a small value UTXO). + In particular, this test triggers for large "dusting transactions" + that are used for advertising purposes by some entities. + see #6960 + """ + # confirmed UTXOs are fine; check this first for performance: + block_height = utxo.block_height + assert block_height is not None + if block_height > 0: + return False + # exempt large value UTXOs + value_sats = utxo.value_sats() + assert value_sats is not None + threshold = self.config.get('unconf_utxo_freeze_threshold', 5_000) + if value_sats >= threshold: + return False + # if funding tx has any is_mine input, then UTXO is fine + funding_tx = self.db.get_transaction(utxo.prevout.txid.hex()) + if funding_tx is None: + # we should typically have the funding tx available; + # might not have it e.g. while not up_to_date + return True + if any(self.is_mine(self.get_txin_address(txin)) + for txin in funding_tx.inputs()): + return False + return True def set_frozen_state_of_addresses(self, addrs: Sequence[str], freeze: bool) -> bool: """Set frozen state of the addresses to FREEZE, True or False""" t@@ -1342,11 +1384,8 @@ class Abstract_Wallet(AddressSynchronizer, ABC): # basic sanity check that input is not garbage: (see if raises) [TxOutpoint.from_str(utxo) for utxo in utxos] with self._freeze_lock: - if freeze: - self._frozen_coins |= set(utxos) - else: - self._frozen_coins -= set(utxos) - self.db.put('frozen_coins', list(self._frozen_coins)) + for utxo in utxos: + self._frozen_coins[utxo] = bool(freeze) def is_address_reserved(self, addr: str) -> bool: # note: atm 'reserved' status is only taken into consideration for 'change addresses' t@@ -1694,7 +1733,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): return True return False - def get_input_tx(self, tx_hash, *, ignore_network_issues=False) -> Optional[Transaction]: + def get_input_tx(self, tx_hash: str, *, ignore_network_issues=False) -> Optional[Transaction]: # First look up an input transaction in the wallet where it # will likely be. If co-signing a transaction it may not have # all the input txs, in which case we ask the network. DIR diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py t@@ -52,7 +52,7 @@ if TYPE_CHECKING: OLD_SEED_VERSION = 4 # electrum versions < 2.0 NEW_SEED_VERSION = 11 # electrum versions >= 2.0 -FINAL_SEED_VERSION = 35 # electrum >= 2.7 will set this to prevent +FINAL_SEED_VERSION = 36 # electrum >= 2.7 will set this to prevent # old versions from overwriting new format t@@ -183,6 +183,7 @@ class WalletDB(JsonDB): self._convert_version_33() self._convert_version_34() self._convert_version_35() + self._convert_version_36() self.put('seed_version', FINAL_SEED_VERSION) # just to be sure self._after_upgrade_tasks() t@@ -731,6 +732,14 @@ class WalletDB(JsonDB): self.data['payment_requests'] = requests_new self.data['seed_version'] = 35 + def _convert_version_36(self): + if not self._is_upgrade_method_needed(35, 35): + return + old_frozen_coins = self.data.get('frozen_coins', []) + new_frozen_coins = {coin: True for coin in old_frozen_coins} + self.data['frozen_coins'] = new_frozen_coins + self.data['seed_version'] = 36 + def _convert_imported(self): if not self._is_upgrade_method_needed(0, 13): return