URI: 
       tMerge pull request #7026 from SomberNight/20210213_wallet_bumpfee - electrum - Electrum Bitcoin wallet
  HTML git clone https://git.parazyd.org/electrum
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
       ---
   DIR commit 6fda9add2825bf6449f891e79c61988e76964fd2
   DIR parent 254f57bce55e11811408d710932dabfb9a1696b9
  HTML Author: ghost43 <somber.night@protonmail.com>
       Date:   Thu, 25 Feb 2021 14:48:50 +0000
       
       Merge pull request #7026 from SomberNight/20210213_wallet_bumpfee
       
       wallet: refactor bump_fee; add new strategy; change Qt dialog to have "advanced" button
       Diffstat:
         M electrum/coinchooser.py             |       2 +-
         M electrum/gui/qt/main_window.py      |      94 ++++---------------------------
         A electrum/gui/qt/rbf_dialog.py       |     213 +++++++++++++++++++++++++++++++
         M electrum/tests/test_transaction.py  |       2 +-
         M electrum/tests/test_wallet_vertica… |      94 ++++++++++++++++++++++++++++++-
         M electrum/transaction.py             |      13 ++++++++++---
         M electrum/wallet.py                  |     137 ++++++++++++++++++++++++++-----
       
       7 files changed, 446 insertions(+), 109 deletions(-)
       ---
   DIR diff --git a/electrum/coinchooser.py b/electrum/coinchooser.py
       t@@ -238,7 +238,7 @@ class CoinChooserBase(Logger):
                    assert is_address(change_addrs[0])
        
                # This takes a count of change outputs and returns a tx fee
       -        output_weight = 4 * Transaction.estimated_output_size(change_addrs[0])
       +        output_weight = 4 * Transaction.estimated_output_size_for_address(change_addrs[0])
                fee_estimator_numchange = lambda count: fee_estimator_w(tx_weight + count * output_weight)
                change = self._change_outputs(tx, change_addrs, fee_estimator_numchange, dust_threshold)
                tx.add_outputs(change)
   DIR diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py
       t@@ -100,6 +100,7 @@ from .update_checker import UpdateCheck, UpdateCheckThread
        from .channels_list import ChannelsList
        from .confirm_tx_dialog import ConfirmTxDialog
        from .transaction_dialog import PreviewTxDialog
       +from .rbf_dialog import BumpFeeDialog, DSCancelDialog
        
        if TYPE_CHECKING:
            from . import ElectrumGui
       t@@ -3264,96 +3265,23 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
                    return False
                return True
        
       -    def _rbf_dialog(self, tx: Transaction, func, title, help_text):
       +    def bump_fee_dialog(self, tx: Transaction):
                txid = tx.txid()
       -        assert txid
                if not isinstance(tx, PartialTransaction):
                    tx = PartialTransaction.from_tx(tx)
                if not self._add_info_to_tx_from_wallet_and_network(tx):
                    return
       -        fee = tx.get_fee()
       -        assert fee is not None
       -        tx_label = self.wallet.get_label_for_txid(txid)
       -        tx_size = tx.estimated_size()
       -        old_fee_rate = fee / tx_size  # sat/vbyte
       -        d = WindowModalDialog(self, title)
       -        vbox = QVBoxLayout(d)
       -        vbox.addWidget(WWLabel(help_text))
       -
       -        ok_button = OkButton(d)
       -        warning_label = WWLabel('\n')
       -        warning_label.setStyleSheet(ColorScheme.RED.as_stylesheet())
       -        feerate_e = FeerateEdit(lambda: 0)
       -        feerate_e.setAmount(max(old_fee_rate * 1.5, old_fee_rate + 1))
       -        def on_feerate():
       -            fee_rate = feerate_e.get_amount()
       -            warning_text = '\n'
       -            if fee_rate is not None:
       -                try:
       -                    new_tx = func(fee_rate)
       -                except Exception as e:
       -                    new_tx = None
       -                    warning_text = str(e).replace('\n',' ')
       -            else:
       -                new_tx = None
       -            ok_button.setEnabled(new_tx is not None)
       -            warning_label.setText(warning_text)
       -
       -        feerate_e.textChanged.connect(on_feerate)
       -        def on_slider(dyn, pos, fee_rate):
       -            fee_slider.activate()
       -            if fee_rate is not None:
       -                feerate_e.setAmount(fee_rate / 1000)
       -        fee_slider = FeeSlider(self, self.config, on_slider)
       -        fee_combo = FeeComboBox(fee_slider)
       -        fee_slider.deactivate()
       -        feerate_e.textEdited.connect(fee_slider.deactivate)
       -
       -        grid = QGridLayout()
       -        grid.addWidget(QLabel(_('Current Fee') + ':'), 0, 0)
       -        grid.addWidget(QLabel(self.format_amount(fee) + ' ' + self.base_unit()), 0, 1)
       -        grid.addWidget(QLabel(_('Current Fee rate') + ':'), 1, 0)
       -        grid.addWidget(QLabel(self.format_fee_rate(1000 * old_fee_rate)), 1, 1)
       -        grid.addWidget(QLabel(_('New Fee rate') + ':'), 2, 0)
       -        grid.addWidget(feerate_e, 2, 1)
       -        grid.addWidget(fee_slider, 3, 1)
       -        grid.addWidget(fee_combo, 3, 2)
       -        vbox.addLayout(grid)
       -        cb = QCheckBox(_('Final'))
       -        vbox.addWidget(cb)
       -        vbox.addWidget(warning_label)
       -        vbox.addLayout(Buttons(CancelButton(d), ok_button))
       -        if not d.exec_():
       -            return
       -        is_final = cb.isChecked()
       -        new_fee_rate = feerate_e.get_amount()
       -        try:
       -            new_tx = func(new_fee_rate)
       -        except Exception as e:
       -            self.show_error(str(e))
       -            return
       -        new_tx.set_rbf(not is_final)
       -        self.show_transaction(new_tx, tx_desc=tx_label)
       -
       -    def bump_fee_dialog(self, tx: Transaction):
       -        title = _('Bump Fee')
       -        help_text = _("Increase your transaction's fee to improve its position in mempool.")
       -        def func(new_fee_rate):
       -            return self.wallet.bump_fee(
       -                tx=tx,
       -                txid=tx.txid(),
       -                new_fee_rate=new_fee_rate,
       -                coins=self.get_coins())
       -        self._rbf_dialog(tx, func, title, help_text)
       +        d = BumpFeeDialog(main_window=self, tx=tx, txid=txid)
       +        d.run()
        
            def dscancel_dialog(self, tx: Transaction):
       -        title = _('Cancel transaction')
       -        help_text = _(
       -            "Cancel an unconfirmed RBF transaction by double-spending "
       -            "its inputs back to your wallet with a higher fee.")
       -        def func(new_fee_rate):
       -            return self.wallet.dscancel(tx=tx, new_fee_rate=new_fee_rate)
       -        self._rbf_dialog(tx, func, title, help_text)
       +        txid = tx.txid()
       +        if not isinstance(tx, PartialTransaction):
       +            tx = PartialTransaction.from_tx(tx)
       +        if not self._add_info_to_tx_from_wallet_and_network(tx):
       +            return
       +        d = DSCancelDialog(main_window=self, tx=tx, txid=txid)
       +        d.run()
        
            def save_transaction_into_wallet(self, tx: Transaction):
                win = self.top_level_window()
   DIR diff --git a/electrum/gui/qt/rbf_dialog.py b/electrum/gui/qt/rbf_dialog.py
       t@@ -0,0 +1,213 @@
       +# Copyright (C) 2021 The Electrum developers
       +# Distributed under the MIT software license, see the accompanying
       +# file LICENCE or http://www.opensource.org/licenses/mit-license.php
       +
       +from typing import TYPE_CHECKING
       +
       +from PyQt5.QtWidgets import (QCheckBox, QLabel, QVBoxLayout, QGridLayout, QWidget,
       +                             QPushButton, QHBoxLayout, QComboBox)
       +
       +from .amountedit import FeerateEdit
       +from .fee_slider import FeeSlider, FeeComboBox
       +from .util import (ColorScheme, WindowModalDialog, Buttons,
       +                   OkButton, WWLabel, CancelButton)
       +
       +from electrum.i18n import _
       +from electrum.transaction import PartialTransaction
       +from electrum.wallet import BumpFeeStrategy
       +
       +if TYPE_CHECKING:
       +    from .main_window import ElectrumWindow
       +
       +
       +class _BaseRBFDialog(WindowModalDialog):
       +
       +    def __init__(
       +            self,
       +            *,
       +            main_window: 'ElectrumWindow',
       +            tx: PartialTransaction,
       +            txid: str,
       +            title: str,
       +            help_text: str,
       +    ):
       +        WindowModalDialog.__init__(self, main_window, title=title)
       +        self.window = main_window
       +        self.wallet = main_window.wallet
       +        self.tx = tx
       +        assert txid
       +        self.txid = txid
       +
       +        fee = tx.get_fee()
       +        assert fee is not None
       +        tx_size = tx.estimated_size()
       +        old_fee_rate = fee / tx_size  # sat/vbyte
       +        vbox = QVBoxLayout(self)
       +        vbox.addWidget(WWLabel(help_text))
       +
       +        ok_button = OkButton(self)
       +        self.adv_button = QPushButton(_("Show advanced settings"))
       +        warning_label = WWLabel('\n')
       +        warning_label.setStyleSheet(ColorScheme.RED.as_stylesheet())
       +        self.feerate_e = FeerateEdit(lambda: 0)
       +        self.feerate_e.setAmount(max(old_fee_rate * 1.5, old_fee_rate + 1))
       +
       +        def on_feerate():
       +            fee_rate = self.feerate_e.get_amount()
       +            warning_text = '\n'
       +            if fee_rate is not None:
       +                try:
       +                    new_tx = self.rbf_func(fee_rate)
       +                except Exception as e:
       +                    new_tx = None
       +                    warning_text = str(e).replace('\n', ' ')
       +            else:
       +                new_tx = None
       +            ok_button.setEnabled(new_tx is not None)
       +            warning_label.setText(warning_text)
       +
       +        self.feerate_e.textChanged.connect(on_feerate)
       +
       +        def on_slider(dyn, pos, fee_rate):
       +            fee_slider.activate()
       +            if fee_rate is not None:
       +                self.feerate_e.setAmount(fee_rate / 1000)
       +
       +        fee_slider = FeeSlider(self.window, self.window.config, on_slider)
       +        fee_combo = FeeComboBox(fee_slider)
       +        fee_slider.deactivate()
       +        self.feerate_e.textEdited.connect(fee_slider.deactivate)
       +
       +        grid = QGridLayout()
       +        grid.addWidget(QLabel(_('Current Fee') + ':'), 0, 0)
       +        grid.addWidget(QLabel(self.window.format_amount(fee) + ' ' + self.window.base_unit()), 0, 1)
       +        grid.addWidget(QLabel(_('Current Fee rate') + ':'), 1, 0)
       +        grid.addWidget(QLabel(self.window.format_fee_rate(1000 * old_fee_rate)), 1, 1)
       +        grid.addWidget(QLabel(_('New Fee rate') + ':'), 2, 0)
       +        grid.addWidget(self.feerate_e, 2, 1)
       +        grid.addWidget(fee_slider, 3, 1)
       +        grid.addWidget(fee_combo, 3, 2)
       +        vbox.addLayout(grid)
       +        self._add_advanced_options_cont(vbox)
       +        vbox.addWidget(warning_label)
       +
       +        btns_hbox = QHBoxLayout()
       +        btns_hbox.addWidget(self.adv_button)
       +        btns_hbox.addStretch(1)
       +        btns_hbox.addWidget(CancelButton(self))
       +        btns_hbox.addWidget(ok_button)
       +        vbox.addLayout(btns_hbox)
       +
       +    def rbf_func(self, fee_rate) -> PartialTransaction:
       +        raise NotImplementedError()  # implemented by subclasses
       +
       +    def _add_advanced_options_cont(self, vbox: QVBoxLayout) -> None:
       +        adv_vbox = QVBoxLayout()
       +        adv_vbox.setContentsMargins(0, 0, 0, 0)
       +        adv_widget = QWidget()
       +        adv_widget.setLayout(adv_vbox)
       +        adv_widget.setVisible(False)
       +        def show_adv_settings():
       +            self.adv_button.setEnabled(False)
       +            adv_widget.setVisible(True)
       +        self.adv_button.clicked.connect(show_adv_settings)
       +        self._add_advanced_options(adv_vbox)
       +        vbox.addWidget(adv_widget)
       +
       +    def _add_advanced_options(self, adv_vbox: QVBoxLayout) -> None:
       +        self.cb_rbf = QCheckBox(_('Keep Replace-By-Fee enabled'))
       +        self.cb_rbf.setChecked(True)
       +        adv_vbox.addWidget(self.cb_rbf)
       +
       +    def run(self) -> None:
       +        if not self.exec_():
       +            return
       +        is_rbf = self.cb_rbf.isChecked()
       +        new_fee_rate = self.feerate_e.get_amount()
       +        try:
       +            new_tx = self.rbf_func(new_fee_rate)
       +        except Exception as e:
       +            self.window.show_error(str(e))
       +            return
       +        new_tx.set_rbf(is_rbf)
       +        tx_label = self.wallet.get_label_for_txid(self.txid)
       +        self.window.show_transaction(new_tx, tx_desc=tx_label)
       +        # TODO maybe save tx_label as label for new tx??
       +
       +
       +class BumpFeeDialog(_BaseRBFDialog):
       +
       +    def __init__(
       +            self,
       +            *,
       +            main_window: 'ElectrumWindow',
       +            tx: PartialTransaction,
       +            txid: str,
       +    ):
       +        help_text = _("Increase your transaction's fee to improve its position in mempool.")
       +        _BaseRBFDialog.__init__(
       +            self,
       +            main_window=main_window,
       +            tx=tx,
       +            txid=txid,
       +            title=_('Bump Fee'),
       +            help_text=help_text,
       +        )
       +
       +    def rbf_func(self, fee_rate):
       +        return self.wallet.bump_fee(
       +            tx=self.tx,
       +            txid=self.txid,
       +            new_fee_rate=fee_rate,
       +            coins=self.window.get_coins(),
       +            strategies=self.option_index_to_strats[self.strat_combo.currentIndex()],
       +        )
       +
       +    def _add_advanced_options(self, adv_vbox: QVBoxLayout) -> None:
       +        self.cb_rbf = QCheckBox(_('Keep Replace-By-Fee enabled'))
       +        self.cb_rbf.setChecked(True)
       +        adv_vbox.addWidget(self.cb_rbf)
       +
       +        self.strat_combo = QComboBox()
       +        options = [
       +            _("decrease change, or add new inputs, or decrease any outputs"),
       +            _("decrease change, or decrease any outputs"),
       +            _("decrease payment"),
       +        ]
       +        self.option_index_to_strats = {
       +            0: [BumpFeeStrategy.COINCHOOSER, BumpFeeStrategy.DECREASE_CHANGE],
       +            1: [BumpFeeStrategy.DECREASE_CHANGE],
       +            2: [BumpFeeStrategy.DECREASE_PAYMENT],
       +        }
       +        self.strat_combo.addItems(options)
       +        self.strat_combo.setCurrentIndex(0)
       +        strat_hbox = QHBoxLayout()
       +        strat_hbox.addWidget(QLabel(_("Strategy") + ":"))
       +        strat_hbox.addWidget(self.strat_combo)
       +        strat_hbox.addStretch(1)
       +        adv_vbox.addLayout(strat_hbox)
       +
       +
       +class DSCancelDialog(_BaseRBFDialog):
       +
       +    def __init__(
       +            self,
       +            *,
       +            main_window: 'ElectrumWindow',
       +            tx: PartialTransaction,
       +            txid: str,
       +    ):
       +        help_text = _(
       +            "Cancel an unconfirmed RBF transaction by double-spending "
       +            "its inputs back to your wallet with a higher fee.")
       +        _BaseRBFDialog.__init__(
       +            self,
       +            main_window=main_window,
       +            tx=tx,
       +            txid=txid,
       +            title=_('Cancel transaction'),
       +            help_text=help_text,
       +        )
       +
       +    def rbf_func(self, fee_rate):
       +        return self.wallet.dscancel(tx=self.tx, new_fee_rate=fee_rate)
   DIR diff --git a/electrum/tests/test_transaction.py b/electrum/tests/test_transaction.py
       t@@ -126,7 +126,7 @@ class TestTransaction(ElectrumTestCase):
                self.assertEqual(tx.estimated_size(), 193)
        
            def test_estimated_output_size(self):
       -        estimated_output_size = transaction.Transaction.estimated_output_size
       +        estimated_output_size = transaction.Transaction.estimated_output_size_for_address
                self.assertEqual(estimated_output_size('14gcRovpkCoGkCNBivQBvw7eso7eiNAbxG'), 34)
                self.assertEqual(estimated_output_size('35ZqQJcBQMZ1rsv8aSuJ2wkC7ohUCQMJbT'), 32)
                self.assertEqual(estimated_output_size('bc1q3g5tmkmlvxryhh843v4dz026avatc0zzr6h3af'), 31)
   DIR diff --git a/electrum/tests/test_wallet_vertical.py b/electrum/tests/test_wallet_vertical.py
       t@@ -10,7 +10,8 @@ from electrum import storage, bitcoin, keystore, bip32, wallet
        from electrum import Transaction
        from electrum import SimpleConfig
        from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT
       -from electrum.wallet import sweep, Multisig_Wallet, Standard_Wallet, Imported_Wallet, restore_wallet_from_text, Abstract_Wallet
       +from electrum.wallet import (sweep, Multisig_Wallet, Standard_Wallet, Imported_Wallet,
       +                             restore_wallet_from_text, Abstract_Wallet, BumpFeeStrategy)
        from electrum.util import bfh, bh2u, create_and_start_event_loop
        from electrum.transaction import TxOutput, Transaction, PartialTransaction, PartialTxOutput, PartialTxInput, tx_from_any
        from electrum.mnemonic import seed_type
       t@@ -937,6 +938,14 @@ class TestWalletSending(TestCaseForTestnet):
                        self._bump_fee_when_not_all_inputs_are_ismine_subcase_all_outputs_are_ismine(
                            simulate_moving_txs=simulate_moving_txs,
                            config=config)
       +            with self.subTest(msg="_bump_fee_p2wpkh_decrease_payment", simulate_moving_txs=simulate_moving_txs):
       +                self._bump_fee_p2wpkh_decrease_payment(
       +                    simulate_moving_txs=simulate_moving_txs,
       +                    config=config)
       +            with self.subTest(msg="_bump_fee_p2wpkh_decrease_payment_batch", simulate_moving_txs=simulate_moving_txs):
       +                self._bump_fee_p2wpkh_decrease_payment_batch(
       +                    simulate_moving_txs=simulate_moving_txs,
       +                    config=config)
        
            def _bump_fee_p2pkh_when_there_is_a_change_address(self, *, simulate_moving_txs, config):
                wallet = self.create_standard_wallet_from_seed('fold object utility erase deputy output stadium feed stereo usage modify bean',
       t@@ -1044,6 +1053,89 @@ class TestWalletSending(TestCaseForTestnet):
                wallet.receive_tx_callback(tx.txid(), tx, TX_HEIGHT_UNCONFIRMED)
                self.assertEqual((0, 461600, 0), wallet.get_balance())
        
       +    def _bump_fee_p2wpkh_decrease_payment(self, *, simulate_moving_txs, config):
       +        wallet = self.create_standard_wallet_from_seed('leader company camera enlist crash sleep insane aware anger hole hammer label',
       +                                                       config=config)
       +
       +        # bootstrap wallet
       +        funding_tx = Transaction('020000000001022ea8f7940c2e4bca2f34f21ba15a5c8d5e3c93d9c6deb17983412feefa0f1f6d0100000000fdffffff9d4ba5ab41951d506a7fa8272ef999ce3df166fe28f6f885aa791f012a0924cf0000000000fdffffff027485010000000000160014f80e86af4246960a24cd21c275a8e8842973fbcaa0860100000000001600149c6b743752604b98d30f1a5d27a5d5ce8919f4400247304402203bf6dd875a775f356d4bb8c4e295a2cd506338c100767518f2b31fb85db71c1302204dc4ebca5584fc1cc08bd7f7171135d1b67ca6c8812c3723cd332eccaa7b848101210360bdbd16d9ef390fd3e804c421e6f30e6b065ac314f4d2b9a80d2f0682ad1431024730440220126b442d7988c5883ca17c2429f51ce770e3a57895524c8dfe07b539e483019e02200b50feed4f42f0035c9a9ddd044820607281e45e29e41a29233c2b8be6080bac01210245d47d08915816a5ecc934cff1b17e00071ca06172f51d632ba95392e8aad4fdd38a1d00')
       +        funding_txid = funding_tx.txid()
       +        self.assertEqual('dd0bf0d1563cd588b4c93cc1a9623c051ddb1c4f4581cf8ef43cfd27f031f246', funding_txid)
       +        wallet.receive_tx_callback(funding_txid, funding_tx, TX_HEIGHT_UNCONFIRMED)
       +
       +        orig_rbf_tx = Transaction('0200000000010146f231f027fd3cf48ecf81454f1cdb1d053c62a9c13cc9b488d53c56d1f00bdd0100000000fdffffff02c8af000000000000160014999a95482213a896c72a251b6cc9f3d137b0a45850c3000000000000160014ea76d391236726af7d7a9c10abe600129154eb5a02473044022076d298537b524a926a8fadad0e9ded5868c8f4cf29246048f76f00eb4afa56310220739ad9e0417e97ce03fad98a454b4977972c2805cef37bfa822c6d6c56737c870121024196fb7b766ac987a08b69a5e108feae8513b7e72bc9e47899e27b36100f2af4d48a1d00')
       +        orig_rbf_txid = orig_rbf_tx.txid()
       +        self.assertEqual('db2f77709a4a04417b3a45838c21470877fe7c182a4f81005a21ce1315c6a5e6', orig_rbf_txid)
       +        wallet.receive_tx_callback(orig_rbf_txid, orig_rbf_tx, TX_HEIGHT_UNCONFIRMED)
       +
       +        # bump tx
       +        tx = wallet.bump_fee(
       +            tx=tx_from_any(orig_rbf_tx.serialize()),
       +            new_fee_rate=60,
       +            strategies=[BumpFeeStrategy.DECREASE_PAYMENT],
       +        )
       +        tx.locktime = 1936085
       +        tx.version = 2
       +        if simulate_moving_txs:
       +            partial_tx = tx.serialize_as_bytes().hex()
parazyd.org:70 /git/electrum/commit/6fda9add2825bf6449f891e79c61988e76964fd2.gph:433: line too long