From 9df8bb61a5ddfeb2cc3fee0afa058cc4704f4e92 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Sun, 10 Sep 2023 10:18:58 +0200 Subject: [PATCH] Give users an option to cancel a submarine swap while awaiting HTLCs. Note that HTLCs must not be cancelled after the funding transaction has been broadcast. If one want to cancel a swap once the funding transaction is in mempool, one should double spend the transaction. --- electrum/gui/qt/main_window.py | 21 +++++++++++++++++++++ electrum/gui/qt/send_tab.py | 5 ++++- electrum/gui/qt/swap_dialog.py | 21 +++++++++++++++------ electrum/gui/qt/util.py | 6 +++++- electrum/submarine_swaps.py | 16 +++++++++++----- 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index b1fa6ac48..b2f42dcea 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -299,6 +299,27 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self._update_check_thread.checked.connect(on_version_received) self._update_check_thread.start() + def run_coroutine_dialog(self, coro, text, on_result, on_cancelled): + """ run coroutine in a waiting dialog, with a Cancel button that cancels the coroutine """ + from electrum import util + loop = util.get_asyncio_loop() + assert util.get_running_loop() != loop, 'must not be called from asyncio thread' + future = asyncio.run_coroutine_threadsafe(coro, loop) + def task(): + try: + return future.result() + except concurrent.futures.CancelledError: + on_cancelled() + try: + WaitingDialog( + self, text, task, + on_success=on_result, + on_error=self.on_error, + on_cancel=future.cancel) + except Exception as e: + self.show_error(str(e)) + raise + def run_coroutine_from_thread(self, coro, name, on_result=None): if self._cleaned_up: self.logger.warning(f"stopping or already stopped but run_coroutine_from_thread was called.") diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 337bce707..fc0aca14e 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -724,7 +724,10 @@ class SendTab(QWidget, MessageBoxMixin, Logger): sm = self.wallet.lnworker.swap_manager swap = sm.get_swap(tx.swap_payment_hash) coro = sm.wait_for_htlcs_and_broadcast(swap, tx.swap_invoice, tx) - self.window.run_coroutine_from_thread(coro, _('Awaiting lightning payment..'), on_result=self.window.on_swap_result) + self.window.run_coroutine_dialog( + coro, _('Awaiting swap payment...'), + on_result=self.window.on_swap_result, + on_cancelled=lambda: sm.cancel_normal_swap(swap)) return def broadcast_thread(): diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py index 1800b76a6..945ffc0d9 100644 --- a/electrum/gui/qt/swap_dialog.py +++ b/electrum/gui/qt/swap_dialog.py @@ -317,16 +317,25 @@ class SwapDialog(WindowModalDialog, QtEventListener): self.ok_button.setEnabled(bool(send_amount) and bool(recv_amount)) def do_normal_swap(self, lightning_amount, onchain_amount, password): - tx = self._create_tx(onchain_amount) - assert tx - coro = self.swap_manager.normal_swap( + dummy_tx = self._create_tx(onchain_amount) + assert dummy_tx + sm = self.swap_manager + coro = sm.request_normal_swap( lightning_amount_sat=lightning_amount, expected_onchain_amount_sat=onchain_amount, - password=password, - tx=tx, channels=self.channels, ) - self.window.run_coroutine_from_thread(coro, _('Swapping funds'), on_result=self.window.on_swap_result) + try: + swap, invoice = self.network.run_from_another_thread(coro) + except Exception as e: + self.window.show_error(str(e)) + return + tx = sm.create_funding_tx(swap, dummy_tx, password) + coro2 = sm.wait_for_htlcs_and_broadcast(swap, invoice, tx) + self.window.run_coroutine_dialog( + coro2, _('Awaiting swap payment...'), + on_result=self.window.on_swap_result, + on_cancelled=lambda: sm.cancel_normal_swap(swap)) def get_description(self): onchain_funds = "onchain funds" diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index dee323a19..97490987c 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -320,7 +320,7 @@ class WindowModalDialog(QDialog, MessageBoxMixin): class WaitingDialog(WindowModalDialog): '''Shows a please wait dialog whilst running a task. It is not necessary to maintain a reference to this dialog.''' - def __init__(self, parent: QWidget, message: str, task, on_success=None, on_error=None): + def __init__(self, parent: QWidget, message: str, task, on_success=None, on_error=None, on_cancel=None): assert parent if isinstance(parent, MessageBoxMixin): parent = parent.top_level_window() @@ -328,6 +328,10 @@ class WaitingDialog(WindowModalDialog): self.message_label = QLabel(message) vbox = QVBoxLayout(self) vbox.addWidget(self.message_label) + if on_cancel: + self.cancel_button = CancelButton(self) + self.cancel_button.clicked.connect(on_cancel) + vbox.addLayout(Buttons(self.cancel_button)) self.accepted.connect(self.on_accepted) self.show() self.thread = TaskThread(self) diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 9555655c1..1af054203 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -252,7 +252,14 @@ class SwapManager(Logger): continue await self.taskgroup.spawn(self.pay_invoice(key)) - def fail_normal_swap(self, swap): + def cancel_normal_swap(self, swap): + """ we must not have broadcast the funding tx """ + if swap.funding_txid is not None: + self.logger.info(f'cannot fail swap {swap.payment_hash.hex()}: already funded') + return + self._fail_normal_swap(swap) + + def _fail_normal_swap(self, swap): if swap.payment_hash in self.lnworker.hold_invoice_callbacks: self.logger.info(f'failing normal swap {swap.payment_hash.hex()}') self.lnworker.unregister_hold_invoice(swap.payment_hash) @@ -282,7 +289,7 @@ class SwapManager(Logger): if not swap.is_reverse: # we might have received HTLCs and double spent the funding tx # in that case we need to fail the HTLCs - self.fail_normal_swap(swap) + self._fail_normal_swap(swap) txin = None if txin: @@ -321,7 +328,7 @@ class SwapManager(Logger): else: # refund tx if spent_height > 0: - self.fail_normal_swap(swap) + self._fail_normal_swap(swap) return if delta < 0: # too early for refund @@ -688,7 +695,6 @@ class SwapManager(Logger): else: # broadcast funding tx right away await self.broadcast_funding_tx(swap, tx) - # fixme: if broadcast fails, we need to fail htlcs and cancel the swap return swap.funding_txid def create_funding_tx(self, swap, tx, password): @@ -723,8 +729,8 @@ class SwapManager(Logger): @log_exceptions async def broadcast_funding_tx(self, swap, tx): - await self.network.broadcast_transaction(tx) swap.funding_txid = tx.txid() + await self.network.broadcast_transaction(tx) async def reverse_swap( self,