1
0
Files
electrum/electrum/gui/qt/swap_dialog.py
f321x a1841600a1 TxEditor: update dynamically based on swap transport
Update the TxEditor (onchain tab) if Send change to lightning is enabled
and the swap transport changes. Connect to swap transport if send change
to lightning gets enabled or if it is enabled and the TxEditor gets
opened.
This allows to nicely show the swap fees without blocking the UI to wait
until the swap manager gets initialized.
2026-01-22 10:09:28 +01:00

575 lines
24 KiB
Python

import enum
from typing import TYPE_CHECKING, Optional, Union, Tuple, Sequence, Callable
from PyQt6.QtCore import pyqtSignal, Qt, QTimer
from PyQt6.QtGui import QIcon, QPixmap, QColor
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton
from PyQt6.QtWidgets import QTreeWidget, QTreeWidgetItem, QHeaderView
from electrum_aionostr.util import from_nip19
from electrum.i18n import _
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, UserCancelled, trigger_callback
from electrum.bitcoin import DummyAddress
from electrum.transaction import PartialTxOutput, PartialTransaction
from electrum.fee_policy import FeePolicy
from electrum.submarine_swaps import NostrTransport
from electrum.gui import messages
from . import util
from .util import (WindowModalDialog, Buttons, OkButton, CancelButton,
EnterButton, ColorScheme, WWLabel, read_QIcon, IconLabel, char_width_in_lineedit,
pubkey_to_q_icon)
from .util import qt_event_listener, QtEventListener
from .amountedit import BTCAmountEdit
from .fee_slider import FeeSlider, FeeComboBox
from .my_treeview import create_toolbar_with_menu, MyTreeView
if TYPE_CHECKING:
from .main_window import ElectrumWindow
from electrum.submarine_swaps import SwapServerTransport, SwapOffer
from electrum.lnchannel import Channel
from electrum.simple_config import SimpleConfig
CANNOT_RECEIVE_WARNING = _(
"""The requested amount is higher than what you can receive in your currently open channels.
If you continue, your funds will be locked until the remote server can find a path to pay you.
If the swap cannot be performed after 24h, you will be refunded.
Do you want to continue?"""
)
ROLE_NPUB = Qt.ItemDataRole.UserRole + 1000
class InvalidSwapParameters(Exception): pass
class SwapProvidersButton(QPushButton):
def __init__(
self,
transport_getter: Callable[[], Optional['SwapServerTransport']],
config: 'SimpleConfig',
main_window: 'ElectrumWindow',
):
"""parent must have a transport() method"""
QPushButton.__init__(self)
self.config = config
self.transport_getter = transport_getter
self.main_window = main_window
self.clicked.connect(self.choose_swap_server)
self.fetching = False
self.update()
def update(self):
if self.fetching:
self.setEnabled(False)
self.setText(_("Fetching..."))
self.setVisible(True)
return
transport = self.transport_getter()
if not isinstance(transport, NostrTransport):
# HTTPTransport or no Network, not showing server selection button
self.setEnabled(False)
self.setVisible(False)
return
self.setEnabled(True)
self.setVisible(True)
offer_count = len(transport.get_recent_offers())
button_text = f' {offer_count} ' + (_('swap providers') if offer_count != 1 else _('swap provider'))
self.setText(button_text)
# update icon
if self.config.SWAPSERVER_NPUB:
pubkey = from_nip19(self.config.SWAPSERVER_NPUB)['object'].hex()
self.setIcon(pubkey_to_q_icon(pubkey))
def choose_swap_server(self) -> None:
transport = self.transport_getter()
assert isinstance(transport, NostrTransport), transport
self.main_window.choose_swapserver_dialog(transport) # type: ignore
self.update()
trigger_callback('swap_provider_changed')
class SwapDialog(WindowModalDialog, QtEventListener):
def __init__(
self,
window: 'ElectrumWindow',
transport: 'SwapServerTransport',
is_reverse: Optional[bool] = None,
recv_amount_sat_or_max: Optional[Union[int, str]] = None, # sat or '!'
channels: Optional[Sequence['Channel']] = None,
):
WindowModalDialog.__init__(self, window, _('Submarine Swap'))
self.window = window
self.config = window.config
self.lnworker = self.window.wallet.lnworker
self.swap_manager = self.lnworker.swap_manager
self.network = window.network
self.channels = channels
self.is_reverse = is_reverse if is_reverse is not None else True
vbox = QVBoxLayout(self)
self.transport = transport
self.server_button = SwapProvidersButton(lambda: self.transport, self.config, self.window)
self.description_label = WWLabel(self.get_description())
self.send_amount_e = BTCAmountEdit(self.window.get_decimal_point)
self.recv_amount_e = BTCAmountEdit(self.window.get_decimal_point)
self.max_button = EnterButton(_("Max"), self.spend_max)
btn_width = 10 * char_width_in_lineedit()
self.max_button.setFixedWidth(btn_width)
self.max_button.setCheckable(True)
self.toggle_button = QPushButton(' \U000021c4 ') # whitespace to force larger min width
self.toggle_button.setEnabled(is_reverse is None)
# send_follows is used to know whether the send amount field / receive
# amount field should be adjusted after the fee slider was moved
self.send_follows = False
self.send_amount_e.follows = False
self.recv_amount_e.follows = False
self.toggle_button.clicked.connect(self.toggle_direction)
# textChanged is triggered for both user and automatic action
self.send_amount_e.textChanged.connect(self.on_send_edited)
self.recv_amount_e.textChanged.connect(self.on_recv_edited)
# textEdited is triggered only for user editing of the fields
self.send_amount_e.textEdited.connect(self.uncheck_max)
self.recv_amount_e.textEdited.connect(self.uncheck_max)
self.fee_policy = FeePolicy(self.config.FEE_POLICY)
self.fee_slider = FeeSlider(parent=self, network=self.network, fee_policy=self.fee_policy, callback=self.fee_slider_callback)
self.fee_combo = FeeComboBox(self.fee_slider)
self.fee_target_label = QLabel()
self._set_fee_slider_visibility(is_visible=not self.is_reverse)
self.swap_limits_label = QLabel()
self.fee_label = QLabel()
self.server_fee_label = QLabel()
self.last_server_mining_fee_sat = None
h = QGridLayout()
h.addWidget(self.description_label, 0, 0, 1, 3)
h.addWidget(self.toggle_button, 0, 3)
self.send_label = IconLabel(text=_('You send')+':')
self.recv_label = IconLabel(text=_('You receive')+':')
h.addWidget(self.send_label, 1, 0)
h.addWidget(self.send_amount_e, 1, 1)
h.addWidget(self.max_button, 1, 2)
h.addWidget(self.recv_label, 2, 0)
h.addWidget(self.recv_amount_e, 2, 1)
h.addWidget(QLabel(_('Swap limits')+':'), 4, 0)
h.addWidget(self.swap_limits_label, 4, 1, 1, 2)
h.addWidget(QLabel(_('Server fee')+':'), 5, 0)
h.addWidget(self.server_fee_label, 5, 1, 1, 2)
h.addWidget(QLabel(_('Mining fee')+':'), 6, 0)
h.addWidget(self.fee_label, 6, 1, 1, 2)
h.addWidget(self.fee_slider, 7, 1)
h.addWidget(self.fee_combo, 7, 2)
h.addWidget(self.fee_target_label, 7, 0)
h.addWidget(QLabel(''), 8, 0)
vbox.addLayout(h)
vbox.addStretch()
self.ok_button = OkButton(self)
self.ok_button.setDefault(True)
self.ok_button.setEnabled(False)
buttons = Buttons(CancelButton(self), self.ok_button)
vbox.addLayout(buttons)
buttons.insertWidget(0, self.server_button)
if recv_amount_sat_or_max:
assert isinstance(recv_amount_sat_or_max, (int, str)), f"invalid {type(recv_amount_sat_or_max)=}"
self.init_recv_amount(recv_amount_sat_or_max)
self.update()
self.needs_tx_update = True
self.timer = QTimer(self)
self.timer.setInterval(500)
self.timer.setSingleShot(False)
self.timer.timeout.connect(self.timer_actions)
self.timer.start()
self.fee_slider.update()
self.register_callbacks()
def closeEvent(self, event):
self.unregister_callbacks()
event.accept()
@qt_event_listener
def on_event_fee_histogram(self, *args):
self.update_send_receive()
@qt_event_listener
def on_event_fee(self, *args):
self.update_send_receive()
@qt_event_listener
def on_event_swap_offers_changed(self, recent_offers: Sequence['SwapOffer']):
self.server_button.update()
if not self.ok_button.isEnabled():
# only update the dialog with the new offer if the user hasn't entered an amount yet.
# if the user has already entered an amount we prefer the swap to fail due to outdated
# fees than the possibility of a swap happening with fees the user hasn't seen
# due to an update happening just before the user initiated the swap
self.update()
@qt_event_listener
def on_event_swap_provider_changed(self):
self.update()
self.update_send_receive()
def timer_actions(self):
if self.needs_tx_update:
self.update_tx()
self.update_ok_button()
self.needs_tx_update = False
def init_recv_amount(self, recv_amount_sat):
if recv_amount_sat == '!':
self.max_button.setChecked(True)
self.spend_max()
else:
recv_amount_sat = max(recv_amount_sat, self.swap_manager.get_min_amount())
self.recv_amount_e.setAmount(recv_amount_sat)
def fee_slider_callback(self, fee_rate):
self.config.FEE_POLICY = self.fee_policy.get_descriptor()
if not self.is_reverse:
self.fee_target_label.setText(self.fee_policy.get_target_text())
self.update_send_receive()
self.update()
def _set_fee_slider_visibility(self, *, is_visible: bool):
if is_visible:
self.fee_slider.setEnabled(True)
self.fee_combo.setEnabled(True)
self.fee_target_label.setText(self.fee_policy.get_target_text())
else:
self.fee_slider.setEnabled(False)
self.fee_combo.setEnabled(False)
# show the eta of the swap claim
self.fee_target_label.setText(FeePolicy(self.config.FEE_POLICY_SWAPS).get_target_text())
def toggle_direction(self):
self.is_reverse = not self.is_reverse
self._set_fee_slider_visibility(is_visible=not self.is_reverse)
self.send_amount_e.setAmount(None)
self.recv_amount_e.setAmount(None)
self.max_button.setChecked(False)
self.update()
def spend_max(self):
if self.max_button.isChecked():
if self.is_reverse:
self._spend_max_reverse_swap()
else:
# spend_max_forward_swap will be called in update_tx
pass
else:
self.send_amount_e.setAmount(None)
self.needs_tx_update = True
def uncheck_max(self):
self.max_button.setChecked(False)
self.update()
def _spend_max_forward_swap(self, tx: Optional[PartialTransaction]) -> None:
if tx:
amount = tx.output_value_for_address(DummyAddress.SWAP)
self.send_amount_e.setAmount(amount)
else:
self.send_amount_e.setAmount(None)
self.max_button.setChecked(False)
def _spend_max_reverse_swap(self) -> None:
amount = min(self.lnworker.num_sats_can_send(), self.swap_manager.get_provider_max_forward_amount())
amount = int(amount) # round down msats
self.send_amount_e.setAmount(amount)
def on_send_edited(self):
if self.send_amount_e.follows:
return
self.send_amount_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
send_amount = self.send_amount_e.get_amount()
recv_amount = self.swap_manager.get_recv_amount(send_amount, is_reverse=self.is_reverse)
if self.is_reverse and send_amount and send_amount > self.lnworker.num_sats_can_send():
# cannot send this much on lightning
recv_amount = None
if (not self.is_reverse) and recv_amount and recv_amount > self.lnworker.num_sats_can_receive():
# cannot receive this much on lightning
recv_amount = None
self.recv_amount_e.follows = True
self.recv_amount_e.setAmount(recv_amount)
self.recv_amount_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet())
self.recv_amount_e.follows = False
self.send_follows = False
self.needs_tx_update = True
def on_recv_edited(self):
if self.recv_amount_e.follows:
return
self.recv_amount_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
recv_amount = self.recv_amount_e.get_amount()
send_amount = self.swap_manager.get_send_amount(recv_amount, is_reverse=self.is_reverse)
if self.is_reverse and send_amount and send_amount > self.lnworker.num_sats_can_send():
send_amount = None
self.send_amount_e.follows = True
self.send_amount_e.setAmount(send_amount)
self.send_amount_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet())
self.send_amount_e.follows = False
self.send_follows = True
self.needs_tx_update = True
def update_send_receive(self):
self.on_recv_edited() if self.send_follows else self.on_send_edited()
def update(self):
sm = self.swap_manager
w_base_unit = self.window.base_unit()
send_icon = read_QIcon("lightning.png" if self.is_reverse else "bitcoin.png")
self.send_label.setIcon(send_icon)
recv_icon = read_QIcon("lightning.png" if not self.is_reverse else "bitcoin.png")
self.recv_label.setIcon(recv_icon)
self.description_label.setText(self.get_description())
self.description_label.repaint() # macOS hack for #6269
min_swap_limit, max_swap_limit = self.get_client_swap_limits_sat()
if max_swap_limit == 0:
swap_name = _("reverse") if self.is_reverse else _("forward")
swap_limit_str = _("No {} swap possible with this provider").format(swap_name)
else:
swap_limit_str = (f"{self.window.format_amount(min_swap_limit)} - "
f"{self.window.format_amount(max_swap_limit)} {w_base_unit}")
self.swap_limits_label.setText(swap_limit_str)
self.swap_limits_label.repaint() # macOS hack for #6269
self.last_server_mining_fee_sat = sm.mining_fee
server_fee_str = '%.2f'%sm.percentage + '% + ' + self.window.format_amount(sm.mining_fee) + ' ' + w_base_unit
self.server_fee_label.setText(server_fee_str)
self.server_fee_label.repaint() # macOS hack for #6269
self.needs_tx_update = True
def get_client_swap_limits_sat(self) -> Tuple[int, int]:
"""Returns the (min, max) client swap limits in sat."""
sm = self.swap_manager
if self.is_reverse:
lower_limit = sm.get_min_amount()
upper_limit = sm.client_max_amount_reverse_swap() or 0
else:
lower_limit = sm.get_send_amount(sm.get_min_amount(), is_reverse=False) or sm.get_min_amount()
upper_limit = sm.client_max_amount_forward_swap() or 0
if lower_limit > upper_limit:
# if the max possible amount is below the lower limit no swap is possible
lower_limit, upper_limit = 0, 0
return lower_limit, upper_limit
def update_fee(self, tx: Optional[PartialTransaction]) -> None:
"""Updates self.fee_label. No other side-effects."""
if self.is_reverse:
sm = self.swap_manager
fee = sm.get_fee_for_txbatcher()
else:
fee = tx.get_fee() if tx else None
fee_text = self.window.format_amount(fee) + ' ' + self.window.base_unit() if fee else _("no input")
self.fee_label.setText(fee_text)
self.fee_label.repaint() # macOS hack for #6269
def run(self, transport: 'SwapServerTransport') -> bool:
"""Can raise InvalidSwapParameters."""
if not self.exec():
return False
if self.is_reverse:
lightning_amount = self.send_amount_e.get_amount()
onchain_amount = self.recv_amount_e.get_amount()
if lightning_amount is None or onchain_amount is None:
return False
sm = self.swap_manager
coro = sm.reverse_swap(
transport=transport,
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount + self.swap_manager.get_fee_for_txbatcher(),
prepayment_sat=2 * self.last_server_mining_fee_sat,
)
try:
# we must not leave the context, so we use run_couroutine_dialog
funding_txid = self.window.run_coroutine_dialog(coro, _('Initiating swap...'))
except Exception as e:
self.window.show_error(f"Reverse swap failed: {str(e)}")
return False
self.window.on_swap_result(funding_txid, is_reverse=True)
return True
else:
lightning_amount = self.recv_amount_e.get_amount()
onchain_amount = self.send_amount_e.get_amount()
if lightning_amount is None or onchain_amount is None:
return False
if lightning_amount > self.lnworker.num_sats_can_receive():
if not self.window.question(CANNOT_RECEIVE_WARNING):
return False
self.window.protect(self.do_normal_swap, (transport, lightning_amount, onchain_amount))
return True
def update_tx(self) -> None:
if self.is_reverse:
self.update_fee(None)
return
is_max = self.max_button.isChecked()
if is_max:
tx = self._create_tx_safe('!')
self._spend_max_forward_swap(tx)
else:
onchain_amount = self.send_amount_e.get_amount()
tx = self._create_tx_safe(onchain_amount)
self.update_fee(tx)
def _create_tx(self, onchain_amount: Union[int, str, None]) -> PartialTransaction:
assert not self.is_reverse
if onchain_amount is None:
raise InvalidSwapParameters("onchain_amount is None")
coins = self.window.get_coins()
if onchain_amount == '!':
max_amount = sum(c.value_sats() for c in coins)
max_swap_amount = self.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)]
try:
tx = self.window.wallet.make_unsigned_transaction(
fee_policy=self.fee_policy,
coins=coins,
outputs=outputs,
send_change_to_lightning=False,
)
except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
raise InvalidSwapParameters(str(e)) from e
return tx
def _create_tx_safe(self, onchain_amount: Union[int, str, None]) -> Optional[PartialTransaction]:
try:
return self._create_tx(onchain_amount=onchain_amount)
except InvalidSwapParameters:
return None
def update_ok_button(self):
"""Updates self.ok_button. No other side-effects."""
send_amount = self.send_amount_e.get_amount()
recv_amount = self.recv_amount_e.get_amount()
self.ok_button.setEnabled(bool(send_amount) and bool(recv_amount))
async def _do_normal_swap(self, transport, lightning_amount, onchain_amount, password):
dummy_tx = self._create_tx(onchain_amount)
assert dummy_tx
sm = self.swap_manager
swap, invoice = await sm.request_normal_swap(
transport=transport,
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount,
channels=self.channels,
)
self._current_swap = swap
tx = sm.create_funding_tx(swap, dummy_tx, password=password)
txid = await sm.wait_for_htlcs_and_broadcast(transport=transport, swap=swap, invoice=invoice, tx=tx)
return txid
def do_normal_swap(self, transport, lightning_amount, onchain_amount, password):
self._current_swap = None
coro = self._do_normal_swap(transport, lightning_amount, onchain_amount, password)
try:
funding_txid = self.window.run_coroutine_dialog(coro, _('Awaiting swap payment...'))
except UserCancelled:
self.swap_manager.cancel_normal_swap(self._current_swap)
self.window.show_message(_('Swap cancelled'))
return
except Exception as e:
self.window.show_error(str(e))
return
self.window.on_swap_result(funding_txid, is_reverse=False)
def get_description(self):
onchain_funds = "onchain"
lightning_funds = "lightning"
return "Send {fromType}, receive {toType}.\nThis will increase your lightning {capacityType} capacity.\n".format(
fromType=lightning_funds if self.is_reverse else onchain_funds,
toType=onchain_funds if self.is_reverse else lightning_funds,
capacityType="receiving" if self.is_reverse else "sending",
)
class SwapServerDialog(WindowModalDialog, QtEventListener):
class Columns(MyTreeView.BaseColumnsEnum):
PUBKEY = enum.auto()
FEE = enum.auto()
MAX_FORWARD = enum.auto()
MAX_REVERSE = enum.auto()
LAST_SEEN = enum.auto()
headers = {
Columns.PUBKEY: _("Pubkey"),
Columns.FEE: _("Fee"),
Columns.MAX_FORWARD: _('Max Forward'),
Columns.MAX_REVERSE: _('Max Reverse'),
Columns.LAST_SEEN: _("Last seen"),
}
def __init__(self, window: 'ElectrumWindow', servers: Sequence['SwapOffer']):
WindowModalDialog.__init__(self, window, _('Choose Swap Provider'))
self.window = window
self.config = window.config
msg = '\n'.join([
_("Please choose a provider from this list."),
_("Note that fees and liquidity may be updated frequently.")
])
self.servers_list = QTreeWidget()
col_names = [self.headers[col_idx] for col_idx in sorted(self.headers.keys())]
self.servers_list.setHeaderLabels(col_names)
self.servers_list.header().setStretchLastSection(False)
for col_idx in range(len(self.Columns)):
sm = QHeaderView.ResizeMode.Stretch if col_idx == self.Columns.PUBKEY else QHeaderView.ResizeMode.ResizeToContents
self.servers_list.header().setSectionResizeMode(col_idx, sm)
self.update_servers_list(servers)
vbox = QVBoxLayout()
self.setLayout(vbox)
vbox.addWidget(WWLabel(msg))
vbox.addWidget(self.servers_list)
vbox.addStretch()
self.ok_button = OkButton(self)
vbox.addLayout(Buttons(CancelButton(self), self.ok_button))
self.setMinimumWidth(650)
self.register_callbacks()
def run(self):
if self.exec() != 1:
return None
if item := self.servers_list.currentItem():
return item.data(self.Columns.PUBKEY, ROLE_NPUB)
return None
def closeEvent(self, event):
self.unregister_callbacks()
event.accept()
@qt_event_listener
def on_event_swap_offers_changed(self, recent_offers: Sequence['SwapOffer']):
self.update_servers_list(recent_offers)
def update_servers_list(self, servers: Sequence['SwapOffer']):
self.servers_list.clear()
from electrum.util import age
items = []
for x in servers:
labels = [""] * len(self.Columns)
labels[self.Columns.PUBKEY] = x.server_pubkey
labels[self.Columns.FEE] = f"{x.pairs.percentage}% + {x.pairs.mining_fee} sats"
labels[self.Columns.MAX_FORWARD] = self.window.format_amount(x.pairs.max_forward) + ' ' + self.window.base_unit()
labels[self.Columns.MAX_REVERSE] = self.window.format_amount(x.pairs.max_reverse) + ' ' + self.window.base_unit()
labels[self.Columns.LAST_SEEN] = age(x.timestamp)
item = QTreeWidgetItem(labels)
item.setData(self.Columns.PUBKEY, ROLE_NPUB, x.server_npub)
item.setIcon(self.Columns.PUBKEY, pubkey_to_q_icon(x.server_pubkey))
items.append(item)
self.servers_list.insertTopLevelItems(0, items)