twallet: auto-freeze small unconfirmed UTXOs - electrum - Electrum Bitcoin wallet
 (HTM) git clone https://git.parazyd.org/electrum
 (DIR) Log
 (DIR) Files
 (DIR) Refs
 (DIR) Submodules
       ---
 (DIR) commit 27cd0780010462075151efecfacc1f72012efb97
 (DIR) parent 2b0f156ce84a991f76f5b00d9d8d708122071623
 (HTM) 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