Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion electrum/gui/qml/components/SwapDialog.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
5 changes: 4 additions & 1 deletion electrum/gui/qml/components/controls/FeeMethodComboBox.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
133 changes: 68 additions & 65 deletions electrum/gui/qml/qeswaphelper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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())
Expand All @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
39 changes: 35 additions & 4 deletions electrum/gui/qml/qetxfinalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions electrum/submarine_swaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down