commit 27cd0780010462075151efecfacc1f72012efb97
parent 2b0f156ce84a991f76f5b00d9d8d708122071623
Author: SomberNight <somber.night@protonmail.com>
Date: Fri, 22 Jan 2021 21:38:32 +0100
wallet: auto-freeze small unconfirmed UTXOs
see #6960
Diffstat:
2 files changed, 58 insertions(+), 10 deletions(-)
diff --git a/electrum/wallet.py b/electrum/wallet.py
@@ -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]
@@ -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()
@@ -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"""
@@ -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'
@@ -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.
diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py
@@ -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
@@ -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()
@@ -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