1
0

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
This commit is contained in:
Sander van Grieken
2025-09-10 16:44:13 +02:00
parent 4cab0ceddd
commit 7ef605ee5c
9 changed files with 211 additions and 42 deletions

View File

@@ -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:

View File

@@ -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.")
])

View File

@@ -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

View File

@@ -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 != ''

View File

@@ -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 != ''

View File

@@ -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)

View File

@@ -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')
}
}
}

View File

@@ -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)

View File

@@ -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):