From 679bd21bac772f34806ec4883a4cb83be5f6a5ee Mon Sep 17 00:00:00 2001 From: djboi <73574557+dhruv-joshi-7@users.noreply.github.com> Date: Wed, 15 Sep 2021 21:02:24 +0530 Subject: [PATCH] Added CPFP Feature for Kivy GUI (#7487) implements https://github.com/spesmilo/electrum/issues/5507 --- electrum/gui/kivy/uix/dialogs/cpfp_dialog.py | 153 +++++++++++++++++++ electrum/gui/kivy/uix/dialogs/tx_dialog.py | 39 ++++- 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 electrum/gui/kivy/uix/dialogs/cpfp_dialog.py diff --git a/electrum/gui/kivy/uix/dialogs/cpfp_dialog.py b/electrum/gui/kivy/uix/dialogs/cpfp_dialog.py new file mode 100644 index 000000000..5a5aad44d --- /dev/null +++ b/electrum/gui/kivy/uix/dialogs/cpfp_dialog.py @@ -0,0 +1,153 @@ +from typing import TYPE_CHECKING, Optional + +from kivy.app import App +from kivy.factory import Factory +from kivy.properties import ObjectProperty +from kivy.lang import Builder + +from electrum.gui.kivy.i18n import _ + +if TYPE_CHECKING: + from ...main_window import ElectrumWindow + +from .fee_dialog import FeeSliderDialog +from electrum.gui.qt.amountedit import BTCAmountEdit +from electrum.util import format_satoshis_plain + +Builder.load_string(''' +<CPFPDialog@Popup> + title: _('Child Pays for Parent') + size_hint: 0.8, 0.8 + pos_hint: {'top':0.9} + method: 0 + BoxLayout: + orientation: 'vertical' + padding: '10dp' + GridLayout: + height: self.minimum_height + size_hint_y: None + cols: 1 + spacing: '10dp' + TopLabel: + text: + _(\ + "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.") + BoxLabel: + id: total_size + text: _('Total Size') + value: '' + BoxLabel: + id: input_amount + text: _('Input amount') + value: '' + BoxLabel: + id: output_amount + text: _('Output amount') + value: '' + BoxLabel: + id: fee_for_child + text: _('Fee for child') + value: '' + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Label: + text: _('Target') + ' (%s):' % (_('mempool') if root.method == 2 else _('ETA') if root.method == 1 else _('static')) + Button: + id: fee_target + text: '' + background_color: (0,0,0,0) + bold: True + on_release: + root.method = (root.method + 1) % 3 + root.update_slider() + root.on_slider(root.slider.value) + + Slider: + id: slider + range: 0, 4 + step: 1 + on_value: root.on_slider(self.value) + GridLayout: + height: self.minimum_height + size_hint_y: None + cols: 1 + spacing: '10dp' + BoxLabel: + id: total_fee + text: _('Total fee') + value: '' + BoxLabel: + id: total_feerate + text: _('Total feerate') + value: '' + Widget: + size_hint: 1, 1 + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Button: + text: 'Cancel' + size_hint: 0.5, None + height: '48dp' + on_release: root.dismiss() + Button: + text: 'OK' + size_hint: 0.5, None + height: '48dp' + on_release: + root.dismiss() + root.on_ok() +''') + +class CPFPDialog(FeeSliderDialog, Factory.Popup): + + def __init__(self, app: 'ElectrumWindow', parent_fee, total_size, new_tx, callback): + self.app = app + self.parent_fee = parent_fee + self.total_size = total_size + self.new_tx = new_tx + self.max_fee = self.new_tx.output_value() + Factory.Popup.__init__(self) + FeeSliderDialog.__init__(self, app.electrum_config, self.ids.slider) + self.callback = callback + self.config = app.electrum_config + self.ids.total_size.value = ('%d bytes'% self.total_size) + self.ids.input_amount.value = self.app.format_amount(self.max_fee) + ' ' + self.app._get_bu() + self.update_slider() + self.update_text() + + 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_text(self): + target, tooltip, dyn = self.config.get_fee_target() + self.ids.fee_target.text = target + fee_per_kb = self.config.fee_per_kb() + self.fee = self.get_child_fee_from_total_feerate(fee_per_kb) + if self.fee is None: + self.ids.fee_for_child.value = "unknown" + else: + comb_fee = self.fee + self.parent_fee + comb_feerate = 1000 * comb_fee / self.total_size + self.ids.fee_for_child.value = self.app.format_amount_and_units(self.fee) + self.ids.output_amount.value = self.app.format_amount_and_units(self.max_fee-self.fee) if self.max_fee > self.fee else '' + self.ids.total_fee.value = self.app.format_amount_and_units(self.fee+self.parent_fee) + self.ids.total_feerate.value = self.app.format_fee_rate(comb_feerate) + + def on_ok(self): + fee = self.fee + self.callback(fee, self.max_fee) + + def on_slider(self, value): + self.save_config() + self.update_text() diff --git a/electrum/gui/kivy/uix/dialogs/tx_dialog.py b/electrum/gui/kivy/uix/dialogs/tx_dialog.py index 0d939244e..3f6486a72 100644 --- a/electrum/gui/kivy/uix/dialogs/tx_dialog.py +++ b/electrum/gui/kivy/uix/dialogs/tx_dialog.py @@ -14,7 +14,7 @@ from kivy.uix.button import Button from electrum.util import InvalidPassword from electrum.address_synchronizer import TX_HEIGHT_LOCAL -from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx +from electrum.wallet import CannotBumpFee, CannotCPFP, CannotDoubleSpendTx from electrum.transaction import Transaction, PartialTransaction from electrum.network import NetworkException @@ -37,6 +37,7 @@ Builder.load_string(''' can_sign: False can_broadcast: False can_rbf: False + can_cpfp: False fee_str: '' feerate_str: '' date_str: '' @@ -145,6 +146,7 @@ class TxDialog(Factory.Popup): self.description = tx_details.label self.can_broadcast = tx_details.can_broadcast self.can_rbf = tx_details.can_bump + self.can_cpfp = tx_details.can_cpfp self.can_dscancel = tx_details.can_dscancel self.tx_hash = tx_details.txid or '' if tx_mined_status.timestamp: @@ -192,6 +194,7 @@ class TxDialog(Factory.Popup): ActionButtonOption(text=_('Sign'), func=lambda btn: self.do_sign(), enabled=self.can_sign), ActionButtonOption(text=_('Broadcast'), func=lambda btn: self.do_broadcast(), enabled=self.can_broadcast), ActionButtonOption(text=_('Bump fee'), func=lambda btn: self.do_rbf(), enabled=self.can_rbf), + ActionButtonOption(text=_('Child pays\nfor parent'), func=lambda btn: self.do_cpfp(), enabled=(not self.can_rbf and self.can_cpfp)), ActionButtonOption(text=_('Cancel') + '\n(double-spend)', func=lambda btn: self.do_dscancel(), enabled=self.can_dscancel), ActionButtonOption(text=_('Remove'), func=lambda btn: self.remove_local_tx(), enabled=self.can_remove_tx), ) @@ -250,6 +253,40 @@ class TxDialog(Factory.Popup): self.update() self.do_sign() + def do_cpfp(self): + from .cpfp_dialog import CPFPDialog + parent_tx = self.tx + new_tx = self.wallet.cpfp(parent_tx, 0) + total_size = parent_tx.estimated_size() + new_tx.estimated_size() + parent_txid = parent_tx.txid() + assert parent_txid + parent_fee = self.wallet.get_tx_fee(parent_txid) + if parent_fee is None: + self.app.show_error(_("Can't CPFP: unknown fee for parent transaction.")) + return + cb = partial(self._do_cpfp, parent_tx=parent_tx) + d = CPFPDialog(self.app, parent_fee, total_size, new_tx=new_tx, callback=cb) + d.open() + + def _do_cpfp( + self, + fee, + max_fee, + *, + parent_tx: Transaction, + ): + if fee is None: + return # fee left empty, treat is as "cancel" + if fee > max_fee: + self.show_error(_('Max fee exceeded')) + return + try: + new_tx = self.wallet.cpfp(parent_tx, fee) + except CannotCPFP as e: + self.app.show_error(str(e)) + return + self.app.tx_dialog(new_tx) + def do_dscancel(self): from .dscancel_dialog import DSCancelDialog tx = self.tx