From a06196a8ed1934337b2b8dea8d8b1c722030e12c Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 27 Jun 2025 13:55:43 +0200 Subject: [PATCH 1/3] qml: let user finalize forward swap onchain tx before initiating swap --- electrum/gui/qml/components/SwapDialog.qml | 22 +++++- electrum/gui/qml/qeswaphelper.py | 82 +++++++++++----------- electrum/gui/qml/qetxfinalizer.py | 6 +- 3 files changed, 64 insertions(+), 46 deletions(-) diff --git a/electrum/gui/qml/components/SwapDialog.qml b/electrum/gui/qml/components/SwapDialog.qml index d3eead88135c..05ed15bcdc1a 100644 --- a/electrum/gui/qml/components/SwapDialog.qml +++ b/electrum/gui/qml/components/SwapDialog.qml @@ -290,7 +290,19 @@ ElDialog { enabled: swaphelper.valid && (swaphelper.state == SwapHelper.ServiceReady || swaphelper.state == SwapHelper.Failed) onClicked: { - swaphelper.executeSwap() + if (swaphelper.isReverse) { + swaphelper.executeSwap() + } else { + swaphelper.prepNormalSwap() + var dialog = forwardSwapTxDialog.createObject(app, { + finalizer: swaphelper.finalizer, + satoshis: swaphelper.finalizer.amount + }) + dialog.accepted.connect(function() { + swaphelper.executeSwap() + }) + dialog.open() + } } } FlatButton { @@ -328,6 +340,14 @@ ElDialog { } } + Component { + id: forwardSwapTxDialog + ConfirmTxDialog { + amountLabelText: qsTr('Amount to swap') + sendButtonText: qsTr('Swap') + } + } + Component.onCompleted: { swapslider.value = swaphelper.sliderPos } diff --git a/electrum/gui/qml/qeswaphelper.py b/electrum/gui/qml/qeswaphelper.py index 8bbf9483df0c..7df281fb3cae 100644 --- a/electrum/gui/qml/qeswaphelper.py +++ b/electrum/gui/qml/qeswaphelper.py @@ -7,6 +7,7 @@ QModelIndex, QVariant) from PyQt6.QtGui import QColor +from electrum.gui.qml.qetxfinalizer import QETxFinalizer from electrum.i18n import _ from electrum.bitcoin import DummyAddress from electrum.logging import get_logger @@ -29,9 +30,6 @@ from electrum.submarine_swaps import SwapOffer -class InvalidSwapParameters(Exception): pass - - class QESwapServerNPubListModel(QAbstractListModel): _logger = get_logger(__name__) @@ -160,10 +158,12 @@ def __init__(self, parent=None): super().__init__(parent) self._wallet = None # type: Optional[QEWallet] + self._finalizer = None # type: Optional[QETxFinalizer] self._sliderPos = 0 self._rangeMin = -1 self._rangeMax = 1 - self._tx = None + self._tx = None # updated on feeslider move and fee histogram updates, used for estimation + self._finalized_tx = None # updated by finalizer, used for actual forward swap self._valid = False self._state = QESwapHelper.State.Initialized self._userinfo = QESwapHelper.MESSAGE_SWAP_HOWTO @@ -217,6 +217,11 @@ def wallet(self, wallet: QEWallet): self.run_swap_manager() self.walletChanged.emit() + finalizerChanged = pyqtSignal() + @pyqtProperty(QETxFinalizer, notify=finalizerChanged) + def finalizer(self): + return self._finalizer + sliderPosChanged = pyqtSignal() @pyqtProperty(float, notify=sliderPosChanged) def sliderPos(self): @@ -555,24 +560,26 @@ def initSwapSliderRange(self): self.swap_slider_moved() @profiler - def update_tx(self, onchain_amount: Union[int, str]): + def update_tx(self, onchain_amount: Union[int, str], fee_policy: Optional[FeePolicy] = None): """Updates the transaction associated with a forward swap.""" if onchain_amount is None: self._tx = None self.valid = False return - outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)] - coins = self._wallet.wallet.get_spendable_coins(None) - fee_policy = FeePolicy('eta:2') try: - self._tx = self._wallet.wallet.make_unsigned_transaction( - coins=coins, - outputs=outputs, - fee_policy=fee_policy) + self._tx = self._create_swap_tx(onchain_amount, fee_policy) except (NotEnoughFunds, NoDynamicFeeEstimates): self._tx = None self.valid = False + def _create_swap_tx(self, onchain_amount: int | str, fee_policy: Optional[FeePolicy] = None): + outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)] + coins = self._wallet.wallet.get_spendable_coins(None) + fee_policy = fee_policy if fee_policy else FeePolicy('eta:2') + return self._wallet.wallet.make_unsigned_transaction( + coins=coins, outputs=outputs, fee_policy=fee_policy + ) + @qt_event_listener def on_event_fee_histogram(self, *args): self.swap_slider_moved() @@ -631,13 +638,15 @@ def fwd_swap_updatetx(self): def do_normal_swap(self, lightning_amount, onchain_amount): assert self._tx + assert self._finalized_tx if lightning_amount is None or onchain_amount is None: return + assert self._finalized_tx.get_dummy_output(DummyAddress.SWAP).value == onchain_amount + async def swap_task(): assert self.swap_transport is not None, "Swap transport not available" try: - dummy_tx = self._create_tx(onchain_amount) self.userinfo = _('Performing swap...') self.state = QESwapHelper.State.Started self._swap, invoice = await self._wallet.wallet.lnworker.swap_manager.request_normal_swap( @@ -646,10 +655,11 @@ async def swap_task(): expected_onchain_amount_sat=onchain_amount, ) - tx = self._wallet.wallet.lnworker.swap_manager.create_funding_tx(self._swap, dummy_tx, password=self._wallet.password) - coro2 = self._wallet.wallet.lnworker.swap_manager.wait_for_htlcs_and_broadcast( + tx = self._wallet.wallet.lnworker.swap_manager.create_funding_tx( + self._swap, self._finalized_tx, password=self._wallet.password) + coro = self._wallet.wallet.lnworker.swap_manager.wait_for_htlcs_and_broadcast( transport=self.swap_transport, swap=self._swap, invoice=invoice, tx=tx) - self._fut_htlc_wait = fut = asyncio.create_task(coro2) + self._fut_htlc_wait = fut = asyncio.create_task(coro) self.canCancel = True txid = await fut @@ -683,32 +693,20 @@ async def swap_task(): asyncio.run_coroutine_threadsafe(swap_task(), get_asyncio_loop()) - 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.client_max_amount_forward_swap() - if max_swap_amount is None: - raise InvalidSwapParameters("swap_manager.client_max_amount_forward_swap() is None") - if max_amount > max_swap_amount: - onchain_amount = max_swap_amount - outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)] - fee_policy = FeePolicy('eta:2') - try: - tx = self._wallet.wallet.make_unsigned_transaction( - coins=coins, - outputs=outputs, - send_change_to_lightning=False, - fee_policy=fee_policy - ) - except (NotEnoughFunds, NoDynamicFeeEstimates) as e: - raise InvalidSwapParameters(str(e)) from e - return tx + @pyqtSlot() + def prepNormalSwap(self): + def mktx(amt, fee_policy: FeePolicy): + try: + self._finalized_tx = self._create_swap_tx(amt, fee_policy) + except (NotEnoughFunds, NoDynamicFeeEstimates): + self._finalized_tx = None + return self._finalized_tx + + self._finalizer = QETxFinalizer(self, make_tx=mktx) + self._finalizer.canRbf = False + self._finalizer.amount = QEAmount(amount_sat=self._send_amount) + self._finalizer.wallet = self._wallet + self.finalizerChanged.emit() def do_reverse_swap(self, lightning_amount, onchain_amount): if lightning_amount is None or onchain_amount is None: diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index d0457c80ecbc..a1260fc82532 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -396,8 +396,8 @@ def __init__( self, parent=None, *, - make_tx: Callable[[int, FeePolicy], PartialTransaction] = None, - accept: Callable[[PartialTransaction], None] = None, + make_tx: Optional[Callable[[int | str, Optional[FeePolicy]], PartialTransaction]] = None, + accept: Optional[Callable[[PartialTransaction], None]] = None ): super().__init__(parent) self.f_make_tx = make_tx @@ -463,7 +463,7 @@ def canRbf(self, canRbf): self.rbf = self._canRbf # if we can RbF, we do RbF @profiler - def make_tx(self, amount): + def make_tx(self, amount: int | str) -> PartialTransaction: self._logger.debug(f'make_tx amount={amount}') if self.f_make_tx: From b8c6be190dc84ba3acce93b2376016899c2a3e79 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 9 Oct 2025 17:01:14 +0200 Subject: [PATCH 2/3] qml: swap: improve variable names, add typing hints, comments --- electrum/gui/qml/components/SwapDialog.qml | 2 +- electrum/gui/qml/qeswaphelper.py | 61 +++++++++++----------- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/electrum/gui/qml/components/SwapDialog.qml b/electrum/gui/qml/components/SwapDialog.qml index 05ed15bcdc1a..83d6a5f1703e 100644 --- a/electrum/gui/qml/components/SwapDialog.qml +++ b/electrum/gui/qml/components/SwapDialog.qml @@ -293,7 +293,7 @@ ElDialog { if (swaphelper.isReverse) { swaphelper.executeSwap() } else { - swaphelper.prepNormalSwap() + swaphelper.prepareNormalSwap() var dialog = forwardSwapTxDialog.createObject(app, { finalizer: swaphelper.finalizer, satoshis: swaphelper.finalizer.amount diff --git a/electrum/gui/qml/qeswaphelper.py b/electrum/gui/qml/qeswaphelper.py index 7df281fb3cae..badba3312d38 100644 --- a/electrum/gui/qml/qeswaphelper.py +++ b/electrum/gui/qml/qeswaphelper.py @@ -7,7 +7,6 @@ QModelIndex, QVariant) from PyQt6.QtGui import QColor -from electrum.gui.qml.qetxfinalizer import QETxFinalizer from electrum.i18n import _ from electrum.bitcoin import DummyAddress from electrum.logging import get_logger @@ -17,17 +16,16 @@ from electrum.submarine_swaps import NostrTransport, SwapServerTransport, pubkey_to_rgb_color from electrum.fee_policy import FeePolicy -from electrum.gui import messages - from electrum.gui.common_qt.util import QtEventListener, qt_event_listener from .auth import AuthMixin, auth_protect from .qetypes import QEAmount from .qewallet import QEWallet +from .qetxfinalizer import QETxFinalizer if TYPE_CHECKING: import concurrent.futures - from electrum.submarine_swaps import SwapOffer + from electrum.submarine_swaps import SwapOffer, SwapData class QESwapServerNPubListModel(QAbstractListModel): @@ -157,13 +155,13 @@ class State(IntEnum): def __init__(self, parent=None): super().__init__(parent) - self._wallet = None # type: Optional[QEWallet] - self._finalizer = None # type: Optional[QETxFinalizer] - self._sliderPos = 0 - self._rangeMin = -1 - self._rangeMax = 1 - self._tx = None # updated on feeslider move and fee histogram updates, used for estimation - self._finalized_tx = None # updated by finalizer, used for actual forward swap + self._wallet: Optional[QEWallet] = None + self._finalizer: Optional[QETxFinalizer] = None + self._sliderPos: float = 0 + self._rangeMin: float = -1 + self._rangeMax: float = 1 + self._preview_tx: Optional[PartialTransaction] = None # updated on feeslider move and fee histogram updates, used for estimation + self._finalized_tx: Optional[PartialTransaction] = None # updated by finalizer, used for actual forward swap self._valid = False self._state = QESwapHelper.State.Initialized self._userinfo = QESwapHelper.MESSAGE_SWAP_HOWTO @@ -174,17 +172,21 @@ def __init__(self, parent=None): self._miningfee = QEAmount() self._isReverse = False self._canCancel = False - self._swap = None - self._fut_htlc_wait = None + self._swap: Optional['SwapData'] = None + self._fut_htlc_wait: Optional[asyncio.Task] = None self._service_available = False - self._send_amount = 0 - self._receive_amount = 0 + self._send_amount: int = 0 + self._receive_amount: int = 0 + + self._leftVoid: float = 0 + self._rightVoid: float = 0 - self._leftVoid = 0 - self._rightVoid = 0 + self._available_swapservers: Optional[QESwapServerNPubListModel] = None - self._available_swapservers = None + self.transport_task: Optional[asyncio.Task] = None + self.swap_transport: Optional[SwapServerTransport] = None + self.recent_offers: Sequence[SwapOffer] = [] self.register_callbacks() self.destroyed.connect(lambda: self.on_destroy()) @@ -193,11 +195,7 @@ def __init__(self, parent=None): self._fwd_swap_updatetx_timer.setSingleShot(True) self._fwd_swap_updatetx_timer.timeout.connect(self.fwd_swap_updatetx) self.requestTxUpdate.connect(self.tx_update_pushback_timer) - self.offersUpdated.connect(self.on_offers_updated) - self.transport_task: Optional[asyncio.Task] = None - self.swap_transport: Optional[SwapServerTransport] = None - self.recent_offers = [] def on_destroy(self): if self.transport_task is not None: @@ -522,7 +520,7 @@ def initSwapSliderRange(self): # this is just to estimate the maximal spendable onchain amount for HTLC self.update_tx('!') try: - max_onchain_spend = self._tx.output_value_for_address(DummyAddress.SWAP) + max_onchain_spend = self._preview_tx.output_value_for_address(DummyAddress.SWAP) except AttributeError: # happens if there are no utxos max_onchain_spend = 0 reverse = int(min(lnworker.num_sats_can_send(), @@ -563,13 +561,13 @@ def initSwapSliderRange(self): def update_tx(self, onchain_amount: Union[int, str], fee_policy: Optional[FeePolicy] = None): """Updates the transaction associated with a forward swap.""" if onchain_amount is None: - self._tx = None + self._preview_tx = None self.valid = False return try: - self._tx = self._create_swap_tx(onchain_amount, fee_policy) + self._preview_tx = self._create_swap_tx(onchain_amount, fee_policy) except (NotEnoughFunds, NoDynamicFeeEstimates): - self._tx = None + self._preview_tx = None self.valid = False def _create_swap_tx(self, onchain_amount: int | str, fee_policy: Optional[FeePolicy] = None): @@ -619,7 +617,7 @@ def swap_slider_moved(self): def tx_update_pushback_timer(self): self._fwd_swap_updatetx_timer.start(250) - def check_valid(self, send_amount, receive_amount): + def check_valid(self, send_amount: int | None, receive_amount: int | None): if send_amount and receive_amount: self.valid = True else: @@ -632,12 +630,11 @@ def fwd_swap_updatetx(self): return self.update_tx(self._send_amount) # add lockup fees, but the swap amount is position - pay_amount = self._send_amount + self._tx.get_fee() if self._tx else 0 - self.miningfee = QEAmount(amount_sat=self._tx.get_fee()) if self._tx else QEAmount() + pay_amount = self._send_amount + self._preview_tx.get_fee() if self._preview_tx else 0 + self.miningfee = QEAmount(amount_sat=self._preview_tx.get_fee()) if self._preview_tx else QEAmount() self.check_valid(pay_amount, self._receive_amount) def do_normal_swap(self, lightning_amount, onchain_amount): - assert self._tx assert self._finalized_tx if lightning_amount is None or onchain_amount is None: return @@ -694,7 +691,9 @@ async def swap_task(): asyncio.run_coroutine_threadsafe(swap_task(), get_asyncio_loop()) @pyqtSlot() - def prepNormalSwap(self): + def prepareNormalSwap(self): + """prepare for normal swap by instantiating a finalizer for the requested amount. + self._finalized_tx will contain the finalized tx using the fees set by the user""" def mktx(amt, fee_policy: FeePolicy): try: self._finalized_tx = self._create_swap_tx(amt, fee_policy) From cc2ec15fc8edf0d8b73a9b4f9faac323dcf6999a Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 15 Oct 2025 14:33:51 +0200 Subject: [PATCH 3/3] qml: add deadline property to QETxFinalizer, use deadline for submarine swaps --- .../components/controls/FeeMethodComboBox.qml | 5 +++- electrum/gui/qml/qeswaphelper.py | 5 +++- electrum/gui/qml/qetxfinalizer.py | 25 +++++++++++++++++++ electrum/submarine_swaps.py | 1 + 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qml/components/controls/FeeMethodComboBox.qml b/electrum/gui/qml/components/controls/FeeMethodComboBox.qml index edfc85089a9b..7777a858475f 100644 --- a/electrum/gui/qml/components/controls/FeeMethodComboBox.qml +++ b/electrum/gui/qml/components/controls/FeeMethodComboBox.qml @@ -11,7 +11,10 @@ ElComboBox { textRole: 'text' valueRole: 'value' - model: [ + // NOTE: deadline property only exists on QETxFinalizer, but as undefined == false, that's ok. + model: feeslider.deadline ? [ + { text: qsTr('ETA'), value: FeeSlider.FSMethod.ETA } + ] : [ { text: qsTr('ETA'), value: FeeSlider.FSMethod.ETA }, { text: qsTr('Mempool'), value: FeeSlider.FSMethod.MEMPOOL }, { text: qsTr('Feerate'), value: FeeSlider.FSMethod.FEERATE }, diff --git a/electrum/gui/qml/qeswaphelper.py b/electrum/gui/qml/qeswaphelper.py index badba3312d38..234f795f5a14 100644 --- a/electrum/gui/qml/qeswaphelper.py +++ b/electrum/gui/qml/qeswaphelper.py @@ -13,7 +13,9 @@ from electrum.transaction import PartialTxOutput, PartialTransaction from electrum.util import (NotEnoughFunds, NoDynamicFeeEstimates, profiler, get_asyncio_loop, age, wait_for2, send_exception_to_crash_reporter) -from electrum.submarine_swaps import NostrTransport, SwapServerTransport, pubkey_to_rgb_color +from electrum.submarine_swaps import ( + NostrTransport, SwapServerTransport, pubkey_to_rgb_color, LOCKTIME_DELTA_REFUND, LOCKTIME_DELTA_REFUND_BUFFER +) from electrum.fee_policy import FeePolicy from electrum.gui.common_qt.util import QtEventListener, qt_event_listener @@ -705,6 +707,7 @@ def mktx(amt, fee_policy: FeePolicy): self._finalizer.canRbf = False self._finalizer.amount = QEAmount(amount_sat=self._send_amount) self._finalizer.wallet = self._wallet + self._finalizer.deadline = LOCKTIME_DELTA_REFUND - LOCKTIME_DELTA_REFUND_BUFFER # 10-block buffer before refund deadline self.finalizerChanged.emit() def do_reverse_swap(self, lightning_amount, onchain_amount): diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index a1260fc82532..0f9c84f0a143 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -408,6 +408,7 @@ def __init__( self._effectiveAmount = QEAmount() self._extraFee = QEAmount() self._canRbf = False + self._deadline = 0 # if deadline is set > 0, finalizer should only allow ETA feepolicies addressChanged = pyqtSignal() @pyqtProperty(str, notify=addressChanged) @@ -462,6 +463,22 @@ def canRbf(self, canRbf): self.canRbfChanged.emit() self.rbf = self._canRbf # if we can RbF, we do RbF + deadlineChanged = pyqtSignal() + @pyqtProperty(int, notify=deadlineChanged) + def deadline(self): + return self._deadline + + @deadline.setter + def deadline(self, relative_num_blocks: int) -> None: + """if set, limits the finalizer to ETA fee policies that meet the deadline. + deadline is in relative blocks""" + if self._deadline != relative_num_blocks: + self._deadline = relative_num_blocks + self.deadlineChanged.emit() + if self._deadline > 0: + self.method = FeeSlider.FSMethod.ETA + self.update() + @profiler def make_tx(self, amount: int | str) -> PartialTransaction: self._logger.debug(f'make_tx amount={amount}') @@ -508,6 +525,14 @@ def update(self): self.validChanged.emit() return + if self._deadline: + if self._deadline < self._fee_policy.value: + self._logger.info(f"current fee '{str(self._fee_policy)}' below deadline {str(self._deadline)}") + self.warning = _("Current fee doesn't meet deadline of {} blocks").format(self._deadline) + self._valid = False + self.validChanged.emit() + return + self._tx = tx amount = self._amount.satsInt if not self._amount.isMax else tx.output_value() diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index c36abb02412e..35ae2241ca87 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -83,6 +83,7 @@ assert MAX_LOCKTIME_DELTA < lnutil.MIN_FINAL_CLTV_DELTA_ACCEPTED assert MAX_LOCKTIME_DELTA < MIN_FINAL_CLTV_DELTA_FOR_CLIENT +LOCKTIME_DELTA_REFUND_BUFFER = 10 # used for min ETA fee calculation # The script of the reverse swaps has one extra check in it to verify # that the length of the preimage is 32. This is required because in