tcoinchooser: don't spend buckets with negative effective value - electrum - Electrum Bitcoin wallet
 (HTM) git clone https://git.parazyd.org/electrum
 (DIR) Log
 (DIR) Files
 (DIR) Refs
 (DIR) Submodules
       ---
 (DIR) commit cb69aa80f7f9751ee5ed4699f93e4b00f8c59b10
 (DIR) parent e0b1bbfc4603556e646c41dec39d31a61a656c1a
 (HTM) Author: SomberNight <somber.night@protonmail.com>
       Date:   Thu, 20 Jun 2019 22:40:57 +0200
       
       coinchooser: don't spend buckets with negative effective value
       
       Calculate the effective value of buckets, and filter <0 out.
       Note that the filtering is done on the buckets, not per-coin.
       This should better preserve the user's privacy in certain cases.
       
       When the user "sends Max", as before, all UTXOs are selected,
       even if they are not economical to spend.
       
       see #5433
       
       Diffstat:
         M electrum/coinchooser.py             |      38 ++++++++++++++++++++++++++-----
         M electrum/simple_config.py           |       6 ++++--
         M electrum/wallet.py                  |      15 +++++++++++----
       
       3 files changed, 47 insertions(+), 12 deletions(-)
       ---
 (DIR) diff --git a/electrum/coinchooser.py b/electrum/coinchooser.py
       t@@ -25,6 +25,7 @@
        from collections import defaultdict
        from math import floor, log10
        from typing import NamedTuple, List, Callable
       +from decimal import Decimal
        
        from .bitcoin import sha256, COIN, TYPE_ADDRESS, is_address
        from .transaction import Transaction, TxOutput
       t@@ -74,6 +75,7 @@ class Bucket(NamedTuple):
            desc: str
            weight: int         # as in BIP-141
            value: int          # in satoshis
       +    effective_value: int   # estimate of value left after subtracting fees. in satoshis
            coins: List[dict]   # UTXOs
            min_height: int     # min block height where a coin was confirmed
            witness: bool       # whether any coin uses segwit
       t@@ -109,11 +111,14 @@ class CoinChooserBase(Logger):
            def keys(self, coins):
                raise NotImplementedError
        
       -    def bucketize_coins(self, coins):
       +    def bucketize_coins(self, coins, *, fee_estimator):
                keys = self.keys(coins)
                buckets = defaultdict(list)
                for key, coin in zip(keys, coins):
                    buckets[key].append(coin)
       +        # fee_estimator returns fee to be paid, for given vbytes.
       +        # guess whether it is just returning a constant as follows.
       +        constant_fee = fee_estimator(2000) == fee_estimator(200)
        
                def make_Bucket(desc, coins):
                    witness = any(Transaction.is_segwit_input(coin, guess_for_address=True) for coin in coins)
       t@@ -123,7 +128,23 @@ class CoinChooserBase(Logger):
                                 for coin in coins)
                    value = sum(coin['value'] for coin in coins)
                    min_height = min(coin['height'] for coin in coins)
       -            return Bucket(desc, weight, value, coins, min_height, witness)
       +            # the fee estimator is typically either a constant or a linear function,
       +            # so the "function:" effective_value(bucket) will be homomorphic for addition
       +            # i.e. effective_value(b1) + effective_value(b2) = effective_value(b1 + b2)
       +            if constant_fee:
       +                effective_value = value
       +            else:
       +                # when converting from weight to vBytes, instead of rounding up,
       +                # keep fractional part, to avoid overestimating fee
       +                fee = fee_estimator(Decimal(weight) / 4)
       +                effective_value = value - fee
       +            return Bucket(desc=desc,
       +                          weight=weight,
       +                          value=value,
       +                          effective_value=effective_value,
       +                          coins=coins,
       +                          min_height=min_height,
       +                          witness=witness)
        
                return list(map(make_Bucket, buckets.keys(), buckets.values()))
        
       t@@ -287,8 +308,14 @@ class CoinChooserBase(Logger):
                                                                    dust_threshold=dust_threshold,
                                                                    base_weight=base_weight)
        
       -        # Collect the coins into buckets, choose a subset of the buckets
       -        all_buckets = self.bucketize_coins(coins)
       +        # Collect the coins into buckets
       +        all_buckets = self.bucketize_coins(coins, fee_estimator=fee_estimator)
       +        # Filter some buckets out. Only keep those that have positive effective value.
       +        # Note that this filtering is intentionally done on the bucket level
       +        # instead of per-coin, as each bucket should be either fully spent or not at all.
       +        # (e.g. CoinChooserPrivacy ensures that same-address coins go into one bucket)
       +        all_buckets = list(filter(lambda b: b.effective_value > 0, all_buckets))
       +        # Choose a subset of the buckets
                scored_candidate = self.choose_buckets(all_buckets, sufficient_funds,
                                                       self.penalty_func(base_tx, tx_from_buckets=tx_from_buckets))
                tx = scored_candidate.tx
       t@@ -334,8 +361,7 @@ class CoinChooserRandom(CoinChooserBase):
                            candidates.add(tuple(sorted(permutation[:count + 1])))
                            break
                    else:
       -                # FIXME this assumes that the effective value of any bkt is >= 0
       -                # we should make sure not to choose buckets with <= 0 eff. val.
       +                # note: this assumes that the effective value of any bkt is >= 0
                        raise NotEnoughFunds()
        
                candidates = [[buckets[n] for n in c] for c in candidates]
 (DIR) diff --git a/electrum/simple_config.py b/electrum/simple_config.py
       t@@ -533,14 +533,16 @@ class SimpleConfig(Logger):
                fee_per_kb = self.fee_per_kb()
                return fee_per_kb / 1000 if fee_per_kb is not None else None
        
       -    def estimate_fee(self, size):
       +    def estimate_fee(self, size: Union[int, float, Decimal]) -> int:
                fee_per_kb = self.fee_per_kb()
                if fee_per_kb is None:
                    raise NoDynamicFeeEstimates()
                return self.estimate_fee_for_feerate(fee_per_kb, size)
        
            @classmethod
       -    def estimate_fee_for_feerate(cls, fee_per_kb, size):
       +    def estimate_fee_for_feerate(cls, fee_per_kb: Union[int, float, Decimal],
       +                                 size: Union[int, float, Decimal]) -> int:
       +        size = Decimal(size)
                fee_per_kb = Decimal(fee_per_kb)
                fee_per_byte = fee_per_kb / 1000
                # to be consistent with what is displayed in the GUI,
 (DIR) diff --git a/electrum/wallet.py b/electrum/wallet.py
       t@@ -745,12 +745,13 @@ class Abstract_Wallet(AddressSynchronizer):
                        base_tx.remove_signatures()
                        base_tx.add_inputs_info(self)
                        base_tx_fee = base_tx.get_fee()
       -                relayfeerate = self.relayfee() / 1000
       +                relayfeerate = Decimal(self.relayfee()) / 1000
                        original_fee_estimator = fee_estimator
       -                def fee_estimator(size: int) -> int:
       +                def fee_estimator(size: Union[int, float, Decimal]) -> int:
       +                    size = Decimal(size)
                            lower_bound = base_tx_fee + round(size * relayfeerate)
                            lower_bound = lower_bound if not is_local else 0
       -                    return max(lower_bound, original_fee_estimator(size))
       +                    return int(max(lower_bound, original_fee_estimator(size)))
                        txi = base_tx.inputs()
                        txo = list(filter(lambda o: not self.is_change(o.address), base_tx.outputs()))
                        old_change_addrs = [o.address for o in base_tx.outputs() if self.is_change(o.address)]
       t@@ -763,7 +764,13 @@ class Abstract_Wallet(AddressSynchronizer):
                    tx = coin_chooser.make_tx(coins, txi, outputs[:] + txo, change_addrs,
                                              fee_estimator, self.dust_threshold())
                else:
       -            # FIXME?? this might spend inputs with negative effective value...
       +            # "spend max" branch
       +            # note: This *will* spend inputs with negative effective value (if there are any).
       +            #       Given as the user is spending "max", and so might be abandoning the wallet,
       +            #       try to include all UTXOs, otherwise leftover might remain in the UTXO set
       +            #       forever. see #5433
       +            # note: Actually it might be the case that not all UTXOs from the wallet are
       +            #       being spent if the user manually selected UTXOs.
                    sendable = sum(map(lambda x:x['value'], coins))
                    outputs[i_max] = outputs[i_max]._replace(value=0)
                    tx = Transaction.from_io(coins, outputs[:])