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
What changed, and why it matters
This commit adds a new 'Manual' fee mode to Electrum's QML (mobile-style) GUI, letting users type in an exact fee or fee rate. It also tightens safety checks for fee-bumping actions (CPFP, RBF bump, and RBF cancel) by warning when the new fee is too low to be relayed by the network or, in the case of CPFP, lower than the parent transaction's fee rate. The changes are mostly defensive UI hardening rather than a fix for an active vulnerability.
Review the manual-fee input sanitization (Decimal parsing, empty-string handling, and locale issues) and ensure the new relay-fee checks are consistently applied across all transaction finalizers, including the desktop GUI if it shares the same backend code.
Security signals we found
New manual fee/fee-rate input path in QML GUI
Added relay-fee validation before constructing RBF cancel and CPFP child transactions
Added CPFP combined-fee-rate must exceed parent fee rate check
Added explicit NoDynamicFeeEstimates handling with user-facing warning
Refactored CPFP child-fee calculation to separate total-fee vs total-feerate paths
Evidence from the diff
The patch introduces FeeMethod.FIXED / FeeSlider.FSMethod.MANUAL and wires manual fee/fee-rate text fields into the QML fee picker. It refactors QETxRbfFeeBumper, QETxCanceller, and QETxCpfpFeeBumper to accept fixed-fee policies, adds relay-fee checks for DSCancel and CPFP, adds a ‘combined feerate > parent feerate’ check for CPFP, and improves warning text consistency. Several warning box layout tweaks are included. No cryptographic or consensus bug is evident in the diff.
Changed components
electrum/fee_policy.pyelectrum/gui/messages.pyelectrum/gui/qml/qetxfinalizer.pyelectrum/gui/qml/components/controls/FeePicker.qmlelectrum/gui/qml/components/controls/FeeMethodComboBox.qmlelectrum/gui/qml/components/CpfpBumpFeeDialog.qmlelectrum/gui/qml/components/RbfBumpFeeDialog.qmlelectrum/gui/qml/components/RbfCancelDialog.qmlelectrum/util.pyInspect captured patch +211 / −42
diff --git a/electrum/fee_policy.py b/electrum/fee_policy.py
index 28c5db8..b00f476 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 fc6fc36..bd9639a 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 8361480..d68f9ed 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 f913d7a..a96add2 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 a34f141..b391004 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 5c0927d..edfc850 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 c8dd3b7..dedb03c 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 afd8a95..dcc6b60 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 9cc1f33..8e5a391 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):
Why this scored 32/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.