mirror of
https://github.com/LBRYFoundation/LBRY-Vault.git
synced 2025-08-28 16:01:30 +00:00
Merge pull request #6641 from SomberNight/202010_dscancel
wallet: implement cancelling tx by double-spending to self ("dscancel")
This commit is contained in:
commit
83143f421a
7 changed files with 451 additions and 4 deletions
|
@ -1,3 +1,5 @@
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from kivy.app import App
|
||||
from kivy.factory import Factory
|
||||
from kivy.properties import ObjectProperty
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
|
|
111
electrum/gui/kivy/uix/dialogs/dscancel_dialog.py
Normal file
111
electrum/gui/kivy/uix/dialogs/dscancel_dialog.py
Normal file
|
@ -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()
|
|
@ -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
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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))
|
||||
|
@ -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, ())
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
@ -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():
|
||||
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
self.assertEqual("70736274ff0100720100000001c0ec8b6cdcb6638fa117ead71a8edebc189b30e6e5415bdfb3c8260aa269e6520100000000fdffffff02a02526000000000017a9145a71fc1a7a98ddd67be935ade1600981c0d066f987585d720000000000160014f0fe5c1867a174a12e70165e728a072619455ed5bb391400000100fda20101000000000102acd6459dec7c3c51048eb112630da756f5d4cb4752b8d39aa325407ae0885cba020000001716001455c7f5e0631d8e6f5f05dddb9f676cec48845532fdffffffd146691ef6a207b682b13da5f2388b1f0d2a2022c8cfb8dc27b65434ec9ec8f701000000171600147b3be8a7ceaf15f57d7df2a3d216bc3c259e3225fdffffff02a9875b000000000017a914ea5a99f83e71d1c1dfc5d0370e9755567fe4a141878096980000000000160014d4ca56fcbad98fb4dcafdc573a75d6a6fffb09b702483045022100dde1ba0c9a2862a65791b8d91295a6603207fb79635935a67890506c214dd96d022046c6616642ef5971103c1db07ac014e63fa3b0e15c5729eacdd3e77fcb7d2086012103a72410f185401bb5b10aaa30989c272b554dc6d53bda6da85a76f662723421af024730440220033d0be8f74e782fbcec2b396647c7715d2356076b442423f23552b617062312022063c95cafdc6d52ccf55c8ee0f9ceb0f57afb41ea9076eb74fe633f59c50c6377012103b96a4954d834fbcfb2bbf8cf7de7dc2b28bc3d661c1557d1fd1db1bfc123a94abb3914002206028d4c44ca36d2c4bff3813df8d5d3c0278357521ecb892cd694c473c03970e4c50c3c14aede00000000000000000000220202105dd9133f33cbd4e50443ef9af428c0be61f097f8942aaa916f50b530125aea0c3c14aede010000000000000000",
|
||||
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.assertTrue(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('01000000000101c0ec8b6cdcb6638fa117ead71a8edebc189b30e6e5415bdfb3c8260aa269e6520100000000fdffffff02a02526000000000017a9145a71fc1a7a98ddd67be935ade1600981c0d066f987585d720000000000160014f0fe5c1867a174a12e70165e728a072619455ed50247304402205442705e988abe74bf391b293bb1b886674284a92ed0788c33024f9336d60aef022013a93049d3bed693254cd31a704d70bb988a36750f0b74d0a5b4d9e29c54ca9d0121028d4c44ca36d2c4bff3813df8d5d3c0278357521ecb892cd694c473c03970e4c5bb391400',
|
||||
str(tx_copy))
|
||||
self.assertEqual('b019bbad45a46ed25365e46e4cae6428fb12ae425977eb93011ffb294cb4977e', tx_copy.txid())
|
||||
self.assertEqual('ba87313e2b3b42f1cc478843d4d53c72d6e06f6c66ac8cfbe2a59cdac2fd532d', tx_copy.wtxid())
|
||||
|
||||
wallet.receive_tx_callback(tx.txid(), tx, TX_HEIGHT_UNCONFIRMED)
|
||||
self.assertEqual((0, funding_output_value - 2500000 - 5000, 0), wallet.get_balance())
|
||||
|
||||
# cancel tx
|
||||
tx_details = wallet.get_tx_info(tx_from_any(tx.serialize()))
|
||||
self.assertTrue(tx_details.can_dscancel)
|
||||
tx = wallet.dscancel(tx=tx_from_any(tx.serialize()), new_fee_rate=70.0)
|
||||
tx.locktime = 1859397
|
||||
tx.version = 2
|
||||
if simulate_moving_txs:
|
||||
partial_tx = tx.serialize_as_bytes().hex()
|
||||
self.assertEqual("70736274ff0100520200000001c0ec8b6cdcb6638fa117ead71a8edebc189b30e6e5415bdfb3c8260aa269e6520100000000fdffffff016c78980000000000160014f0fe5c1867a174a12e70165e728a072619455ed5455f1c00000100fda20101000000000102acd6459dec7c3c51048eb112630da756f5d4cb4752b8d39aa325407ae0885cba020000001716001455c7f5e0631d8e6f5f05dddb9f676cec48845532fdffffffd146691ef6a207b682b13da5f2388b1f0d2a2022c8cfb8dc27b65434ec9ec8f701000000171600147b3be8a7ceaf15f57d7df2a3d216bc3c259e3225fdffffff02a9875b000000000017a914ea5a99f83e71d1c1dfc5d0370e9755567fe4a141878096980000000000160014d4ca56fcbad98fb4dcafdc573a75d6a6fffb09b702483045022100dde1ba0c9a2862a65791b8d91295a6603207fb79635935a67890506c214dd96d022046c6616642ef5971103c1db07ac014e63fa3b0e15c5729eacdd3e77fcb7d2086012103a72410f185401bb5b10aaa30989c272b554dc6d53bda6da85a76f662723421af024730440220033d0be8f74e782fbcec2b396647c7715d2356076b442423f23552b617062312022063c95cafdc6d52ccf55c8ee0f9ceb0f57afb41ea9076eb74fe633f59c50c6377012103b96a4954d834fbcfb2bbf8cf7de7dc2b28bc3d661c1557d1fd1db1bfc123a94abb3914002206028d4c44ca36d2c4bff3813df8d5d3c0278357521ecb892cd694c473c03970e4c50c3c14aede000000000000000000220202105dd9133f33cbd4e50443ef9af428c0be61f097f8942aaa916f50b530125aea0c3c14aede010000000000000000",
|
||||
partial_tx)
|
||||
tx = tx_from_any(partial_tx) # simulates moving partial txn between cosigners
|
||||
self.assertFalse(tx.is_complete())
|
||||
|
||||
wallet.sign_transaction(tx, password=None)
|
||||
self.assertTrue(tx.is_complete())
|
||||
self.assertTrue(tx.is_segwit())
|
||||
tx_copy = tx_from_any(tx.serialize())
|
||||
self.assertEqual('02000000000101c0ec8b6cdcb6638fa117ead71a8edebc189b30e6e5415bdfb3c8260aa269e6520100000000fdffffff016c78980000000000160014f0fe5c1867a174a12e70165e728a072619455ed50247304402201e706f7ab50e4212a98782e483476102cd6579dad91196002b13dedec79a9a6302205ae30e6c3cf6dd8c566ddae090eeedaac09ba0adc4c0205dfa77bc627621a6b70121028d4c44ca36d2c4bff3813df8d5d3c0278357521ecb892cd694c473c03970e4c5455f1c00',
|
||||
str(tx_copy))
|
||||
self.assertEqual('165f82b1440cd3a31c005cec660cf834917a1e0a89011805a620c702840fc46a', tx_copy.txid())
|
||||
self.assertEqual('a164fff4f4231a09e8745eb27d0fe636c5c291400b8506d932b0bde6ff8cf9ee', tx_copy.wtxid())
|
||||
|
||||
wallet.receive_tx_callback(tx.txid(), tx, TX_HEIGHT_UNCONFIRMED)
|
||||
self.assertEqual((0, 9992300, 0), wallet.get_balance())
|
||||
|
||||
def _dscancel_when_user_sends_max(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()
|
||||
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', '!')]
|
||||
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()
|
||||
self.assertEqual("70736274ff0100530100000001c0ec8b6cdcb6638fa117ead71a8edebc189b30e6e5415bdfb3c8260aa269e6520100000000fdffffff01f88298000000000017a9145a71fc1a7a98ddd67be935ade1600981c0d066f987bb391400000100fda20101000000000102acd6459dec7c3c51048eb112630da756f5d4cb4752b8d39aa325407ae0885cba020000001716001455c7f5e0631d8e6f5f05dddb9f676cec48845532fdffffffd146691ef6a207b682b13da5f2388b1f0d2a2022c8cfb8dc27b65434ec9ec8f701000000171600147b3be8a7ceaf15f57d7df2a3d216bc3c259e3225fdffffff02a9875b000000000017a914ea5a99f83e71d1c1dfc5d0370e9755567fe4a141878096980000000000160014d4ca56fcbad98fb4dcafdc573a75d6a6fffb09b702483045022100dde1ba0c9a2862a65791b8d91295a6603207fb79635935a67890506c214dd96d022046c6616642ef5971103c1db07ac014e63fa3b0e15c5729eacdd3e77fcb7d2086012103a72410f185401bb5b10aaa30989c272b554dc6d53bda6da85a76f662723421af024730440220033d0be8f74e782fbcec2b396647c7715d2356076b442423f23552b617062312022063c95cafdc6d52ccf55c8ee0f9ceb0f57afb41ea9076eb74fe633f59c50c6377012103b96a4954d834fbcfb2bbf8cf7de7dc2b28bc3d661c1557d1fd1db1bfc123a94abb3914002206028d4c44ca36d2c4bff3813df8d5d3c0278357521ecb892cd694c473c03970e4c50c3c14aede00000000000000000000",
|
||||
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.assertTrue(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('01000000000101c0ec8b6cdcb6638fa117ead71a8edebc189b30e6e5415bdfb3c8260aa269e6520100000000fdffffff01f88298000000000017a9145a71fc1a7a98ddd67be935ade1600981c0d066f987024730440220520ab41536d5d0fac8ad44e6aa4a8258a266121bab1eb6599f1ee86bbc65719d02205944c2fb765fca4753a850beadac49f5305c6722410c347c08cec4d90e3eb4430121028d4c44ca36d2c4bff3813df8d5d3c0278357521ecb892cd694c473c03970e4c5bb391400',
|
||||
str(tx_copy))
|
||||
self.assertEqual('dc4b622f3225f00edb886011fa02b74630cdbc24cebdd3210d5ea3b68bef5cc9', tx_copy.txid())
|
||||
self.assertEqual('a00340ee8c90673e05f2cf368601b6bba6a7f0513bd974feb218a326e39b1874', tx_copy.wtxid())
|
||||
|
||||
wallet.receive_tx_callback(tx.txid(), tx, TX_HEIGHT_UNCONFIRMED)
|
||||
self.assertEqual((0, 0, 0), wallet.get_balance())
|
||||
|
||||
# cancel tx
|
||||
tx_details = wallet.get_tx_info(tx_from_any(tx.serialize()))
|
||||
self.assertTrue(tx_details.can_dscancel)
|
||||
tx = wallet.dscancel(tx=tx_from_any(tx.serialize()), new_fee_rate=70.0)
|
||||
tx.locktime = 1859455
|
||||
tx.version = 2
|
||||
if simulate_moving_txs:
|
||||
partial_tx = tx.serialize_as_bytes().hex()
|
||||
self.assertEqual("70736274ff0100520200000001c0ec8b6cdcb6638fa117ead71a8edebc189b30e6e5415bdfb3c8260aa269e6520100000000fdffffff016c78980000000000160014f0fe5c1867a174a12e70165e728a072619455ed57f5f1c00000100fda20101000000000102acd6459dec7c3c51048eb112630da756f5d4cb4752b8d39aa325407ae0885cba020000001716001455c7f5e0631d8e6f5f05dddb9f676cec48845532fdffffffd146691ef6a207b682b13da5f2388b1f0d2a2022c8cfb8dc27b65434ec9ec8f701000000171600147b3be8a7ceaf15f57d7df2a3d216bc3c259e3225fdffffff02a9875b000000000017a914ea5a99f83e71d1c1dfc5d0370e9755567fe4a141878096980000000000160014d4ca56fcbad98fb4dcafdc573a75d6a6fffb09b702483045022100dde1ba0c9a2862a65791b8d91295a6603207fb79635935a67890506c214dd96d022046c6616642ef5971103c1db07ac014e63fa3b0e15c5729eacdd3e77fcb7d2086012103a72410f185401bb5b10aaa30989c272b554dc6d53bda6da85a76f662723421af024730440220033d0be8f74e782fbcec2b396647c7715d2356076b442423f23552b617062312022063c95cafdc6d52ccf55c8ee0f9ceb0f57afb41ea9076eb74fe633f59c50c6377012103b96a4954d834fbcfb2bbf8cf7de7dc2b28bc3d661c1557d1fd1db1bfc123a94abb3914002206028d4c44ca36d2c4bff3813df8d5d3c0278357521ecb892cd694c473c03970e4c50c3c14aede000000000000000000220202105dd9133f33cbd4e50443ef9af428c0be61f097f8942aaa916f50b530125aea0c3c14aede010000000000000000",
|
||||
partial_tx)
|
||||
tx = tx_from_any(partial_tx) # simulates moving partial txn between cosigners
|
||||
self.assertFalse(tx.is_complete())
|
||||
|
||||
wallet.sign_transaction(tx, password=None)
|
||||
self.assertTrue(tx.is_complete())
|
||||
self.assertTrue(tx.is_segwit())
|
||||
tx_copy = tx_from_any(tx.serialize())
|
||||
self.assertEqual('02000000000101c0ec8b6cdcb6638fa117ead71a8edebc189b30e6e5415bdfb3c8260aa269e6520100000000fdffffff016c78980000000000160014f0fe5c1867a174a12e70165e728a072619455ed502473044022013892ba1580bd8b35fe74cb7a0dceb6914b01ed5cfef6435b94ac0256866971c02200290d08d5f199fcdbba1a2dc4884f5cdea0177cb88e423d8588480d6a5fd62740121028d4c44ca36d2c4bff3813df8d5d3c0278357521ecb892cd694c473c03970e4c57f5f1c00',
|
||||
str(tx_copy))
|
||||
self.assertEqual('42e222b8faff6cb7fcb82697e04f7bc88a5ed57293773a57a5e400ce0450203e', tx_copy.txid())
|
||||
self.assertEqual('0c6511d0c008604948ea68b0f8cb3da00966c5a97a08a220716ff47eecd4922d', tx_copy.wtxid())
|
||||
|
||||
wallet.receive_tx_callback(tx.txid(), tx, TX_HEIGHT_UNCONFIRMED)
|
||||
self.assertEqual((0, 9992300, 0), wallet.get_balance())
|
||||
|
||||
|
||||
class TestWalletOfflineSigning(TestCaseForTestnet):
|
||||
|
||||
|
|
|
@ -35,6 +35,7 @@ import copy
|
|||
import errno
|
||||
import traceback
|
||||
import operator
|
||||
import math
|
||||
from functools import partial
|
||||
from collections import defaultdict
|
||||
from numbers import Number
|
||||
|
@ -211,6 +212,9 @@ def get_locktime_for_new_transaction(network: 'Network') -> int:
|
|||
class CannotBumpFee(Exception): pass
|
||||
|
||||
|
||||
class CannotDoubleSpendTx(Exception): pass
|
||||
|
||||
|
||||
class InternalAddressCorruption(Exception):
|
||||
def __str__(self):
|
||||
return _("Wallet file corruption detected. "
|
||||
|
@ -223,6 +227,7 @@ class TxWalletDetails(NamedTuple):
|
|||
label: str
|
||||
can_broadcast: bool
|
||||
can_bump: bool
|
||||
can_dscancel: bool # whether user can double-spend to self
|
||||
can_save_as_local: bool
|
||||
amount: Optional[int]
|
||||
fee: Optional[int]
|
||||
|
@ -574,6 +579,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
|
|||
and is_relevant
|
||||
# don't offer during common signing flow, e.g. when watch-only wallet starts creating a tx:
|
||||
and bool(tx_we_already_have_in_db))
|
||||
can_dscancel = False
|
||||
if tx.is_complete():
|
||||
if tx_we_already_have_in_db:
|
||||
label = self.get_label_for_txid(tx_hash)
|
||||
|
@ -591,6 +597,8 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
|
|||
fee_per_byte = fee / size
|
||||
exp_n = self.config.fee_to_depth(fee_per_byte)
|
||||
can_bump = is_mine and not tx.is_final()
|
||||
can_dscancel = (is_mine and not tx.is_final()
|
||||
and not all([self.is_mine(txout.address) for txout in tx.outputs()]))
|
||||
else:
|
||||
status = _('Local')
|
||||
can_broadcast = self.network is not None
|
||||
|
@ -622,6 +630,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
|
|||
label=label,
|
||||
can_broadcast=can_broadcast,
|
||||
can_bump=can_bump,
|
||||
can_dscancel=can_dscancel,
|
||||
can_save_as_local=can_save_as_local,
|
||||
amount=amount,
|
||||
fee=fee,
|
||||
|
@ -1488,6 +1497,53 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
|
|||
tx_new.add_info_from_wallet(self)
|
||||
return tx_new
|
||||
|
||||
def dscancel(
|
||||
self, *, tx: Transaction, new_fee_rate: Union[int, float, Decimal]
|
||||
) -> PartialTransaction:
|
||||
"""Double-Spend-Cancel: cancel an unconfirmed tx by double-spending
|
||||
its inputs, paying ourselves.
|
||||
'new_fee_rate' is the target min rate in sat/vbyte
|
||||
"""
|
||||
if tx.is_final():
|
||||
raise CannotDoubleSpendTx(_('Cannot cancel transaction') + ': ' + _('transaction is final'))
|
||||
new_fee_rate = quantize_feerate(new_fee_rate) # strip excess precision
|
||||
old_tx_size = tx.estimated_size()
|
||||
old_txid = tx.txid()
|
||||
assert old_txid
|
||||
old_fee = self.get_tx_fee(old_txid)
|
||||
if old_fee is None:
|
||||
raise CannotDoubleSpendTx(_('Cannot cancel transaction') + ': ' + _('current fee unknown'))
|
||||
old_fee_rate = old_fee / old_tx_size # sat/vbyte
|
||||
if new_fee_rate <= old_fee_rate:
|
||||
raise CannotDoubleSpendTx(_('Cannot cancel transaction') + ': ' + _("The new fee rate needs to be higher than the old fee rate."))
|
||||
|
||||
tx = PartialTransaction.from_tx(tx)
|
||||
tx.add_info_from_wallet(self)
|
||||
|
||||
# grab all ismine inputs
|
||||
inputs = [txin for txin in tx.inputs()
|
||||
if self.is_mine(self.get_txin_address(txin))]
|
||||
value = sum([txin.value_sats() for txin in tx.inputs()])
|
||||
# figure out output address
|
||||
old_change_addrs = [o.address for o in tx.outputs() if self.is_mine(o.address)]
|
||||
out_address = (self.get_single_change_address_for_new_transaction(old_change_addrs)
|
||||
or self.get_receiving_address())
|
||||
|
||||
locktime = get_locktime_for_new_transaction(self.network)
|
||||
|
||||
outputs = [PartialTxOutput.from_address_and_value(out_address, value)]
|
||||
tx_new = PartialTransaction.from_io(inputs, outputs, locktime=locktime)
|
||||
new_tx_size = tx_new.estimated_size()
|
||||
new_fee = max(
|
||||
new_fee_rate * new_tx_size,
|
||||
old_fee + self.relayfee() * new_tx_size / Decimal(1000), # BIP-125 rules 3 and 4
|
||||
)
|
||||
new_fee = int(math.ceil(new_fee))
|
||||
outputs = [PartialTxOutput.from_address_and_value(out_address, value - new_fee)]
|
||||
tx_new = PartialTransaction.from_io(inputs, outputs, locktime=locktime)
|
||||
tx_new.add_info_from_wallet(self)
|
||||
return tx_new
|
||||
|
||||
@abstractmethod
|
||||
def _add_input_sig_info(self, txin: PartialTxInput, address: str, *, only_der_suffix: bool = True) -> None:
|
||||
pass
|
||||
|
|
Loading…
Add table
Reference in a new issue