From 7ef605ee5c5a9598577d302d81f497d4e3944bb3 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 10 Sep 2025 16:44:13 +0200 Subject: [PATCH 1/5] 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): From fffcf4a90bf5804a42f9ed4f65abf386ef5ece84 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 17 Dec 2025 12:09:07 +0100 Subject: [PATCH 2/5] qml: add FeePicker manual fee/feerate input validators --- electrum/gui/qml/components/controls/FeePicker.qml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/electrum/gui/qml/components/controls/FeePicker.qml b/electrum/gui/qml/components/controls/FeePicker.qml index dedb03ca2..932e9afa1 100644 --- a/electrum/gui/qml/components/controls/FeePicker.qml +++ b/electrum/gui/qml/components/controls/FeePicker.qml @@ -140,6 +140,9 @@ Item { Layout.fillWidth: true text: finalizer.userFeerate inputMethodHints: Qt.ImhDigitsOnly + validator: RegularExpressionValidator { + regularExpression: /^[0-9]*\.[0-9]?$/ + } onTextEdited: { finalizer.userFeerate = text } @@ -156,6 +159,9 @@ Item { Layout.fillWidth: true text: finalizer.userFee inputMethodHints: Qt.ImhDigitsOnly + validator: RegularExpressionValidator { + regularExpression: /^[0-9]*$/ + } onTextEdited: { finalizer.userFee = text } From f387300ab21fab37374b4b1e48eb13c285abecbc Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 12 Jan 2026 17:53:56 +0000 Subject: [PATCH 3/5] qml: FeePicker: use UI_UNIT_NAME constants, instead of hardcoding --- electrum/gui/qml/components/controls/FeePicker.qml | 4 ++-- electrum/gui/qml/qeapp.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qml/components/controls/FeePicker.qml b/electrum/gui/qml/components/controls/FeePicker.qml index 932e9afa1..afbabb110 100644 --- a/electrum/gui/qml/components/controls/FeePicker.qml +++ b/electrum/gui/qml/components/controls/FeePicker.qml @@ -151,7 +151,7 @@ Item { Label { Layout.fillWidth: true color: Material.accentColor - text: qsTr('sat/vbyte') + text: UI_UNIT_NAME.FEERATE_SAT_PER_VBYTE } TextField { @@ -170,7 +170,7 @@ Item { Label { Layout.fillWidth: true color: Material.accentColor - text: qsTr('sat') + text: UI_UNIT_NAME.FIXED_SAT } } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index a4df18b13..db47c89e3 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -553,6 +553,7 @@ class ElectrumQmlApplication(QGuiApplication): self.context.setContextProperty('UI_UNIT_NAME', { "FEERATE_SAT_PER_VBYTE": electrum.util.UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE, "FEERATE_SAT_PER_VB": electrum.util.UI_UNIT_NAME_FEERATE_SAT_PER_VB, + "FIXED_SAT": electrum.util.UI_UNIT_NAME_FIXED_SAT, "TXSIZE_VBYTES": electrum.util.UI_UNIT_NAME_TXSIZE_VBYTES, "MEMPOOL_MB": electrum.util.UI_UNIT_NAME_MEMPOOL_MB, }) From 65f245f475eaeb9884bf7b32fab5d9a2f638a951 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 12 Jan 2026 18:19:51 +0000 Subject: [PATCH 4/5] qml: FeePicker: hide "Target" line in "Manual" mode instead use font colors to hint which textedit is being used for target --- electrum/gui/qml/components/controls/FeePicker.qml | 8 ++++++-- electrum/gui/qml/qetxfinalizer.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qml/components/controls/FeePicker.qml b/electrum/gui/qml/components/controls/FeePicker.qml index afbabb110..df97d5bef 100644 --- a/electrum/gui/qml/components/controls/FeePicker.qml +++ b/electrum/gui/qml/components/controls/FeePicker.qml @@ -74,14 +74,14 @@ Item { Layout.preferredWidth: 1 text: targetLabel color: Material.accentColor - visible: showPicker + visible: showPicker && !manualFeeEntry } Label { Layout.fillWidth: true Layout.preferredWidth: 2 text: finalizer.target - visible: showPicker + visible: showPicker && !manualFeeEntry } RowLayout { @@ -122,6 +122,7 @@ Item { } Label { + Layout.fillWidth: true Layout.preferredWidth: 1 text: qsTr('Rate') color: Material.accentColor @@ -139,6 +140,7 @@ Item { id: rate Layout.fillWidth: true text: finalizer.userFeerate + color: finalizer.isUserFeerateLast ? Material.foreground : Material.accentColor inputMethodHints: Qt.ImhDigitsOnly validator: RegularExpressionValidator { regularExpression: /^[0-9]*\.[0-9]?$/ @@ -158,6 +160,7 @@ Item { id: absolute Layout.fillWidth: true text: finalizer.userFee + color: finalizer.isUserFeerateLast ? Material.accentColor : Material.foreground inputMethodHints: Qt.ImhDigitsOnly validator: RegularExpressionValidator { regularExpression: /^[0-9]*$/ @@ -175,6 +178,7 @@ Item { } Label { + Layout.fillWidth: true Layout.preferredWidth: 1 visible: showPicker && manualFeeEntry color: Material.accentColor diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index dcc6b60e2..bf1200498 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -158,6 +158,7 @@ class TxFeeSlider(FeeSlider): self._feeRate = '' self._userFee = '' self._userFeerate = '' + self._is_user_feerate_last = True self._rbf = False self._tx = None # type: Optional[PartialTransaction] self._inputs = [] @@ -201,6 +202,7 @@ class TxFeeSlider(FeeSlider): user_fee = int(userFee) if userFee else 0 self._fee_policy = FeePolicy(f'fixed:{user_fee}') self.userFeeChanged.emit() + self.isUserFeerateLast = False self.update() userFeerateChanged = pyqtSignal() @@ -217,8 +219,20 @@ class TxFeeSlider(FeeSlider): user_feerate = int(as_decimal * 1000) self._fee_policy = FeePolicy(f'feerate:{user_feerate}') self.userFeerateChanged.emit() + self.isUserFeerateLast = True self.update() + isUserFeerateLastChanged = pyqtSignal() + @pyqtProperty(bool, notify=isUserFeerateLastChanged) + def isUserFeerateLast(self): + return self._is_user_feerate_last + + @isUserFeerateLast.setter + def isUserFeerateLast(self, isUserFeerateLast): + if self._is_user_feerate_last != isUserFeerateLast: + self._is_user_feerate_last = isUserFeerateLast + self.isUserFeerateLastChanged.emit() + rbfChanged = pyqtSignal() @pyqtProperty(bool, notify=rbfChanged) def rbf(self): From 8a3d9fd75869be974a429c8be28c873f9b5c8a82 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Wed, 14 Jan 2026 16:51:45 +0000 Subject: [PATCH 5/5] qml: FeePicker: restrict abs/rate editing to mimic wallet.bump_fee/cpfp --- .../gui/qml/components/CpfpBumpFeeDialog.qml | 1 + .../gui/qml/components/RbfBumpFeeDialog.qml | 2 +- .../gui/qml/components/RbfCancelDialog.qml | 2 +- .../gui/qml/components/controls/FeePicker.qml | 48 +++++++++++-------- 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/electrum/gui/qml/components/CpfpBumpFeeDialog.qml b/electrum/gui/qml/components/CpfpBumpFeeDialog.qml index d68f9edc7..1c8f7b127 100644 --- a/electrum/gui/qml/components/CpfpBumpFeeDialog.qml +++ b/electrum/gui/qml/components/CpfpBumpFeeDialog.qml @@ -147,6 +147,7 @@ ElDialog { Layout.fillWidth: true finalizer: dialog.cpfpfeebumper showTxInfo: false + allowPickerFeeRates: false } } } diff --git a/electrum/gui/qml/components/RbfBumpFeeDialog.qml b/electrum/gui/qml/components/RbfBumpFeeDialog.qml index a96add281..4d9fdcf9c 100644 --- a/electrum/gui/qml/components/RbfBumpFeeDialog.qml +++ b/electrum/gui/qml/components/RbfBumpFeeDialog.qml @@ -116,7 +116,7 @@ ElDialog { id: feepicker width: parent.width finalizer: dialog.rbffeebumper - + allowPickerAbsFees: false } } diff --git a/electrum/gui/qml/components/RbfCancelDialog.qml b/electrum/gui/qml/components/RbfCancelDialog.qml index b39100413..b8832b10d 100644 --- a/electrum/gui/qml/components/RbfCancelDialog.qml +++ b/electrum/gui/qml/components/RbfCancelDialog.qml @@ -88,7 +88,7 @@ ElDialog { id: feepicker width: parent.width finalizer: dialog.txcanceller - + allowPickerAbsFees: false } } diff --git a/electrum/gui/qml/components/controls/FeePicker.qml b/electrum/gui/qml/components/controls/FeePicker.qml index df97d5bef..0fe96b734 100644 --- a/electrum/gui/qml/components/controls/FeePicker.qml +++ b/electrum/gui/qml/components/controls/FeePicker.qml @@ -18,6 +18,8 @@ Item { property bool showTxInfo: true property bool showPicker: true + property bool allowPickerAbsFees: true + property bool allowPickerFeeRates: true property bool manualFeeEntry: finalizer.method == FeeSlider.FSMethod.MANUAL @@ -121,24 +123,22 @@ Item { } } - Label { + RowLayout { + Layout.columnSpan: 2 Layout.fillWidth: true - Layout.preferredWidth: 1 - text: qsTr('Rate') - color: Material.accentColor - visible: showPicker && manualFeeEntry - } + visible: showPicker && manualFeeEntry && allowPickerFeeRates - GridLayout { - Layout.preferredWidth: 2 - Layout.rowSpan: 2 - visible: showPicker && manualFeeEntry - columns: 2 - columnSpacing: constants.paddingMedium + Label { + Layout.fillWidth: true + Layout.preferredWidth: 1 + text: qsTr('Rate') + color: Material.accentColor + } TextField { id: rate Layout.fillWidth: true + Layout.preferredWidth: 2 text: finalizer.userFeerate color: finalizer.isUserFeerateLast ? Material.foreground : Material.accentColor inputMethodHints: Qt.ImhDigitsOnly @@ -152,13 +152,28 @@ Item { Label { Layout.fillWidth: true + Layout.preferredWidth: 1 color: Material.accentColor text: UI_UNIT_NAME.FEERATE_SAT_PER_VBYTE } + } + + RowLayout { + Layout.columnSpan: 2 + Layout.fillWidth: true + visible: showPicker && manualFeeEntry && allowPickerAbsFees + + Label { + Layout.fillWidth: true + Layout.preferredWidth: 1 + color: Material.accentColor + text: qsTr('Total') + } TextField { id: absolute Layout.fillWidth: true + Layout.preferredWidth: 2 text: finalizer.userFee color: finalizer.isUserFeerateLast ? Material.accentColor : Material.foreground inputMethodHints: Qt.ImhDigitsOnly @@ -172,18 +187,11 @@ Item { Label { Layout.fillWidth: true + Layout.preferredWidth: 1 color: Material.accentColor text: UI_UNIT_NAME.FIXED_SAT } } - Label { - Layout.fillWidth: true - Layout.preferredWidth: 1 - visible: showPicker && manualFeeEntry - color: Material.accentColor - text: qsTr('Total') - } - } }