Browse Source

qml: add initial logic and UI for CPFP

master
Sander van Grieken 3 years ago
parent
commit
78df722419
  1. 259
      electrum/gui/qml/components/CpfpBumpFeeDialog.qml
  2. 27
      electrum/gui/qml/components/TxDetails.qml
  3. 3
      electrum/gui/qml/qeapp.py
  4. 175
      electrum/gui/qml/qetxfinalizer.py

259
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()
}
}
}
}

27
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 {

3
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')

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

Loading…
Cancel
Save