From 7ef605ee5c5a9598577d302d81f497d4e3944bb3 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 10 Sep 2025 16:44:13 +0200 Subject: [PATCH] qml: allow manual editing of fee/feerate also improve warning box styling consistency across finalizers, add CPFP new feerate > old feerate check, add relayfee checks for CPFP, DSCancel, proper warning string for no dynamic fee estimates --- electrum/fee_policy.py | 9 +- electrum/gui/messages.py | 5 + .../gui/qml/components/CpfpBumpFeeDialog.qml | 3 +- .../gui/qml/components/RbfBumpFeeDialog.qml | 3 +- .../gui/qml/components/RbfCancelDialog.qml | 3 +- .../components/controls/FeeMethodComboBox.qml | 3 +- .../gui/qml/components/controls/FeePicker.qml | 58 ++++++ electrum/gui/qml/qetxfinalizer.py | 168 ++++++++++++++---- electrum/util.py | 1 + 9 files changed, 211 insertions(+), 42 deletions(-) diff --git a/electrum/fee_policy.py b/electrum/fee_policy.py index 28c5db8f2..b00f47696 100644 --- a/electrum/fee_policy.py +++ b/electrum/fee_policy.py @@ -64,9 +64,10 @@ class FeeMethod(IntEnum): def name_for_GUI(self): names = { + FeeMethod.FIXED: _('FIXED'), FeeMethod.FEERATE: _('Feerate'), - FeeMethod.ETA:_('ETA'), - FeeMethod.MEMPOOL :_('Mempool') + FeeMethod.ETA: _('ETA'), + FeeMethod.MEMPOOL: _('Mempool') } return names[self] @@ -173,6 +174,8 @@ class FeePolicy(Logger): elif self.method == FeeMethod.FEERATE: fee_per_byte = self.value/1000 return format_fee_satoshis(fee_per_byte) + f" {util.UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE}" + elif self.method == FeeMethod.FIXED: + return f'{self.value} {util.UI_UNIT_NAME_FIXED_SAT}' def get_estimate_text(self, network: 'Network') -> str: """ @@ -233,6 +236,8 @@ class FeePolicy(Logger): fee_rate = network.fee_estimates.eta_to_fee(self.get_slider_pos()) else: fee_rate = None + elif self.method == FeeMethod.FIXED: + fee_rate = None else: raise Exception(self.method) if fee_rate is not None: diff --git a/electrum/gui/messages.py b/electrum/gui/messages.py index fc6fc36a5..bd9639a86 100644 --- a/electrum/gui/messages.py +++ b/electrum/gui/messages.py @@ -141,3 +141,8 @@ MSG_CONNECTMODE_ONESERVER_HELP = _( "This mode is only intended for connecting to your own fully trusted server. " "Using this option on a public server is a security risk and is discouraged." ) + +MSG_RELAYFEE = ' '.join([ + _("This transaction requires a higher fee, or it will not be propagated by your current server."), + _("Try to raise your transaction fee, or use a server with a lower relay fee.") +]) diff --git a/electrum/gui/qml/components/CpfpBumpFeeDialog.qml b/electrum/gui/qml/components/CpfpBumpFeeDialog.qml index 8361480a1..d68f9edc7 100644 --- a/electrum/gui/qml/components/CpfpBumpFeeDialog.qml +++ b/electrum/gui/qml/components/CpfpBumpFeeDialog.qml @@ -153,8 +153,7 @@ ElDialog { InfoTextArea { Layout.columnSpan: 2 - Layout.preferredWidth: parent.width * 3/4 - Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true Layout.topMargin: constants.paddingLarge visible: cpfpfeebumper.warning != '' text: cpfpfeebumper.warning diff --git a/electrum/gui/qml/components/RbfBumpFeeDialog.qml b/electrum/gui/qml/components/RbfBumpFeeDialog.qml index f913d7a6e..a96add281 100644 --- a/electrum/gui/qml/components/RbfBumpFeeDialog.qml +++ b/electrum/gui/qml/components/RbfBumpFeeDialog.qml @@ -162,8 +162,7 @@ ElDialog { InfoTextArea { Layout.columnSpan: 2 - Layout.preferredWidth: parent.width * 3/4 - Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true Layout.topMargin: constants.paddingLarge iconStyle: InfoTextArea.IconStyle.Warn visible: rbffeebumper.warning != '' diff --git a/electrum/gui/qml/components/RbfCancelDialog.qml b/electrum/gui/qml/components/RbfCancelDialog.qml index a34f14107..b39100413 100644 --- a/electrum/gui/qml/components/RbfCancelDialog.qml +++ b/electrum/gui/qml/components/RbfCancelDialog.qml @@ -94,8 +94,7 @@ ElDialog { InfoTextArea { Layout.columnSpan: 2 - Layout.preferredWidth: parent.width * 3/4 - Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true Layout.topMargin: constants.paddingLarge iconStyle: InfoTextArea.IconStyle.Warn visible: txcanceller.warning != '' diff --git a/electrum/gui/qml/components/controls/FeeMethodComboBox.qml b/electrum/gui/qml/components/controls/FeeMethodComboBox.qml index 5c0927df3..edfc85089 100644 --- a/electrum/gui/qml/components/controls/FeeMethodComboBox.qml +++ b/electrum/gui/qml/components/controls/FeeMethodComboBox.qml @@ -14,7 +14,8 @@ ElComboBox { model: [ { text: qsTr('ETA'), value: FeeSlider.FSMethod.ETA }, { text: qsTr('Mempool'), value: FeeSlider.FSMethod.MEMPOOL }, - { text: qsTr('Feerate'), value: FeeSlider.FSMethod.FEERATE } + { text: qsTr('Feerate'), value: FeeSlider.FSMethod.FEERATE }, + { text: qsTr('Manual'), value: FeeSlider.FSMethod.MANUAL } ] onCurrentValueChanged: { if (activeFocus) diff --git a/electrum/gui/qml/components/controls/FeePicker.qml b/electrum/gui/qml/components/controls/FeePicker.qml index c8dd3b797..dedb03ca2 100644 --- a/electrum/gui/qml/components/controls/FeePicker.qml +++ b/electrum/gui/qml/components/controls/FeePicker.qml @@ -19,6 +19,8 @@ Item { property bool showTxInfo: true property bool showPicker: true + property bool manualFeeEntry: finalizer.method == FeeSlider.FSMethod.MANUAL + implicitHeight: rootLayout.height GridLayout { @@ -91,6 +93,7 @@ Item { id: feeslider Layout.fillWidth: true leftPadding: constants.paddingMedium + enabled: !manualFeeEntry snapMode: Slider.SnapOnRelease stepSize: 1 @@ -117,5 +120,60 @@ Item { feeslider: finalizer } } + + Label { + Layout.preferredWidth: 1 + text: qsTr('Rate') + color: Material.accentColor + visible: showPicker && manualFeeEntry + } + + GridLayout { + Layout.preferredWidth: 2 + Layout.rowSpan: 2 + visible: showPicker && manualFeeEntry + columns: 2 + columnSpacing: constants.paddingMedium + + TextField { + id: rate + Layout.fillWidth: true + text: finalizer.userFeerate + inputMethodHints: Qt.ImhDigitsOnly + onTextEdited: { + finalizer.userFeerate = text + } + } + + Label { + Layout.fillWidth: true + color: Material.accentColor + text: qsTr('sat/vbyte') + } + + TextField { + id: absolute + Layout.fillWidth: true + text: finalizer.userFee + inputMethodHints: Qt.ImhDigitsOnly + onTextEdited: { + finalizer.userFee = text + } + } + + Label { + Layout.fillWidth: true + color: Material.accentColor + text: qsTr('sat') + } + } + + Label { + Layout.preferredWidth: 1 + visible: showPicker && manualFeeEntry + color: Material.accentColor + text: qsTr('Total') + } + } } diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index afd8a9536..dcc6b60e2 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -18,6 +18,8 @@ from electrum.plugin import run_hook from electrum.fee_policy import FeePolicy, FeeMethod from electrum.network import NetworkException +from electrum.gui import messages + from .qewallet import QEWallet from .qetypes import QEAmount from .util import QtEventListener, event_listener @@ -33,12 +35,14 @@ class FeeSlider(QObject): FEERATE = 0 ETA = 1 MEMPOOL = 2 + MANUAL = 3 def to_fee_method(self) -> 'FeeMethod': return { self.FEERATE: FeeMethod.FEERATE, self.ETA: FeeMethod.ETA, self.MEMPOOL: FeeMethod.MEMPOOL, + self.MANUAL: FeeMethod.FIXED }[self] @classmethod @@ -47,6 +51,7 @@ class FeeSlider(QObject): FeeMethod.FEERATE: cls.FEERATE, FeeMethod.ETA: cls.ETA, FeeMethod.MEMPOOL: cls.MEMPOOL, + FeeMethod.FIXED: cls.MANUAL }[fm] def __init__(self, parent=None): @@ -55,6 +60,7 @@ class FeeSlider(QObject): self._wallet = None # type: Optional[QEWallet] self._sliderSteps = 0 self._sliderPos = 0 + self._fee_method = None # type: Optional[FeeSlider.FSMethod] self._fee_policy = None # type: Optional[FeePolicy] self._target = '' self._config = None # type: Optional[SimpleConfig] @@ -97,10 +103,9 @@ class FeeSlider(QObject): @method.setter def method(self, method: int): - fsmethod = self.FSMethod(method) - method = fsmethod.to_fee_method() - if self._fee_policy.method != method: - self._fee_policy.set_method(method) + if self._fee_method != FeeSlider.FSMethod(method): + self._fee_method = self.FSMethod(method) + self._fee_policy.set_method(self._fee_method.to_fee_method()) self.update_slider() self.methodChanged.emit() self.save_config() @@ -117,6 +122,8 @@ class FeeSlider(QObject): self.targetChanged.emit() def update_slider(self): + if self._fee_method == FeeSlider.FSMethod.MANUAL: + return self._sliderSteps = self._fee_policy.get_slider_max() self._sliderPos = self._fee_policy.get_slider_pos() self.sliderStepsChanged.emit() @@ -127,16 +134,16 @@ class FeeSlider(QObject): def read_config(self): self._fee_policy = FeePolicy(self._config.FEE_POLICY) + self._fee_method = self.FSMethod.from_fee_method(self._fee_policy.method) self.update_slider() self.methodChanged.emit() - self.update_target() self.update() def save_config(self): - value = int(self._sliderPos) - self._fee_policy.set_value_from_slider_pos(value) - self._config.FEE_POLICY = self._fee_policy.get_descriptor() - self.update_target() + if self._fee_method != FeeSlider.FSMethod.MANUAL: + value = int(self._sliderPos) + self._fee_policy.set_value_from_slider_pos(value) + self._config.FEE_POLICY = self._fee_policy.get_descriptor() self.update() def update(self): @@ -149,6 +156,8 @@ class TxFeeSlider(FeeSlider): self._fee = QEAmount() self._feeRate = '' + self._userFee = '' + self._userFeerate = '' self._rbf = False self._tx = None # type: Optional[PartialTransaction] self._inputs = [] @@ -179,6 +188,37 @@ class TxFeeSlider(FeeSlider): self._feeRate = feeRate self.feeRateChanged.emit() + userFeeChanged = pyqtSignal() + @pyqtProperty(str, notify=userFeeChanged) + def userFee(self): + return self._userFee + + @userFee.setter + def userFee(self, userFee): + if self._userFee != userFee: + self._logger.warn('userFee') + self._userFee = userFee + user_fee = int(userFee) if userFee else 0 + self._fee_policy = FeePolicy(f'fixed:{user_fee}') + self.userFeeChanged.emit() + self.update() + + userFeerateChanged = pyqtSignal() + @pyqtProperty(str, notify=userFeerateChanged) + def userFeerate(self): + return self._userFeerate + + @userFeerate.setter + def userFeerate(self, userFeerate): + if self._userFeerate != userFeerate: + self._logger.warn('userFeerate') + self._userFeerate = userFeerate + as_decimal = Decimal(userFeerate) if userFeerate else 0 + user_feerate = int(as_decimal * 1000) + self._fee_policy = FeePolicy(f'feerate:{user_feerate}') + self.userFeerateChanged.emit() + self.update() + rbfChanged = pyqtSignal() @pyqtProperty(bool, notify=rbfChanged) def rbf(self): @@ -255,6 +295,17 @@ class TxFeeSlider(FeeSlider): self.update_inputs_from_tx(tx) self.update_outputs_from_tx(tx) + self.update_target() + self.update_manual_fields() + + def update_manual_fields(self): + if self._fee_method == FeeSlider.FSMethod.MANUAL: + if self._fee_policy.method == FeeMethod.FIXED: + self._userFeerate = self.feeRate + self.userFeerateChanged.emit() + else: + self._userFee = self.fee.satsStr + self.userFeeChanged.emit() def update_inputs_from_tx(self, tx: Transaction): inputs = [] @@ -308,6 +359,14 @@ class TxFeeSlider(FeeSlider): else: self.warning = '' + def save_config(self): + if self._fee_method == FeeSlider.FSMethod.MANUAL: + if self.fee: + self.userFee = self.fee.satsStr + if self.feeRate: + self.userFeerate = self.feeRate + super().save_config() + class QETxFinalizer(TxFeeSlider): _logger = get_logger(__name__) @@ -417,6 +476,11 @@ class QETxFinalizer(TxFeeSlider): self._valid = False self.validChanged.emit() return + except NoDynamicFeeEstimates: + self.warning = _('No dynamic fee estimates available') + self._valid = False + self.validChanged.emit() + return except Exception as e: self._logger.error(str(e)) self.warning = repr(e) @@ -661,12 +725,16 @@ class QETxRbfFeeBumper(TxFeeSlider, TxMonMixin): # not initialized yet return - fee_per_kb = self._fee_policy.fee_per_kb(self._wallet.wallet.network) - if fee_per_kb is None: - # dynamic method and no network - self._logger.debug('no fee_per_kb') - self.warning = _('Cannot determine dynamic fees, not connected') - return + if self._fee_policy.method == FeeMethod.FIXED: + fee = self._fee_policy.value + fee_per_kb = 1000 * fee / self._orig_tx.estimated_size() + else: + fee_per_kb = self._fee_policy.fee_per_kb(self._wallet.wallet.network) + if fee_per_kb is None: + # dynamic method and no network + self._logger.debug('no fee_per_kb') + self.warning = _('Cannot determine dynamic fees, not connected') + return new_fee_rate = fee_per_kb / 1000 if new_fee_rate <= float(self._oldfee_rate): @@ -768,12 +836,16 @@ class QETxCanceller(TxFeeSlider, TxMonMixin): # not initialized yet return - fee_per_kb = self._fee_policy.fee_per_kb(self._wallet.wallet.network) - if fee_per_kb is None: - # dynamic method and no network - self._logger.debug('no fee_per_kb') - self.warning = _('Cannot determine dynamic fees, not connected') - return + if self._fee_policy.method == FeeMethod.FIXED: + fee = self._fee_policy.value + fee_per_kb = 1000 * fee / self._orig_tx.estimated_size() + else: + fee_per_kb = self._fee_policy.fee_per_kb(self._wallet.wallet.network) + if fee_per_kb is None: + # dynamic method and no network + self._logger.debug('no fee_per_kb') + self.warning = _('Cannot determine dynamic fees, not connected') + return new_fee_rate = fee_per_kb / 1000 if new_fee_rate <= float(self._oldfee_rate): @@ -782,6 +854,13 @@ class QETxCanceller(TxFeeSlider, TxMonMixin): self.warning = _("The new fee rate needs to be higher than the old fee rate.") return + if fee_per_kb < self._wallet.wallet.relayfee(): + self._valid = False + self.validChanged.emit() + self._logger.warning('feerate too low for relay') + self.warning = messages.MSG_RELAYFEE + return + try: self._tx = self._wallet.wallet.dscancel( tx=self._orig_tx, @@ -895,11 +974,11 @@ 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) - child_fee = package_fee - self._parent_fee + return self.get_child_fee_from_total_fee(package_fee) + + def get_child_fee_from_total_fee(self, fee: int) -> int: + child_fee = 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): @@ -922,19 +1001,21 @@ class QETxCpfpFeeBumper(TxFeeSlider, TxMonMixin): self.validChanged.emit() self.warning = '' - fee_per_kb = self._fee_policy.fee_per_kb(self._wallet.wallet.network) - if fee_per_kb is None: - # dynamic method and no network - self._logger.debug('no fee_per_kb') - self.warning = _('Cannot determine dynamic fees, not connected') - return - if self._parent_fee is None: self._logger.error(_("Can't CPFP: unknown fee for parent transaction.")) self.warning = _("Can't CPFP: unknown fee for parent transaction.") return - fee = self.get_child_fee_from_total_feerate(fee_per_kb=fee_per_kb) + if self._fee_policy.method == FeeMethod.FIXED: + fee = self.get_child_fee_from_total_fee(self._fee_policy.value) + else: + fee_per_kb = self._fee_policy.fee_per_kb(self._wallet.wallet.network) + if fee_per_kb is None: + # dynamic method and no network + self._logger.debug('no fee_per_kb') + self.warning = _('Cannot determine dynamic fees, not connected') + return + fee = self.get_child_fee_from_total_feerate(fee_per_kb=fee_per_kb) if fee is None: self._logger.warning('no fee') @@ -944,10 +1025,20 @@ class QETxCpfpFeeBumper(TxFeeSlider, TxMonMixin): self._logger.warning('max fee exceeded') self.warning = _('Max fee exceeded') return + min_child_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=self._wallet.wallet.relayfee(), size=self._total_size) + if fee < min_child_fee: + self._logger.warning('feerate too low for relay') + self.warning = messages.MSG_RELAYFEE + return comb_fee = fee + self._parent_fee comb_feerate = comb_fee / self._total_size + if comb_feerate < (self._parent_fee / self._parent_tx_size): + self._logger.debug('combined feerate below parent tx feerate') + self.warning = _('Combined feerate should be greater than the parent tx feerate') + return + self._fee.satsInt = fee self._output_amount.satsInt = self._max_fee - fee self.outputAmountChanged.emit() @@ -967,10 +1058,21 @@ class QETxCpfpFeeBumper(TxFeeSlider, TxMonMixin): self.update_inputs_from_tx(self._new_tx) self.update_outputs_from_tx(self._new_tx) + self.update_target() + self.update_manual_fields() self._valid = True self.validChanged.emit() + def update_manual_fields(self): + if self._fee_method == FeeSlider.FSMethod.MANUAL: + if self._fee_policy.method == FeeMethod.FIXED: + self._userFeerate = self._total_fee_rate + self.userFeerateChanged.emit() + else: + self._userFee = self._total_fee.satsStr + self.userFeeChanged.emit() + @pyqtSlot(result=str) def getNewTx(self): return str(self._new_tx) diff --git a/electrum/util.py b/electrum/util.py index 9cc1f334d..8e5a391a1 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -891,6 +891,7 @@ UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE = "sat/vbyte" UI_UNIT_NAME_FEERATE_SAT_PER_VB = "sat/vB" UI_UNIT_NAME_TXSIZE_VBYTES = "vbytes" UI_UNIT_NAME_MEMPOOL_MB = "vMB" +UI_UNIT_NAME_FIXED_SAT = "sat" def format_fee_satoshis(fee, *, num_zeros=0, precision=None):