URI: 
       tMerge pull request #6867 from SomberNight/202012_wallet_rbf_fixes - electrum - Electrum Bitcoin wallet
  HTML git clone https://git.parazyd.org/electrum
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
       ---
   DIR commit 7ab1a4552b579cd7b9a2197be12da4f02f396629
   DIR parent b28b3994c7c879d43325faa8db8f4691be1f920f
  HTML Author: ghost43 <somber.night@protonmail.com>
       Date:   Sun, 20 Dec 2020 14:31:47 +0000
       
       Merge pull request #6867 from SomberNight/202012_wallet_rbf_fixes
       
       wallet: fix bump_fee and dscancel for "not all inputs ismine" case
       Diffstat:
         M electrum/gui/kivy/uix/dialogs/tx_d… |      77 ++++++++++++++++++++++++-------
         M electrum/gui/qt/history_list.py     |       5 ++---
         M electrum/gui/qt/main_window.py      |      44 ++++++++++++++++++++++++-------
         M electrum/tests/test_wallet_vertica… |     202 ++++++++++++++++++++++++++++++-
         M electrum/transaction.py             |      22 +++++++++++++++++-----
         M electrum/wallet.py                  |     110 ++++++++++++++++++++++---------
       
       6 files changed, 394 insertions(+), 66 deletions(-)
       ---
   DIR diff --git a/electrum/gui/kivy/uix/dialogs/tx_dialog.py b/electrum/gui/kivy/uix/dialogs/tx_dialog.py
       t@@ -1,6 +1,7 @@
        import copy
        from datetime import datetime
        from typing import NamedTuple, Callable, TYPE_CHECKING
       +from functools import partial
        
        from kivy.app import App
        from kivy.factory import Factory
       t@@ -18,6 +19,7 @@ from electrum.util import InvalidPassword
        from electrum.address_synchronizer import TX_HEIGHT_LOCAL
        from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx
        from electrum.transaction import Transaction, PartialTransaction
       +from electrum.network import NetworkException
        from ...util import address_colors
        
        if TYPE_CHECKING:
       t@@ -137,6 +139,7 @@ class TxDialog(Factory.Popup):
                # As a result, e.g. we might learn an imported address tx is segwit,
                # or that a beyond-gap-limit address is is_mine.
                # note: this might fetch prev txs over the network.
       +        # note: this is a no-op for complete txs
                tx.add_info_from_wallet(self.wallet)
        
            def on_open(self):
       t@@ -231,22 +234,51 @@ class TxDialog(Factory.Popup):
                action_button = self.ids.action_button
                self._action_button_fn(action_button)
        
       +    def _add_info_to_tx_from_wallet_and_network(self, tx: PartialTransaction) -> bool:
       +        """Returns whether successful."""
       +        # note side-effect: tx is being mutated
       +        assert isinstance(tx, PartialTransaction)
       +        try:
       +            # note: this might download input utxos over network
       +            # FIXME network code in gui thread...
       +            tx.add_info_from_wallet(self.wallet, ignore_network_issues=False)
       +        except NetworkException as e:
       +            self.app.show_error(repr(e))
       +            return False
       +        return True
       +
            def do_rbf(self):
                from .bump_fee_dialog import BumpFeeDialog
       -        fee = self.wallet.get_wallet_delta(self.tx).fee
       -        if fee is None:
       -            self.app.show_error(_("Can't bump fee: unknown fee for original transaction."))
       +        tx = self.tx
       +        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
       -        size = self.tx.estimated_size()
       -        d = BumpFeeDialog(self.app, fee, size, self._do_rbf)
       +        fee = tx.get_fee()
       +        assert fee is not None
       +        size = tx.estimated_size()
       +        cb = partial(self._do_rbf, tx=tx, txid=txid)
       +        d = BumpFeeDialog(self.app, fee, size, cb)
                d.open()
        
       -    def _do_rbf(self, new_fee_rate, is_final):
       +    def _do_rbf(
       +            self,
       +            new_fee_rate,
       +            is_final,
       +            *,
       +            tx: PartialTransaction,
       +            txid: str,
       +    ):
                if new_fee_rate is None:
                    return
                try:
       -            new_tx = self.wallet.bump_fee(tx=self.tx,
       -                                          new_fee_rate=new_fee_rate)
       +            new_tx = self.wallet.bump_fee(
       +                tx=tx,
       +                txid=txid,
       +                new_fee_rate=new_fee_rate,
       +            )
                except CannotBumpFee as e:
                    self.app.show_error(str(e))
                    return
       t@@ -258,20 +290,33 @@ class TxDialog(Factory.Popup):
        
            def do_dscancel(self):
                from .dscancel_dialog import DSCancelDialog
       -        fee = self.wallet.get_wallet_delta(self.tx).fee
       -        if fee is None:
       -            self.app.show_error(_('Cannot cancel transaction') + ': ' + _('unknown fee for original transaction'))
       +        tx = self.tx
       +        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
       -        size = self.tx.estimated_size()
       -        d = DSCancelDialog(self.app, fee, size, self._do_dscancel)
       +        fee = tx.get_fee()
       +        assert fee is not None
       +        size = tx.estimated_size()
       +        cb = partial(self._do_dscancel, tx=tx)
       +        d = DSCancelDialog(self.app, fee, size, cb)
                d.open()
        
       -    def _do_dscancel(self, new_fee_rate):
       +    def _do_dscancel(
       +            self,
       +            new_fee_rate,
       +            *,
       +            tx: PartialTransaction,
       +    ):
                if new_fee_rate is None:
                    return
                try:
       -            new_tx = self.wallet.dscancel(tx=self.tx,
       -                                          new_fee_rate=new_fee_rate)
       +            new_tx = self.wallet.dscancel(
       +                tx=tx,
       +                new_fee_rate=new_fee_rate,
       +            )
                except CannotDoubleSpendTx as e:
                    self.app.show_error(str(e))
                    return
   DIR diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py
       t@@ -691,14 +691,13 @@ class HistoryList(MyTreeView, AcceptFileDragDrop):
                if channel_id:
                    menu.addAction(_("View Channel"), lambda: self.parent.show_channel(bytes.fromhex(channel_id)))
                if is_unconfirmed and tx:
       -            # note: the current implementation of RBF *needs* the old tx fee
       -            if tx_details.can_bump and tx_details.fee is not None:
       +            if tx_details.can_bump:
                        menu.addAction(_("Increase fee"), lambda: self.parent.bump_fee_dialog(tx))
                    else:
                        child_tx = self.wallet.cpfp(tx, 0)
                        if child_tx:
                            menu.addAction(_("Child pays for parent"), lambda: self.parent.cpfp(tx, child_tx))
       -            if tx_details.can_dscancel and tx_details.fee is not None:
       +            if tx_details.can_dscancel:
                        menu.addAction(_("Cancel (double-spend)"), lambda: self.parent.dscancel_dialog(tx))
                invoices = self.wallet.get_relevant_invoices_for_tx(tx)
                if len(invoices) == 1:
   DIR diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py
       t@@ -70,7 +70,8 @@ from electrum.wallet import (Multisig_Wallet, CannotBumpFee, Abstract_Wallet,
                                     sweep_preparations, InternalAddressCorruption,
                                     CannotDoubleSpendTx)
        from electrum.version import ELECTRUM_VERSION
       -from electrum.network import Network, TxBroadcastError, BestEffortRequestFailed, UntrustedServerReturnedError
       +from electrum.network import (Network, TxBroadcastError, BestEffortRequestFailed,
       +                              UntrustedServerReturnedError, NetworkException)
        from electrum.exchange_rate import FxThread
        from electrum.simple_config import SimpleConfig
        from electrum.logging import Logger
       t@@ -90,7 +91,7 @@ from .util import (read_QIcon, ColorScheme, text_dialog, icon_path, WaitingDialo
                           import_meta_gui, export_meta_gui,
                           filename_field, address_field, char_width_in_lineedit, webopen,
                           TRANSACTION_FILE_EXTENSION_FILTER_ANY, MONOSPACE_FONT,
       -                   getOpenFileName, getSaveFileName)
       +                   getOpenFileName, getSaveFileName, BlockingWaitingDialog)
        from .util import ButtonsTextEdit, ButtonsLineEdit
        from .installwizard import WIF_HELP_TEXT
        from .history_list import HistoryList, HistoryModel
       t@@ -3189,13 +3190,31 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
                new_tx.set_rbf(True)
                self.show_transaction(new_tx)
        
       +    def _add_info_to_tx_from_wallet_and_network(self, tx: PartialTransaction) -> bool:
       +        """Returns whether successful."""
       +        # note side-effect: tx is being mutated
       +        assert isinstance(tx, PartialTransaction)
       +        try:
       +            # note: this might download input utxos over network
       +            BlockingWaitingDialog(
       +                self,
       +                _("Adding info to tx, from wallet and network..."),
       +                lambda: tx.add_info_from_wallet(self.wallet, ignore_network_issues=False),
       +            )
       +        except NetworkException as e:
       +            self.show_error(repr(e))
       +            return False
       +        return True
       +
            def bump_fee_dialog(self, tx: Transaction):
                txid = tx.txid()
                assert txid
       -        fee = self.wallet.get_tx_fee(txid)
       -        if fee is None:
       -            self.show_error(_("Can't bump fee: unknown fee for original transaction."))
       +        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
       t@@ -3236,7 +3255,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
                is_final = cb.isChecked()
                new_fee_rate = feerate_e.get_amount()
                try:
       -            new_tx = self.wallet.bump_fee(tx=tx, new_fee_rate=new_fee_rate, coins=self.get_coins())
       +            new_tx = self.wallet.bump_fee(
       +                tx=tx,
       +                txid=txid,
       +                new_fee_rate=new_fee_rate,
       +                coins=self.get_coins(),
       +            )
                except CannotBumpFee as e:
                    self.show_error(str(e))
                    return
       t@@ -3247,10 +3271,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
            def dscancel_dialog(self, tx: Transaction):
                txid = tx.txid()
                assert txid
       -        fee = self.wallet.get_tx_fee(txid)
       -        if fee is None:
       -            self.show_error(_('Cannot cancel transaction') + ': ' + _('unknown fee for original transaction'))
       +        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_size = tx.estimated_size()
                old_fee_rate = fee / tx_size  # sat/vbyte
                d = WindowModalDialog(self, _('Cancel transaction'))
   DIR diff --git a/electrum/tests/test_wallet_vertical.py b/electrum/tests/test_wallet_vertical.py
       t@@ -11,7 +11,7 @@ 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.util import bfh, bh2u
       +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@@ -575,11 +575,11 @@ class TestWalletSending(TestCaseForTestnet):
                super().setUp()
                self.config = SimpleConfig({'electrum_path': self.electrum_path})
        
       -    def create_standard_wallet_from_seed(self, seed_words, *, config=None):
       +    def create_standard_wallet_from_seed(self, seed_words, *, config=None, gap_limit=2):
                if config is None:
                    config = self.config
                ks = keystore.from_seed(seed_words, '', False)
       -        return WalletIntegrityHelper.create_standard_wallet(ks, gap_limit=2, config=config)
       +        return WalletIntegrityHelper.create_standard_wallet(ks, gap_limit=gap_limit, config=config)
        
            @mock.patch.object(wallet.Abstract_Wallet, 'save_db')
            def test_sending_between_p2wpkh_and_compressed_p2pkh(self, mock_save_db):
       t@@ -929,6 +929,14 @@ class TestWalletSending(TestCaseForTestnet):
                        self._rbf_batching(
                            simulate_moving_txs=simulate_moving_txs,
                            config=config)
       +            with self.subTest(msg="_bump_fee_when_not_all_inputs_are_ismine_subcase_some_outputs_are_ismine_but_not_all", simulate_moving_txs=simulate_moving_txs):
       +                self._bump_fee_when_not_all_inputs_are_ismine_subcase_some_outputs_are_ismine_but_not_all(
       +                    simulate_moving_txs=simulate_moving_txs,
       +                    config=config)
       +            with self.subTest(msg="_bump_fee_when_not_all_inputs_are_ismine_subcase_all_outputs_are_ismine", simulate_moving_txs=simulate_moving_txs):
       +                self._bump_fee_when_not_all_inputs_are_ismine_subcase_all_outputs_are_ismine(
       +                    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@@ -1134,6 +1142,130 @@ class TestWalletSending(TestCaseForTestnet):
                wallet.receive_tx_callback(tx.txid(), tx, TX_HEIGHT_UNCONFIRMED)
                self.assertEqual((0, 7490060, 0), wallet.get_balance())
        
       +    def _bump_fee_when_not_all_inputs_are_ismine_subcase_some_outputs_are_ismine_but_not_all(self, *, simulate_moving_txs, config):
       +        class NetworkMock:
       +            relay_fee = 1000
       +            async def get_transaction(self, txid, timeout=None):
       +                if txid == "597098f9077cd2a7bf5bb2a03c9ae5fcd9d1f07c0891cb42cbb129cf9eaf57fd":
       +                    return "02000000000102a5883f3de780d260e6f26cf85144403c7744a65a44cd38f9ff45aecadf010c540000000000fdffffffbdeb0175b1c51c96843d1952f7e1c49c1703717d7d020048d4de0a8eed94dad50000000000fdffffff03b2a00700000000001600140cd6c9f8ce0aa73d77fcf7f156c74f5cbec6906bb2a00700000000001600146435504ddc95e6019a90bb7dfc7ca81a88a8633106d790000000000016001444bd3017ee214370abf683abaa7f6204c9f40210024730440220652a04a2a301d9a031a034f3ae48174e204e17acf7bfc27f0dcab14243f73e2202207b29e964c434dfb2c515232d36566a40dccd4dd93ccb7fd15260ecbda10f0d9801210231994e564a0530068d17a9b0f85bec58d1352517a2861ea99e5b3070d2c5dbda02473044022072186473874919019da0e3d92b6e0aa4f88cba448ed5434615e5a3c8e2b7c42a02203ec05cef66960d5bc45d0f3d25675190cf8035b11a05ed4b719fd9c3a894899b012102f5fdca8c4e30ba0a1babf9cf9ebe62519b08aead351c349ed1ffc8316c24f542d7f61c00"
       +                else:
       +                    raise Exception("unexpected txid")
       +            def has_internet_connection(self):
       +                return True
       +            def run_from_another_thread(self, coro, *, timeout=None):
       +                loop, stop_loop, loop_thread = create_and_start_event_loop()
       +                fut = asyncio.run_coroutine_threadsafe(coro, loop)
       +                try:
       +                    return fut.result(timeout)
       +                finally:
       +                    loop.call_soon_threadsafe(stop_loop.set_result, 1)
       +                    loop_thread.join(timeout=1)
       +            def get_local_height(self):
       +                return 0
       +            def blockchain(self):
       +                class BlockchainMock:
       +                    def is_tip_stale(self):
       +                        return True
       +                return BlockchainMock()
       +
       +        wallet = self.create_standard_wallet_from_seed('mix total present junior leader live state athlete mistake crack wall valve',
       +                                                       config=config)
       +        wallet.network = NetworkMock()
       +
       +        # bootstrap wallet
       +        funding_tx = Transaction('02000000000101a5883f3de780d260e6f26cf85144403c7744a65a44cd38f9ff45aecadf010c540100000000fdffffff0220a1070000000000160014db44724ac632ae47ee5765954d64796dd5fec72708de3c000000000016001424b32aadb42a89016c4de8f11741c3b29b15f21c02473044022045cc6c1cc875cbb0c0d8fe323dc1de9716e49ed5659741b0fb3dd9a196894066022077c242640071d12ec5763c5870f482a4823d8713e4bd14353dd621ed29a7f96d012102aea8d439a0f79d8b58e8d7bda83009f587e1f3da350adaa484329bf47cd03465fef61c00')
       +        funding_txid = funding_tx.txid()
       +        self.assertEqual('08557327673db61cc921e1a30826608599b86457836be3021105c13940d9a9a3', funding_txid)
       +        wallet.receive_tx_callback(funding_txid, funding_tx, TX_HEIGHT_UNCONFIRMED)
       +
       +        orig_rbf_tx = Transaction('02000000000102a3a9d94039c1051102e36b835764b89985602608a3e121c91cb63d67277355080000000000fdfffffffd57af9ecf29b1cb42cb91087cf0d1d9fce59a3ca0b25bbfa7d27c07f99870590200000000fdffffff03b2a00700000000001600145dc80fd43eb70fd21a6c4446e3ce043df94f100cb2a00700000000001600147db4ab480b7d2218fba561ff304178f4afcbc972be358900000000001600149d91f0053172fab394d277ae27e9fa5c5a49210902473044022003999f03be8b9e299b2cd3bc7bce05e273d5d9ce24fc47af8754f26a7a13e13f022004e668499a67061789f6ebd2932c969ece74417ae3f2307bf696428bbed4fe36012102a1c9b25b37aa31ccbb2d72caaffce81ec8253020a74017d92bbfc14a832fc9cb0247304402207121358a66c0e716e2ba2be928076736261c691b4fbf89ea8d255449a4f5837b022042cadf9fe1b4f3c03ede3cef6783b42f0ba319f2e0273b624009cd023488c4c1012103a5ba95fb1e0043428ed70680fc17db254b3f701dfccf91e48090aa17c1b7ea40fef61c00')
       +        orig_rbf_txid = orig_rbf_tx.txid()
       +        self.assertEqual('6057690010ddac93a371629e1f41866400623e13a9cd336d280fc3239086a983', 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=70)
       +        tx.locktime = 1898268
       +        tx.version = 2
       +        if simulate_moving_txs:
       +            partial_tx = tx.serialize_as_bytes().hex()
parazyd.org:70 /git/electrum/commit/7ab1a4552b579cd7b9a2197be12da4f02f396629.gph:346: line too long