URI: 
       tMerge pull request #6641 from SomberNight/202010_dscancel - electrum - Electrum Bitcoin wallet
  HTML git clone https://git.parazyd.org/electrum
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
       ---
   DIR commit 83143f421a24559541ff9c7490ee7d5d541627bf
   DIR parent 082b2b35856f56c6664f17372dcb0a13735fe2a9
  HTML Author: ghost43 <somber.night@protonmail.com>
       Date:   Tue, 13 Oct 2020 17:42:10 +0000
       
       Merge pull request #6641 from SomberNight/202010_dscancel
       
       wallet: implement cancelling tx by double-spending to self ("dscancel")
       Diffstat:
         M electrum/gui/kivy/uix/dialogs/bump… |       8 +++++++-
         A electrum/gui/kivy/uix/dialogs/dsca… |     111 ++++++++++++++++++++++++++++++
         M electrum/gui/kivy/uix/dialogs/tx_d… |      27 ++++++++++++++++++++++++++-
         M electrum/gui/qt/history_list.py     |       2 ++
         M electrum/gui/qt/main_window.py      |      55 +++++++++++++++++++++++++++++--
         M electrum/tests/test_wallet_vertica… |     196 +++++++++++++++++++++++++++++++
         M electrum/wallet.py                  |      56 +++++++++++++++++++++++++++++++
       
       7 files changed, 451 insertions(+), 4 deletions(-)
       ---
   DIR diff --git a/electrum/gui/kivy/uix/dialogs/bump_fee_dialog.py b/electrum/gui/kivy/uix/dialogs/bump_fee_dialog.py
       t@@ -1,3 +1,5 @@
       +from typing import TYPE_CHECKING
       +
        from kivy.app import App
        from kivy.factory import Factory
        from kivy.properties import ObjectProperty
       t@@ -5,6 +7,10 @@ from kivy.lang import Builder
        
        from electrum.gui.kivy.i18n import _
        
       +if TYPE_CHECKING:
       +    from ...main_window import ElectrumWindow
       +
       +
        Builder.load_string('''
        <BumpFeeDialog@Popup>
            title: _('Bump fee')
       t@@ -68,7 +74,7 @@ Builder.load_string('''
        
        class BumpFeeDialog(Factory.Popup):
        
       -    def __init__(self, app, fee, size, callback):
       +    def __init__(self, app: 'ElectrumWindow', fee, size, callback):
                Factory.Popup.__init__(self)
                self.app = app
                self.init_fee = fee
   DIR diff --git a/electrum/gui/kivy/uix/dialogs/dscancel_dialog.py b/electrum/gui/kivy/uix/dialogs/dscancel_dialog.py
       t@@ -0,0 +1,111 @@
       +from typing import TYPE_CHECKING
       +
       +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
       +
       +
       +Builder.load_string('''
       +<DSCancelDialog@Popup>
       +    title: _('Cancel transaction')
       +    size_hint: 0.8, 0.8
       +    pos_hint: {'top':0.9}
       +    BoxLayout:
       +        orientation: 'vertical'
       +        padding: '10dp'
       +
       +        GridLayout:
       +            height: self.minimum_height
       +            size_hint_y: None
       +            cols: 1
       +            spacing: '10dp'
       +            BoxLabel:
       +                id: old_fee
       +                text: _('Current Fee')
       +                value: ''
       +            BoxLabel:
       +                id: old_feerate
       +                text: _('Current Fee rate')
       +                value: ''
       +        Label:
       +            id: tooltip1
       +            text: ''
       +            size_hint_y: None
       +        Label:
       +            id: tooltip2
       +            text: ''
       +            size_hint_y: None
       +        Slider:
       +            id: slider
       +            range: 0, 4
       +            step: 1
       +            on_value: root.on_slider(self.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 DSCancelDialog(Factory.Popup):
       +
       +    def __init__(self, app: 'ElectrumWindow', fee, size, callback):
       +        Factory.Popup.__init__(self)
       +        self.app = app
       +        self.init_fee = fee
       +        self.tx_size = size
       +        self.callback = callback
       +        self.config = app.electrum_config
       +        self.mempool = self.config.use_mempool_fees()
       +        self.dynfees = self.config.is_dynfee() and bool(self.app.network) and self.config.has_dynamic_fees_ready()
       +        self.ids.old_fee.value = self.app.format_amount_and_units(self.init_fee)
       +        self.ids.old_feerate.value = self.app.format_fee_rate(fee / self.tx_size * 1000)
       +        self.update_slider()
       +        self.update_text()
       +
       +    def update_text(self):
       +        pos = int(self.ids.slider.value)
       +        new_fee_rate = self.get_fee_rate()
       +        text, tooltip = self.config.get_fee_text(pos, self.dynfees, self.mempool, new_fee_rate)
       +        self.ids.tooltip1.text = text
       +        self.ids.tooltip2.text = tooltip
       +
       +    def update_slider(self):
       +        slider = self.ids.slider
       +        maxp, pos, fee_rate = self.config.get_fee_slider(self.dynfees, self.mempool)
       +        slider.range = (0, maxp)
       +        slider.step = 1
       +        slider.value = pos
       +
       +    def get_fee_rate(self):
       +        pos = int(self.ids.slider.value)
       +        if self.dynfees:
       +            fee_rate = self.config.depth_to_fee(pos) if self.mempool else self.config.eta_to_fee(pos)
       +        else:
       +            fee_rate = self.config.static_fee(pos)
       +        return fee_rate  # sat/kbyte
       +
       +    def on_ok(self):
       +        new_fee_rate = self.get_fee_rate() / 1000
       +        self.callback(new_fee_rate)
       +
       +    def on_slider(self, value):
       +        self.update_text()
   DIR diff --git a/electrum/gui/kivy/uix/dialogs/tx_dialog.py b/electrum/gui/kivy/uix/dialogs/tx_dialog.py
       t@@ -16,7 +16,7 @@ from electrum.gui.kivy.i18n import _
        
        from electrum.util import InvalidPassword
        from electrum.address_synchronizer import TX_HEIGHT_LOCAL
       -from electrum.wallet import CannotBumpFee
       +from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx
        from electrum.transaction import Transaction, PartialTransaction
        from ...util import address_colors
        
       t@@ -151,6 +151,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_dscancel = tx_details.can_dscancel
                self.tx_hash = tx_details.txid or ''
                if tx_mined_status.timestamp:
                    self.date_label = _('Date')
       t@@ -196,6 +197,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=_('Cancel (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),
                )
                num_options = sum(map(lambda o: bool(o.enabled), options))
       t@@ -253,6 +255,29 @@ class TxDialog(Factory.Popup):
                self.update()
                self.do_sign()
        
       +    def do_dscancel(self):
       +        from .dscancel_dialog import DSCancelDialog
       +        is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(self.tx)
       +        if fee is None:
       +            self.app.show_error(_('Cannot cancel transaction') + ': ' + _('unknown fee for original transaction'))
       +            return
       +        size = self.tx.estimated_size()
       +        d = DSCancelDialog(self.app, fee, size, self._do_dscancel)
       +        d.open()
       +
       +    def _do_dscancel(self, new_fee_rate):
       +        if new_fee_rate is None:
       +            return
       +        try:
       +            new_tx = self.wallet.dscancel(tx=self.tx,
       +                                          new_fee_rate=new_fee_rate)
       +        except CannotDoubleSpendTx as e:
       +            self.app.show_error(str(e))
       +            return
       +        self.tx = new_tx
       +        self.update()
       +        self.do_sign()
       +
            def do_sign(self):
                self.app.protected(_("Sign this transaction?"), self._do_sign, ())
        
   DIR diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py
       t@@ -698,6 +698,8 @@ class HistoryList(MyTreeView, AcceptFileDragDrop):
                        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:
       +                menu.addAction(_("Cancel (double-spend)"), lambda: self.parent.dscancel_dialog(tx))
                invoices = self.wallet.get_relevant_invoices_for_tx(tx)
                if len(invoices) == 1:
                    menu.addAction(_("View invoice"), lambda inv=invoices[0]: self.parent.show_onchain_invoice(inv))
   DIR diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py
       t@@ -67,7 +67,8 @@ from electrum.invoices import PR_PAID, PR_FAILED, pr_expiration_values, LNInvoic
        from electrum.transaction import (Transaction, PartialTxInput,
                                          PartialTransaction, PartialTxOutput)
        from electrum.wallet import (Multisig_Wallet, CannotBumpFee, Abstract_Wallet,
       -                             sweep_preparations, InternalAddressCorruption)
       +                             sweep_preparations, InternalAddressCorruption,
       +                             CannotDoubleSpendTx)
        from electrum.version import ELECTRUM_VERSION
        from electrum.network import Network, TxBroadcastError, BestEffortRequestFailed, UntrustedServerReturnedError
        from electrum.exchange_rate import FxThread
       t@@ -486,7 +487,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
                run_hook('close_wallet', self.wallet)
        
            @profiler
       -    def load_wallet(self, wallet):
       +    def load_wallet(self, wallet: Abstract_Wallet):
                wallet.thread = TaskThread(self, self.on_error)
                self.update_recently_visited(wallet.storage.path)
                if wallet.has_lightning():
       t@@ -3236,6 +3237,56 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
                    new_tx.set_rbf(False)
                self.show_transaction(new_tx, tx_desc=tx_label)
        
       +    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'))
       +            return
       +        tx_size = tx.estimated_size()
       +        old_fee_rate = fee / tx_size  # sat/vbyte
       +        d = WindowModalDialog(self, _('Cancel transaction'))
       +        vbox = QVBoxLayout(d)
       +        vbox.addWidget(WWLabel(_("Cancel an unconfirmed RBF transaction by double-spending "
       +                                 "its inputs back to your wallet with a higher fee.")))
       +
       +        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)
       +        def on_textedit_rate():
       +            fee_slider.deactivate()
       +        feerate_e = FeerateEdit(lambda: 0)
       +        feerate_e.setAmount(max(old_fee_rate * 1.5, old_fee_rate + 1))
       +        feerate_e.textEdited.connect(on_textedit_rate)
       +        grid.addWidget(feerate_e, 2, 1)
       +
       +        def on_slider_rate(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_rate)
       +        fee_combo = FeeComboBox(fee_slider)
       +        fee_slider.deactivate()
       +        grid.addWidget(fee_slider, 3, 1)
       +        grid.addWidget(fee_combo, 3, 2)
       +
       +        vbox.addLayout(grid)
       +        vbox.addLayout(Buttons(CancelButton(d), OkButton(d)))
       +        if not d.exec_():
       +            return
       +        new_fee_rate = feerate_e.get_amount()
       +        try:
       +            new_tx = self.wallet.dscancel(tx=tx, new_fee_rate=new_fee_rate)
       +        except CannotDoubleSpendTx as e:
       +            self.show_error(str(e))
       +            return
       +        self.show_transaction(new_tx)
       +
            def save_transaction_into_wallet(self, tx: Transaction):
                win = self.top_level_window()
                try:
   DIR diff --git a/electrum/tests/test_wallet_vertical.py b/electrum/tests/test_wallet_vertical.py
       t@@ -1558,6 +1558,202 @@ class TestWalletSending(TestCaseForTestnet):
                self.assertFalse(any([wallet_frost.is_mine(txin.address) for txin in tx.inputs()]))
                self.assertFalse(any([wallet_frost.is_mine(txout.address) for txout in tx.outputs()]))
        
       +    @mock.patch.object(wallet.Abstract_Wallet, 'save_db')
       +    def test_dscancel(self, mock_save_db):
       +        self.maxDiff = None
       +        config = SimpleConfig({'electrum_path': self.electrum_path})
       +        config.set_key('coin_chooser_output_rounding', False)
       +
       +        for simulate_moving_txs in (False, True):
       +            with self.subTest(msg="_dscancel_when_all_outputs_are_ismine", simulate_moving_txs=simulate_moving_txs):
       +                self._dscancel_when_all_outputs_are_ismine(
       +                    simulate_moving_txs=simulate_moving_txs,
       +                    config=config)
       +            with self.subTest(msg="_dscancel_p2wpkh_when_there_is_a_change_address", simulate_moving_txs=simulate_moving_txs):
       +                self._dscancel_p2wpkh_when_there_is_a_change_address(
       +                    simulate_moving_txs=simulate_moving_txs,
       +                    config=config)
       +            with self.subTest(msg="_dscancel_when_user_sends_max", simulate_moving_txs=simulate_moving_txs):
       +                self._dscancel_when_user_sends_max(
       +                    simulate_moving_txs=simulate_moving_txs,
       +                    config=config)
       +
       +    def _dscancel_when_all_outputs_are_ismine(self, *, simulate_moving_txs, config):
       +        wallet = self.create_standard_wallet_from_seed('fold object utility erase deputy output stadium feed stereo usage modify bean',
       +                                                       config=config)
       +
       +        # bootstrap wallet
       +        funding_tx = Transaction('010000000001011f4db0ecd81f4388db316bc16efb4e9daf874cf4950d54ecb4c0fb372433d68500000000171600143d57fd9e88ef0e70cddb0d8b75ef86698cab0d44fdffffff0280969800000000001976a91472e34cebab371967b038ce41d0e8fa1fb983795e88ac86a0ae020000000017a9149188bc82bdcae077060ebb4f02201b73c806edc887024830450221008e0725d531bd7dee4d8d38a0f921d7b1213e5b16c05312a80464ecc2b649598d0220596d309cf66d5f47cb3df558dbb43c5023a7796a80f5a88b023287e45a4db6b9012102c34d61ceafa8c216f01e05707672354f8119334610f7933a3f80dd7fb6290296bd391400')
       +        funding_txid = funding_tx.txid()
       +        funding_output_value = 10000000
       +        self.assertEqual('03052739fcfa2ead5f8e57e26021b0c2c546bcd3d74c6e708d5046dc58d90762', funding_txid)
       +        wallet.receive_tx_callback(funding_txid, funding_tx, TX_HEIGHT_UNCONFIRMED)
       +
       +        # create tx
       +        outputs = [PartialTxOutput.from_address_and_value('miFLSDZBXUo4on8PGhTRTAufUn4mP61uoH', '!')]
       +        coins = wallet.get_spendable_coins(domain=None)
       +        tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000)
       +        tx.set_rbf(True)
       +        tx.locktime = 1859362
       +        tx.version = 2
       +        if simulate_moving_txs:
       +            partial_tx = tx.serialize_as_bytes().hex()
       +            self.assertEqual("70736274ff01005502000000016207d958dc46508d706e4cd7d3bc46c5c2b02160e2578e5fad2efafc392705030000000000fdffffff01f8829800000000001976a9141df43441a3a3ee563e560d3ddc7e07cc9f9c3cdb88ac225f1c00000100fa010000000001011f4db0ecd81f4388db316bc16efb4e9daf874cf4950d54ecb4c0fb372433d68500000000171600143d57fd9e88ef0e70cddb0d8b75ef86698cab0d44fdffffff0280969800000000001976a91472e34cebab371967b038ce41d0e8fa1fb983795e88ac86a0ae020000000017a9149188bc82bdcae077060ebb4f02201b73c806edc887024830450221008e0725d531bd7dee4d8d38a0f921d7b1213e5b16c05312a80464ecc2b649598d0220596d309cf66d5f47cb3df558dbb43c5023a7796a80f5a88b023287e45a4db6b9012102c34d61ceafa8c216f01e05707672354f8119334610f7933a3f80dd7fb6290296bd391400220602a807c07bd7975211078e916bdda061d97e98d59a3631a804aada2f9a3f5b587a0c8296e571000000000000000000220202a7536f0bfbc60c5a8e86e2b9df26431fc062f9f454016dbc26f2467e0bc98b3f0c8296e571000000000100000000",
       +                             partial_tx)
       +            tx = tx_from_any(partial_tx)  # simulates moving partial txn between cosigners
       +        wallet.sign_transaction(tx, password=None)
       +
       +        self.assertTrue(tx.is_complete())
       +        self.assertFalse(tx.is_segwit())
       +        self.assertEqual(1, len(tx.inputs()))
       +        tx_copy = tx_from_any(tx.serialize())
       +        self.assertTrue(wallet.is_mine(wallet.get_txin_address(tx_copy.inputs()[0])))
       +
       +        self.assertEqual(tx.txid(), tx_copy.txid())
       +        self.assertEqual(tx.wtxid(), tx_copy.wtxid())
       +        self.assertEqual('02000000016207d958dc46508d706e4cd7d3bc46c5c2b02160e2578e5fad2efafc39270503000000006a47304402200c1ad6499cfd7a808c2463e211e0aaf503a571c85b679e69af215b76f05ad74d022066fccfec30164ad62686734ec3eca024e33e935b1bf30a98df85d87f01ba1b5f012102a807c07bd7975211078e916bdda061d97e98d59a3631a804aada2f9a3f5b587afdffffff01f8829800000000001976a9141df43441a3a3ee563e560d3ddc7e07cc9f9c3cdb88ac225f1c00',
       +                         str(tx_copy))
       +        self.assertEqual('200d5173d3113e9cec7a63e885b64836245572d93b6dda4035f3ed44341b6277', tx_copy.txid())
       +        self.assertEqual('200d5173d3113e9cec7a63e885b64836245572d93b6dda4035f3ed44341b6277', tx_copy.wtxid())
       +
       +        wallet.receive_tx_callback(tx.txid(), tx, TX_HEIGHT_UNCONFIRMED)
       +        self.assertEqual((0, funding_output_value - 5000, 0), wallet.get_balance())
       +
       +        # cancel tx
       +        tx_details = wallet.get_tx_info(tx_from_any(tx.serialize()))
       +        self.assertFalse(tx_details.can_dscancel)
       +
       +    def _dscancel_p2wpkh_when_there_is_a_change_address(self, *, simulate_moving_txs, config):
       +        wallet = self.create_standard_wallet_from_seed('frost repair depend effort salon ring foam oak cancel receive save usage',
       +                                                       config=config)
       +
       +        # bootstrap wallet
       +        funding_tx = Transaction('01000000000102acd6459dec7c3c51048eb112630da756f5d4cb4752b8d39aa325407ae0885cba020000001716001455c7f5e0631d8e6f5f05dddb9f676cec48845532fdffffffd146691ef6a207b682b13da5f2388b1f0d2a2022c8cfb8dc27b65434ec9ec8f701000000171600147b3be8a7ceaf15f57d7df2a3d216bc3c259e3225fdffffff02a9875b000000000017a914ea5a99f83e71d1c1dfc5d0370e9755567fe4a141878096980000000000160014d4ca56fcbad98fb4dcafdc573a75d6a6fffb09b702483045022100dde1ba0c9a2862a65791b8d91295a6603207fb79635935a67890506c214dd96d022046c6616642ef5971103c1db07ac014e63fa3b0e15c5729eacdd3e77fcb7d2086012103a72410f185401bb5b10aaa30989c272b554dc6d53bda6da85a76f662723421af024730440220033d0be8f74e782fbcec2b396647c7715d2356076b442423f23552b617062312022063c95cafdc6d52ccf55c8ee0f9ceb0f57afb41ea9076eb74fe633f59c50c6377012103b96a4954d834fbcfb2bbf8cf7de7dc2b28bc3d661c1557d1fd1db1bfc123a94abb391400')
       +        funding_txid = funding_tx.txid()
       +        funding_output_value = 10000000
       +        self.assertEqual('52e669a20a26c8b3df5b41e5e6309b18bcde8e1ad7ea17a18f63b6dc6c8becc0', funding_txid)
       +        wallet.receive_tx_callback(funding_txid, funding_tx, TX_HEIGHT_UNCONFIRMED)
       +
       +        # create tx
       +        outputs = [PartialTxOutput.from_address_and_value('2N1VTMMFb91SH9SNRAkT7z8otP5eZEct4KL', 2500000)]
       +        coins = wallet.get_spendable_coins(domain=None)
       +        tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=5000)
       +        tx.set_rbf(True)
       +        tx.locktime = 1325499
       +        tx.version = 1
       +        if simulate_moving_txs:
       +            partial_tx = tx.serialize_as_bytes().hex()
parazyd.org:70 /git/electrum/commit/83143f421a24559541ff9c7490ee7d5d541627bf.gph:400: line too long