diff --git a/electrum/gui/qml/components/CpfpBumpFeeDialog.qml b/electrum/gui/qml/components/CpfpBumpFeeDialog.qml new file mode 100644 index 000000000..158fe5956 --- /dev/null +++ b/electrum/gui/qml/components/CpfpBumpFeeDialog.qml @@ -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() + } + } + } + +} diff --git a/electrum/gui/qml/components/TxDetails.qml b/electrum/gui/qml/components/TxDetails.qml index 987e20e78..159f937ae 100644 --- a/electrum/gui/qml/components/TxDetails.qml +++ b/electrum/gui/qml/components/TxDetails.qml @@ -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 { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index f0ff649cf..fc11f6bf6 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -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') diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index ece802e12..8e9a44678 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -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)