diff --git a/electrum/gui/qml/components/SwapDialog.qml b/electrum/gui/qml/components/SwapDialog.qml index a0ab14cd30d1..9ea3580c1a10 100644 --- a/electrum/gui/qml/components/SwapDialog.qml +++ b/electrum/gui/qml/components/SwapDialog.qml @@ -293,7 +293,19 @@ ElDialog { enabled: swaphelper.valid && (swaphelper.state == SwapHelper.ServiceReady || swaphelper.state == SwapHelper.Failed) onClicked: { - swaphelper.executeSwap() + if (swaphelper.isReverse) { + swaphelper.executeSwap() + } else { + swaphelper.prepareNormalSwap() + var dialog = forwardSwapTxDialog.createObject(app, { + finalizer: swaphelper.finalizer, + satoshis: swaphelper.finalizer.amount + }) + dialog.accepted.connect(function() { + swaphelper.executeSwap() + }) + dialog.open() + } } } FlatButton { @@ -331,6 +343,15 @@ ElDialog { } } + Component { + id: forwardSwapTxDialog + ConfirmTxDialog { + amountLabelText: qsTr('Amount to swap') + sendButtonText: qsTr('Swap') + finalizer: swaphelper.finalizer + } + } + Component.onCompleted: { swapslider.value = swaphelper.sliderPos } diff --git a/electrum/gui/qml/components/controls/FeeMethodComboBox.qml b/electrum/gui/qml/components/controls/FeeMethodComboBox.qml index 5c0927df388e..2c3d5414ec23 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 bcc1a8e6548b..148e6d7ab5a1 100644 --- a/electrum/gui/qml/qeswaphelper.py +++ b/electrum/gui/qml/qeswaphelper.py @@ -13,18 +13,19 @@ from electrum.transaction import PartialTxOutput, PartialTransaction from electrum.util import (NotEnoughFunds, NoDynamicFeeEstimates, profiler, get_asyncio_loop, age, wait_for2) -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 import messages - from .auth import AuthMixin, auth_protect from .qetypes import QEAmount from .qewallet import QEWallet from .util import QtEventListener, qt_event_listener +from .qetxfinalizer import QETxFinalizer if TYPE_CHECKING: - from electrum.submarine_swaps import SwapOffer + from electrum.submarine_swaps import SwapOffer, SwapData class InvalidSwapParameters(Exception): pass @@ -157,11 +158,13 @@ class State(IntEnum): def __init__(self, parent=None): super().__init__(parent) - self._wallet = None # type: Optional[QEWallet] - self._sliderPos = 0 - self._rangeMin = -1 - self._rangeMax = 1 - self._tx = None + 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 @@ -172,17 +175,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 = 0 - self._rightVoid = 0 + self._leftVoid: float = 0 + self._rightVoid: float = 0 - self._available_swapservers = None + self._available_swapservers: Optional[QESwapServerNPubListModel] = 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()) @@ -191,11 +198,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: @@ -215,6 +218,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): @@ -509,7 +517,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(), @@ -547,24 +555,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._preview_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._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): + 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() @@ -604,7 +614,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: @@ -617,19 +627,20 @@ 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 + 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( @@ -638,10 +649,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 @@ -675,32 +687,23 @@ 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 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) + 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._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): 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 bafe4a6c4090..aee2de5cde1a 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -2,7 +2,7 @@ from enum import IntEnum import threading from decimal import Decimal -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, Callable from functools import partial from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum @@ -54,7 +54,7 @@ def __init__(self, parent=None): self._wallet = None # type: Optional[QEWallet] self._sliderSteps = 0 self._sliderPos = 0 - self._fee_policy = None + self._fee_policy: Optional[FeePolicy] = None self._target = '' self._config = None # type: Optional[SimpleConfig] @@ -314,7 +314,13 @@ class QETxFinalizer(TxFeeSlider): finished = pyqtSignal([bool, bool, bool], arguments=['signed', 'saved', 'complete']) signError = pyqtSignal([str], arguments=['message']) - def __init__(self, parent=None, *, make_tx=None, accept=None): + def __init__( + self, + parent=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 self.f_accept = accept @@ -324,6 +330,7 @@ def __init__(self, parent=None, *, make_tx=None, accept=None): 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) @@ -376,8 +383,24 @@ 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): + def make_tx(self, amount: int | str) -> PartialTransaction: self._logger.debug(f'make_tx amount={amount}') if self.f_make_tx: @@ -417,6 +440,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 c65f74734fee..a67e9a10f2d7 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -67,6 +67,7 @@ assert MAX_LOCKTIME_DELTA < lnutil.MIN_FINAL_CLTV_DELTA_FOR_INVOICE 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