From bbfe5225b69a54c17f68f927c2ba1ac97252ff58 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 18 Sep 2023 16:13:32 +0200 Subject: [PATCH] qml: port cancel normal swap feature from desktop client --- electrum/gui/qml/components/SwapDialog.qml | 30 ++++++-- electrum/gui/qml/qeswaphelper.py | 89 +++++++++++++++++++--- 2 files changed, 100 insertions(+), 19 deletions(-) diff --git a/electrum/gui/qml/components/SwapDialog.qml b/electrum/gui/qml/components/SwapDialog.qml index b005b28ca..8c3f64a85 100644 --- a/electrum/gui/qml/components/SwapDialog.qml +++ b/electrum/gui/qml/components/SwapDialog.qml @@ -35,7 +35,7 @@ ElDialog { text: swaphelper.userinfo iconStyle: swaphelper.state == SwapHelper.Started ? InfoTextArea.IconStyle.Spinner - : swaphelper.state == SwapHelper.Failed + : swaphelper.state == SwapHelper.Failed || swaphelper.state == SwapHelper.Cancelled ? InfoTextArea.IconStyle.Error : swaphelper.state == SwapHelper.Success ? InfoTextArea.IconStyle.Done @@ -251,15 +251,31 @@ ElDialog { Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } - FlatButton { + ButtonContainer { Layout.columnSpan: 2 Layout.fillWidth: true - text: qsTr('Ok') - icon.source: Qt.resolvedUrl('../../icons/confirmed.png') - enabled: swaphelper.valid && (swaphelper.state == SwapHelper.ServiceReady || swaphelper.state == SwapHelper.Failed) + FlatButton { + Layout.fillWidth: true + Layout.preferredWidth: 1 + text: qsTr('Ok') + icon.source: Qt.resolvedUrl('../../icons/confirmed.png') + visible: !swaphelper.canCancel + enabled: swaphelper.valid && (swaphelper.state == SwapHelper.ServiceReady || swaphelper.state == SwapHelper.Failed) + + onClicked: { + swaphelper.executeSwap() + } + } + FlatButton { + Layout.fillWidth: true + Layout.preferredWidth: 1 + text: qsTr('Cancel') + icon.source: Qt.resolvedUrl('../../icons/closebutton.png') + visible: swaphelper.canCancel - onClicked: { - swaphelper.executeSwap() + onClicked: { + swaphelper.cancelNormalSwap() + } } } } diff --git a/electrum/gui/qml/qeswaphelper.py b/electrum/gui/qml/qeswaphelper.py index f5bd90911..a8b73e122 100644 --- a/electrum/gui/qml/qeswaphelper.py +++ b/electrum/gui/qml/qeswaphelper.py @@ -1,6 +1,6 @@ import asyncio +import concurrent import threading -import math from typing import Union from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer, Q_ENUMS @@ -8,7 +8,7 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer, Q_ from electrum.i18n import _ from electrum.bitcoin import DummyAddress from electrum.logging import get_logger -from electrum.transaction import PartialTxOutput +from electrum.transaction import PartialTxOutput, PartialTransaction from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, profiler, get_asyncio_loop from .auth import AuthMixin, auth_protect @@ -16,6 +16,10 @@ from .qetypes import QEAmount from .qewallet import QEWallet from .util import QtEventListener, qt_event_listener + +class InvalidSwapParameters(Exception): pass + + class QESwapHelper(AuthMixin, QObject, QtEventListener): _logger = get_logger(__name__) @@ -25,6 +29,7 @@ class QESwapHelper(AuthMixin, QObject, QtEventListener): Started = 2 Failed = 3 Success = 4 + Cancelled = 5 Q_ENUMS(State) @@ -51,7 +56,10 @@ class QESwapHelper(AuthMixin, QObject, QtEventListener): self._server_miningfee = QEAmount() self._miningfee = QEAmount() self._isReverse = False - + self._canCancel = False + self._swap = None + self._fut_htlc_wait = None + self._service_available = False self._send_amount = 0 self._receive_amount = 0 @@ -225,6 +233,16 @@ class QESwapHelper(AuthMixin, QObject, QtEventListener): self._isReverse = isReverse self.isReverseChanged.emit() + canCancelChanged = pyqtSignal() + @pyqtProperty(bool, notify=canCancelChanged) + def canCancel(self): + return self._canCancel + + @canCancel.setter + def canCancel(self, canCancel): + if self._canCancel != canCancel: + self._canCancel = canCancel + self.canCancelChanged.emit() def init_swap_slider_range(self): lnworker = self._wallet.wallet.lnworker @@ -346,20 +364,26 @@ class QESwapHelper(AuthMixin, QObject, QtEventListener): if lightning_amount is None or onchain_amount is None: return loop = get_asyncio_loop() - coro = self._wallet.wallet.lnworker.swap_manager.normal_swap( + coro = self._wallet.wallet.lnworker.swap_manager.request_normal_swap( lightning_amount_sat=lightning_amount, expected_onchain_amount_sat=onchain_amount, - password=self._wallet.password, - tx=self._tx, ) def swap_task(): try: + dummy_tx = self._create_tx(onchain_amount) fut = asyncio.run_coroutine_threadsafe(coro, loop) self.userinfo = _('Performing swap...') self.state = QESwapHelper.State.Started + self._swap, invoice = fut.result() + + tx = self._wallet.wallet.lnworker.swap_manager.create_funding_tx(self._swap, dummy_tx, self._wallet.password) + coro2 = self._wallet.wallet.lnworker.swap_manager.wait_for_htlcs_and_broadcast(self._swap, invoice, tx) + self._fut_htlc_wait = fut = asyncio.run_coroutine_threadsafe(coro2, loop) + + self.canCancel = True txid = fut.result() - try: # swaphelper might be destroyed at this point + try: # swaphelper might be destroyed at this point self.userinfo = ' '.join([ _('Success!'), _('Your funding transaction has been broadcast.'), @@ -369,16 +393,51 @@ class QESwapHelper(AuthMixin, QObject, QtEventListener): self.state = QESwapHelper.State.Success except RuntimeError: pass + except concurrent.futures.CancelledError: + self._wallet.wallet.lnworker.swap_manager.cancel_normal_swap(self._swap) + self.userinfo = _('Swap cancelled') + self.state = QESwapHelper.State.Cancelled except Exception as e: - try: # swaphelper might be destroyed at this point + try: # swaphelper might be destroyed at this point self.state = QESwapHelper.State.Failed self.userinfo = _('Error') + ': ' + str(e) self._logger.error(str(e)) except RuntimeError: pass + finally: + try: # swaphelper might be destroyed at this point + self.canCancel = False + self._swap = None + self._fut_htlc_wait = None + except RuntimeError: + pass threading.Thread(target=swap_task, daemon=True).start() + def _create_tx(self, onchain_amount: Union[int, str, None]) -> PartialTransaction: + # TODO: func taken from qt GUI, this should be common code + assert not self.isReverse + if onchain_amount is None: + raise InvalidSwapParameters("onchain_amount is None") + # coins = self.window.get_coins() + coins = self._wallet.wallet.get_spendable_coins() + if onchain_amount == '!': + max_amount = sum(c.value_sats() for c in coins) + max_swap_amount = self._wallet.wallet.lnworker.swap_manager.max_amount_forward_swap() + if max_swap_amount is None: + raise InvalidSwapParameters("swap_manager.max_amount_forward_swap() is None") + if max_amount > max_swap_amount: + onchain_amount = max_swap_amount + self._wallet.wallet.config.WALLET_SEND_CHANGE_TO_LIGHTNING = False + outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)] + try: + tx = self._wallet.wallet.make_unsigned_transaction( + coins=coins, + outputs=outputs) + except (NotEnoughFunds, NoDynamicFeeEstimates) as e: + raise InvalidSwapParameters(str(e)) from e + return tx + def do_reverse_swap(self, lightning_amount, onchain_amount): if lightning_amount is None or onchain_amount is None: return @@ -394,9 +453,9 @@ class QESwapHelper(AuthMixin, QObject, QtEventListener): fut = asyncio.run_coroutine_threadsafe(coro, loop) self.userinfo = _('Performing swap...') self.state = QESwapHelper.State.Started - success = fut.result() - try: # swaphelper might be destroyed at this point - if success: + txid = fut.result() + try: # swaphelper might be destroyed at this point + if txid: self.userinfo = ' '.join([ _('Success!'), _('The funding transaction has been detected.'), @@ -410,7 +469,7 @@ class QESwapHelper(AuthMixin, QObject, QtEventListener): except RuntimeError: pass except Exception as e: - try: # swaphelper might be destroyed at this point + try: # swaphelper might be destroyed at this point self.state = QESwapHelper.State.Failed self.userinfo = _('Error') + ': ' + str(e) self._logger.error(str(e)) @@ -436,3 +495,9 @@ class QESwapHelper(AuthMixin, QObject, QtEventListener): lightning_amount = self._receive_amount onchain_amount = self._send_amount self.do_normal_swap(lightning_amount, onchain_amount) + + @pyqtSlot() + def cancelNormalSwap(self): + assert self._swap + self.canCancel = False + self._fut_htlc_wait.cancel()