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:
@@ -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:
|
||||
|
||||
@@ -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.")
|
||||
])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 != ''
|
||||
|
||||
@@ -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 != ''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user