qml: add initial logic and UI for CPFP
This commit is contained in:
259
electrum/gui/qml/components/CpfpBumpFeeDialog.qml
Normal file
259
electrum/gui/qml/components/CpfpBumpFeeDialog.qml
Normal file
@@ -0,0 +1,259 @@
|
||||
import QtQuick 2.6
|
||||
import QtQuick.Layouts 1.0
|
||||
import QtQuick.Controls 2.14
|
||||
import QtQuick.Controls.Material 2.0
|
||||
|
||||
import org.electrum 1.0
|
||||
|
||||
import "controls"
|
||||
|
||||
//TODO: listen to tx to be bumped, mined = abort this
|
||||
|
||||
ElDialog {
|
||||
id: dialog
|
||||
|
||||
required property string txid
|
||||
required property QtObject cpfpfeebumper
|
||||
|
||||
signal txaccepted
|
||||
|
||||
title: qsTr('Bump Fee')
|
||||
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
padding: 0
|
||||
|
||||
standardButtons: Dialog.Cancel
|
||||
|
||||
modal: true
|
||||
parent: Overlay.overlay
|
||||
Overlay.modal: Rectangle {
|
||||
color: "#aa000000"
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
GridLayout {
|
||||
Layout.preferredWidth: parent.width
|
||||
Layout.leftMargin: constants.paddingLarge
|
||||
Layout.rightMargin: constants.paddingLarge
|
||||
columns: 2
|
||||
|
||||
Label {
|
||||
Layout.columnSpan: 2
|
||||
Layout.fillWidth: true
|
||||
text: qsTr('A CPFP is a transaction that sends an unconfirmed output back to yourself, with a high fee. The goal is to have miners confirm the parent transaction in order to get the fee attached to the child transaction.')
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Label {
|
||||
Layout.columnSpan: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.bottomMargin: constants.paddingLarge
|
||||
text: qsTr('The proposed fee is computed using your fee/kB settings, applied to the total size of both child and parent transactions. After you broadcast a CPFP transaction, it is normal to see a new unconfirmed transaction in your history.')
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Label {
|
||||
Layout.preferredWidth: 1
|
||||
Layout.fillWidth: true
|
||||
text: qsTr('Total size')
|
||||
color: Material.accentColor
|
||||
}
|
||||
|
||||
Label {
|
||||
Layout.preferredWidth: 1
|
||||
Layout.fillWidth: true
|
||||
text: qsTr('%1 bytes').arg(cpfpfeebumper.totalSize)
|
||||
}
|
||||
|
||||
Label {
|
||||
text: qsTr('Input amount')
|
||||
color: Material.accentColor
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Label {
|
||||
text: Config.formatSats(cpfpfeebumper.inputAmount)
|
||||
}
|
||||
|
||||
Label {
|
||||
text: Config.baseUnit
|
||||
color: Material.accentColor
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: qsTr('Output amount')
|
||||
color: Material.accentColor
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Label {
|
||||
text: cpfpfeebumper.valid ? Config.formatSats(cpfpfeebumper.outputAmount) : ''
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: cpfpfeebumper.valid
|
||||
text: Config.baseUnit
|
||||
color: Material.accentColor
|
||||
}
|
||||
}
|
||||
|
||||
Slider {
|
||||
id: feeslider
|
||||
leftPadding: constants.paddingMedium
|
||||
snapMode: Slider.SnapOnRelease
|
||||
stepSize: 1
|
||||
from: 0
|
||||
to: cpfpfeebumper.sliderSteps
|
||||
onValueChanged: {
|
||||
if (activeFocus)
|
||||
cpfpfeebumper.sliderPos = value
|
||||
}
|
||||
Component.onCompleted: {
|
||||
value = cpfpfeebumper.sliderPos
|
||||
}
|
||||
Connections {
|
||||
target: cpfpfeebumper
|
||||
function onSliderPosChanged() {
|
||||
feeslider.value = cpfpfeebumper.sliderPos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FeeMethodComboBox {
|
||||
id: feemethod
|
||||
feeslider: cpfpfeebumper
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: feemethod.currentValue
|
||||
text: qsTr('Target')
|
||||
color: Material.accentColor
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: feemethod.currentValue
|
||||
text: cpfpfeebumper.target
|
||||
}
|
||||
|
||||
Label {
|
||||
text: qsTr('Fee for child')
|
||||
color: Material.accentColor
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Label {
|
||||
id: fee
|
||||
text: cpfpfeebumper.valid ? Config.formatSats(cpfpfeebumper.feeForChild) : ''
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: cpfpfeebumper.valid
|
||||
text: Config.baseUnit
|
||||
color: Material.accentColor
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: qsTr('Total fee')
|
||||
color: Material.accentColor
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Label {
|
||||
text: cpfpfeebumper.valid ? Config.formatSats(cpfpfeebumper.totalFee) : ''
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: cpfpfeebumper.valid
|
||||
text: Config.baseUnit
|
||||
color: Material.accentColor
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: qsTr('Total fee rate')
|
||||
color: Material.accentColor
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Label {
|
||||
text: cpfpfeebumper.valid ? cpfpfeebumper.totalFeeRate : ''
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: cpfpfeebumper.valid
|
||||
text: 'sat/vB'
|
||||
color: Material.accentColor
|
||||
}
|
||||
}
|
||||
|
||||
InfoTextArea {
|
||||
Layout.columnSpan: 2
|
||||
Layout.preferredWidth: parent.width * 3/4
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.topMargin: constants.paddingLarge
|
||||
visible: cpfpfeebumper.warning != ''
|
||||
text: cpfpfeebumper.warning
|
||||
iconStyle: InfoTextArea.IconStyle.Warn
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: cpfpfeebumper.valid
|
||||
text: qsTr('Outputs')
|
||||
Layout.columnSpan: 2
|
||||
color: Material.accentColor
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: cpfpfeebumper.valid ? cpfpfeebumper.outputs : []
|
||||
delegate: TextHighlightPane {
|
||||
Layout.columnSpan: 2
|
||||
Layout.fillWidth: true
|
||||
padding: 0
|
||||
leftPadding: constants.paddingSmall
|
||||
RowLayout {
|
||||
width: parent.width
|
||||
Label {
|
||||
text: modelData.address
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.Wrap
|
||||
font.pixelSize: constants.fontSizeLarge
|
||||
font.family: FixedFont
|
||||
color: modelData.is_mine ? constants.colorMine : Material.foreground
|
||||
}
|
||||
Label {
|
||||
text: Config.formatSats(modelData.value_sats)
|
||||
font.pixelSize: constants.fontSizeMedium
|
||||
font.family: FixedFont
|
||||
}
|
||||
Label {
|
||||
text: Config.baseUnit
|
||||
font.pixelSize: constants.fontSizeMedium
|
||||
color: Material.accentColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true; Layout.preferredWidth: 1 }
|
||||
|
||||
FlatButton {
|
||||
id: sendButton
|
||||
Layout.fillWidth: true
|
||||
text: qsTr('Ok')
|
||||
icon.source: '../../icons/confirmed.png'
|
||||
enabled: cpfpfeebumper.valid
|
||||
onClicked: {
|
||||
txaccepted()
|
||||
dialog.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -400,6 +400,16 @@ Pane {
|
||||
}
|
||||
}
|
||||
|
||||
FlatButton {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr('Bump fee (CPFP)')
|
||||
visible: txdetails.canCpfp
|
||||
onClicked: {
|
||||
var dialog = cpfpBumpFeeDialog.createObject(root, { txid: root.txid })
|
||||
dialog.open()
|
||||
}
|
||||
}
|
||||
|
||||
FlatButton {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr('Cancel Tx')
|
||||
@@ -461,6 +471,23 @@ Pane {
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: cpfpBumpFeeDialog
|
||||
CpfpBumpFeeDialog {
|
||||
id: dialog
|
||||
cpfpfeebumper: TxCpfpFeeBumper {
|
||||
id: cpfpfeebumper
|
||||
wallet: Daemon.currentWallet
|
||||
txid: dialog.txid
|
||||
}
|
||||
|
||||
onTxaccepted: {
|
||||
root.rawtx = cpfpfeebumper.getNewTx() // TODO: don't replace tx, but push new window
|
||||
}
|
||||
onClosed: destroy()
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: rbfCancelDialog
|
||||
RbfCancelDialog {
|
||||
|
||||
@@ -19,7 +19,7 @@ from .qeqr import QEQRParser, QEQRImageProvider, QEQRImageProviderHelper
|
||||
from .qewalletdb import QEWalletDB
|
||||
from .qebitcoin import QEBitcoin
|
||||
from .qefx import QEFX
|
||||
from .qetxfinalizer import QETxFinalizer, QETxRbfFeeBumper, QETxCanceller
|
||||
from .qetxfinalizer import QETxFinalizer, QETxRbfFeeBumper, QETxCpfpFeeBumper, QETxCanceller
|
||||
from .qeinvoice import QEInvoice, QEInvoiceParser, QEUserEnteredPayment
|
||||
from .qerequestdetails import QERequestDetails
|
||||
from .qetypes import QEAmount
|
||||
@@ -217,6 +217,7 @@ class ElectrumQmlApplication(QGuiApplication):
|
||||
qmlRegisterType(QESwapHelper, 'org.electrum', 1, 0, 'SwapHelper')
|
||||
qmlRegisterType(QERequestDetails, 'org.electrum', 1, 0, 'RequestDetails')
|
||||
qmlRegisterType(QETxRbfFeeBumper, 'org.electrum', 1, 0, 'TxRbfFeeBumper')
|
||||
qmlRegisterType(QETxCpfpFeeBumper, 'org.electrum', 1, 0, 'TxCpfpFeeBumper')
|
||||
qmlRegisterType(QETxCanceller, 'org.electrum', 1, 0, 'TxCanceller')
|
||||
|
||||
qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
|
||||
|
||||
@@ -202,6 +203,9 @@ class TxFeeSlider(FeeSlider):
|
||||
self.fee = QEAmount(amount_sat=int(fee))
|
||||
self.feeRate = f'{feerate:.1f}'
|
||||
|
||||
self.update_outputs_from_tx(tx)
|
||||
|
||||
def update_outputs_from_tx(self, tx):
|
||||
outputs = []
|
||||
for o in tx.outputs():
|
||||
outputs.append({
|
||||
@@ -615,3 +619,174 @@ class QETxCanceller(TxFeeSlider):
|
||||
@pyqtSlot(result=str)
|
||||
def getNewTx(self):
|
||||
return str(self._tx)
|
||||
|
||||
class QETxCpfpFeeBumper(TxFeeSlider):
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
_input_amount = QEAmount()
|
||||
_output_amount = QEAmount()
|
||||
_fee_for_child = QEAmount()
|
||||
_total_fee = QEAmount()
|
||||
_total_fee_rate = 0
|
||||
_total_size = 0
|
||||
|
||||
_parent_tx = None
|
||||
_new_tx = None
|
||||
_parent_tx_size = 0
|
||||
_parent_fee = 0
|
||||
_max_fee = 0
|
||||
_txid = ''
|
||||
_rbf = True
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
txidChanged = pyqtSignal()
|
||||
@pyqtProperty(str, notify=txidChanged)
|
||||
def txid(self):
|
||||
return self._txid
|
||||
|
||||
@txid.setter
|
||||
def txid(self, txid):
|
||||
if self._txid != txid:
|
||||
self._txid = txid
|
||||
self.get_tx()
|
||||
self.txidChanged.emit()
|
||||
|
||||
totalFeeChanged = pyqtSignal()
|
||||
@pyqtProperty(QEAmount, notify=totalFeeChanged)
|
||||
def totalFee(self):
|
||||
return self._total_fee
|
||||
|
||||
@totalFee.setter
|
||||
def totalFee(self, totalfee):
|
||||
if self._total_fee != totalfee:
|
||||
self._total_fee.copyFrom(totalfee)
|
||||
self.totalFeeChanged.emit()
|
||||
|
||||
totalFeeRateChanged = pyqtSignal()
|
||||
@pyqtProperty(str, notify=totalFeeRateChanged)
|
||||
def totalFeeRate(self):
|
||||
return self._total_fee_rate
|
||||
|
||||
@totalFeeRate.setter
|
||||
def totalFeeRate(self, totalfeerate):
|
||||
if self._total_fee_rate != totalfeerate:
|
||||
self._total_fee_rate = totalfeerate
|
||||
self.totalFeeRateChanged.emit()
|
||||
|
||||
feeForChildChanged = pyqtSignal()
|
||||
@pyqtProperty(QEAmount, notify=feeForChildChanged)
|
||||
def feeForChild(self):
|
||||
return self._fee_for_child
|
||||
|
||||
@feeForChild.setter
|
||||
def feeForChild(self, feeforchild):
|
||||
if self._fee_for_child != feeforchild:
|
||||
self._fee_for_child.copyFrom(feeforchild)
|
||||
self.feeForChildChanged.emit()
|
||||
|
||||
inputAmountChanged = pyqtSignal()
|
||||
@pyqtProperty(QEAmount, notify=inputAmountChanged)
|
||||
def inputAmount(self):
|
||||
return self._input_amount
|
||||
|
||||
outputAmountChanged = pyqtSignal()
|
||||
@pyqtProperty(QEAmount, notify=outputAmountChanged)
|
||||
def outputAmount(self):
|
||||
return self._output_amount
|
||||
|
||||
totalSizeChanged = pyqtSignal()
|
||||
@pyqtProperty(int, notify=totalSizeChanged)
|
||||
def totalSize(self):
|
||||
return self._total_size
|
||||
|
||||
|
||||
def get_tx(self):
|
||||
assert self._txid
|
||||
self._parent_tx = self._wallet.wallet.get_input_tx(self._txid)
|
||||
assert self._parent_tx
|
||||
|
||||
if isinstance(self._parent_tx, PartialTransaction):
|
||||
self._logger.error('unexpected PartialTransaction')
|
||||
return
|
||||
|
||||
self._parent_tx_size = self._parent_tx.estimated_size()
|
||||
self._parent_fee = self._wallet.wallet.adb.get_tx_fee(self._txid)
|
||||
|
||||
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
|
||||
|
||||
self._new_tx = self._wallet.wallet.cpfp(self._parent_tx, 0)
|
||||
self._total_size = self._parent_tx_size + self._new_tx.estimated_size()
|
||||
self.totalSizeChanged.emit()
|
||||
self._max_fee = self._new_tx.output_value()
|
||||
self._input_amount.satsInt = self._max_fee
|
||||
|
||||
self.update()
|
||||
|
||||
def get_child_fee_from_total_feerate(self, fee_per_kb: Optional[int]) -> Optional[int]:
|
||||
if fee_per_kb is None:
|
||||
return None
|
||||
fee = fee_per_kb * self._total_size / 1000 - self._parent_fee
|
||||
fee = round(fee)
|
||||
fee = min(self._max_fee, fee)
|
||||
fee = max(self._total_size, fee) # pay at least 1 sat/byte for combined size
|
||||
return fee
|
||||
|
||||
def update(self):
|
||||
if not self._txid: # not initialized yet
|
||||
return
|
||||
|
||||
assert self._parent_tx
|
||||
|
||||
self._valid = False
|
||||
self.validChanged.emit()
|
||||
self.warning = ''
|
||||
|
||||
fee_per_kb = self._config.fee_per_kb()
|
||||
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')
|
||||
self.warning = _('No fee')
|
||||
return
|
||||
if fee > self._max_fee:
|
||||
self._logger.warning('max fee exceeded')
|
||||
self.warning = _('Max fee exceeded')
|
||||
return
|
||||
|
||||
|
||||
comb_fee = fee + self._parent_fee
|
||||
comb_feerate = comb_fee / self._total_size
|
||||
|
||||
self._fee_for_child.satsInt = fee
|
||||
self._output_amount.satsInt = self._max_fee - fee
|
||||
self.outputAmountChanged.emit()
|
||||
|
||||
self._total_fee.satsInt = fee + self._parent_fee
|
||||
self._total_fee_rate = f'{comb_feerate:.1f}'
|
||||
|
||||
try:
|
||||
self._new_tx = self._wallet.wallet.cpfp(self._parent_tx, fee)
|
||||
except CannotCPFP as e:
|
||||
self._logger.error(str(e))
|
||||
self.warning = str(e)
|
||||
return
|
||||
|
||||
self.update_outputs_from_tx(self._new_tx)
|
||||
|
||||
self._valid = True
|
||||
self.validChanged.emit()
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def getNewTx(self):
|
||||
return str(self._new_tx)
|
||||
|
||||
Reference in New Issue
Block a user