From 5432228d17819865fe559e5073974c895fc08d7d Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 1 Aug 2025 17:48:57 +0000 Subject: [PATCH 1/4] fee calculation should round up satoshis so that with a feerate of 0.1 sat/vbyte, for a tx of size 141 vbytes, the fee is 15 sat (instead of 14 sat) (assuming a min relay fee of 0.1 s/b, the tx needs to pay a minimum of 15 sats to propagate) --- electrum/fee_policy.py | 13 +++++++++---- electrum/gui/qml/qetxfinalizer.py | 4 ++-- electrum/gui/qt/confirm_tx_dialog.py | 5 ++++- electrum/gui/qt/main_window.py | 4 ++-- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/electrum/fee_policy.py b/electrum/fee_policy.py index 33b7eb384..f02e545c8 100644 --- a/electrum/fee_policy.py +++ b/electrum/fee_policy.py @@ -2,6 +2,7 @@ from typing import Optional, Sequence, Tuple, Union, TYPE_CHECKING, Dict from decimal import Decimal from numbers import Real from enum import IntEnum +import math from .i18n import _ from .util import NoDynamicFeeEstimates, quantize_feerate, format_fee_satoshis @@ -257,11 +258,15 @@ class FeePolicy(Logger): else: raise NoDynamicFeeEstimates() - return self.estimate_fee_for_feerate(fee_per_kb, size) + return self.estimate_fee_for_feerate(fee_per_kb=fee_per_kb, size=size) @classmethod - def estimate_fee_for_feerate(cls, fee_per_kb: Union[int, float, Decimal], - size: Union[int, float, Decimal]) -> int: + def estimate_fee_for_feerate( + cls, + *, + fee_per_kb: Union[int, float, Decimal], + size: Union[int, float, Decimal], + ) -> int: # note: 'size' is in vbytes size = Decimal(size) fee_per_kb = Decimal(fee_per_kb) @@ -269,7 +274,7 @@ class FeePolicy(Logger): # to be consistent with what is displayed in the GUI, # the calculation needs to use the same precision: fee_per_byte = quantize_feerate(fee_per_byte) - return round(fee_per_byte * size) + return math.ceil(fee_per_byte * size) class FixedFeePolicy(FeePolicy): diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index 1b378d608..5e691732a 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -887,8 +887,8 @@ class QETxCpfpFeeBumper(TxFeeSlider, TxMonMixin): def get_child_fee_from_total_feerate(self, fee_per_kb: Optional[int]) -> Optional[int]: if fee_per_kb is None: return None - fee = fee_per_kb * self._total_size / 1000 - self._parent_fee - fee = round(fee) + package_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=fee_per_kb, size=self._total_size) + fee = package_fee - self._parent_fee fee = min(self._max_fee, fee) fee = max(self._total_size, fee) # pay at least 1 sat/byte for combined size return fee diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index 6ab873331..d6047eb24 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -350,7 +350,10 @@ class TxEditor(WindowModalDialog): # fallback to actual fee displayed_feerate = quantize_feerate(fee / size) if fee is not None else None self.feerate_e.setAmount(displayed_feerate) - displayed_fee = round(displayed_feerate * size) if displayed_feerate is not None else None + if displayed_feerate is not None: + displayed_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=displayed_feerate * 1000, size=size) + else: + displayed_fee = None self.fee_e.setAmount(displayed_fee) else: if freeze_fee: diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index ef69b7208..21c5c286e 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -2874,8 +2874,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): def get_child_fee_from_total_feerate(fee_per_kb: Optional[int]) -> Optional[int]: if fee_per_kb is None: return None - fee = fee_per_kb * total_size / 1000 - parent_fee - fee = round(fee) + package_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=fee_per_kb, size=total_size) + fee = package_fee - parent_fee fee = min(max_fee, fee) fee = max(total_size, fee) # pay at least 1 sat/byte for combined size return fee From db52ec7798fbc642e32985ca3fe6a9a5f16727ac Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 1 Aug 2025 18:16:11 +0000 Subject: [PATCH 2/4] gui: cpfp: use wallet.relayfee() instead of hardcoded 1 sat/byte --- electrum/gui/qml/qetxfinalizer.py | 10 ++++++---- electrum/gui/qt/main_window.py | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index 5e691732a..76a17ddec 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -888,10 +888,12 @@ class QETxCpfpFeeBumper(TxFeeSlider, TxMonMixin): if fee_per_kb is None: return None package_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=fee_per_kb, size=self._total_size) - fee = package_fee - self._parent_fee - fee = min(self._max_fee, fee) - fee = max(self._total_size, fee) # pay at least 1 sat/byte for combined size - return fee + child_fee = package_fee - self._parent_fee + child_fee = min(self._max_fee, child_fee) + # pay at least minrelayfee for combined size: + min_child_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=self._wallet.wallet.relayfee(), size=self._total_size) + child_fee = max(min_child_fee, child_fee) + return child_fee def tx_verified(self): self._valid = False diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 21c5c286e..a2aaf6a98 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -2875,10 +2875,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): if fee_per_kb is None: return None package_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=fee_per_kb, size=total_size) - fee = package_fee - parent_fee - fee = min(max_fee, fee) - fee = max(total_size, fee) # pay at least 1 sat/byte for combined size - return fee + child_fee = package_fee - parent_fee + child_fee = min(max_fee, child_fee) + # pay at least minrelayfee for combined size: + min_child_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=self.wallet.relayfee(), size=total_size) + child_fee = max(min_child_fee, child_fee) + return child_fee fee_policy = FeePolicy(self.config.FEE_POLICY) suggested_feerate = fee_policy.fee_per_kb(self.network) fee = get_child_fee_from_total_feerate(suggested_feerate) From d8a6ed9b55bc7399d13ced3f40f08e17602ab2fc Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 1 Aug 2025 18:35:09 +0000 Subject: [PATCH 3/4] bitcoin.py: split dust_threshold from minrelayfee, make it hardcoded instead - the dust threshold in bitcoin core is calculated with a 3 sat/vbyte feerate, which we were interpreting as 3*minrelayfee - and now bitcoin core is considering changing the minrelayfee (1->0.1 s/b), independently from the dust threshold (in https://github.com/bitcoin/bitcoin/pull/33106) --- electrum/bitcoin.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/electrum/bitcoin.py b/electrum/bitcoin.py index 35e17dc39..01440a757 100644 --- a/electrum/bitcoin.py +++ b/electrum/bitcoin.py @@ -347,10 +347,7 @@ DUST_LIMIT_P2WPKH = 294 def dust_threshold(network: 'Network' = None) -> int: """Returns the dust limit in satoshis.""" - # Change <= dust threshold is added to the tx fee - dust_lim = 182 * 3 * relayfee(network) # in msat - # convert to sat, but round up: - return (dust_lim // 1000) + (dust_lim % 1000 > 0) + return DUST_LIMIT_P2PKH def hash_encode(x: bytes) -> str: From 58af1c493de0a620f164ac025f10c58280af36b1 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 1 Aug 2025 17:46:27 +0000 Subject: [PATCH 4/4] fee_policy: lower min relay fee from 1 sat/byte to 0.1 s/b - minrelayfee is still server-reported, but the clamps are changed from [1, 50] to [0.1, 50] sat/vbyte - dynamic feerates coming from the estimator are still clamped to [1, 1500] sat/vbyte ref https://github.com/bitcoin/bitcoin/pull/33106 --- electrum/bitcoin.py | 4 ++-- electrum/fee_policy.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/electrum/bitcoin.py b/electrum/bitcoin.py index 01440a757..562b53ee4 100644 --- a/electrum/bitcoin.py +++ b/electrum/bitcoin.py @@ -325,14 +325,14 @@ def construct_script( def relayfee(network: 'Network' = None) -> int: """Returns feerate in sat/kbyte.""" - from .fee_policy import FEERATE_DEFAULT_RELAY, FEERATE_MAX_RELAY + from .fee_policy import FEERATE_MIN_RELAY, FEERATE_DEFAULT_RELAY, FEERATE_MAX_RELAY if network and network.relay_fee is not None: fee = network.relay_fee else: fee = FEERATE_DEFAULT_RELAY # sanity safeguards, as network.relay_fee is coming from a server: fee = min(fee, FEERATE_MAX_RELAY) - fee = max(fee, FEERATE_DEFAULT_RELAY) + fee = max(fee, FEERATE_MIN_RELAY) return fee diff --git a/electrum/fee_policy.py b/electrum/fee_policy.py index f02e545c8..9e8e660e1 100644 --- a/electrum/fee_policy.py +++ b/electrum/fee_policy.py @@ -25,8 +25,10 @@ FEERATE_MAX_DYNAMIC = 1500000 FEERATE_WARNING_HIGH_FEE = 600000 FEERATE_FALLBACK_STATIC_FEE = 150000 FEERATE_REGTEST_STATIC_FEE = FEERATE_FALLBACK_STATIC_FEE # hardcoded fee used on regtest -FEERATE_DEFAULT_RELAY = 1000 +FEERATE_MIN_RELAY = 100 +FEERATE_DEFAULT_RELAY = 1000 # conservative "min relay fee" FEERATE_MAX_RELAY = 50000 +assert FEERATE_MIN_RELAY <= FEERATE_DEFAULT_RELAY <= FEERATE_MAX_RELAY # warn user if fee/amount for on-chain tx is higher than this FEE_RATIO_HIGH_WARNING = 0.05 @@ -288,6 +290,8 @@ def impose_hard_limits_on_fee(func): if fee is None: return fee fee = min(FEERATE_MAX_DYNAMIC, fee) + # Clamp dynamic feerates with conservative min relay fee, + # to ensure txs propagate well: fee = max(FEERATE_DEFAULT_RELAY, fee) return fee return get_fee_within_limits @@ -359,7 +363,7 @@ class FeeHistogram: slot = min(item[1], bytes_limit-bytes_current) bytes_current += slot capped_histogram.append([ - max(FEERATE_DEFAULT_RELAY/1000, item[0]), # clamped to [FEERATE_DEFAULT_RELAY/1000,inf[ + max(FEERATE_MIN_RELAY/1000, item[0]), # clamped to [FEERATE_MIN_RELAY/1000,inf[ slot, # width of bucket bytes_current, # cumulative depth at far end of bucket ])