From 40811abe45b02e98666fdf96dde102b18b328f8f Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 9 Jan 2026 16:07:09 +0100 Subject: [PATCH] qt: TxEditor: separate swap transport from tab Separates the swap transport initialization logic from the submarine payment tab logic so it can be used for the send change to lightning functionality too. Also makes the gui updates on transport establishment more thread safe by using pyqtSignals instead of calling gui methods from the asyncio thread. --- electrum/gui/qt/confirm_tx_dialog.py | 233 ++++++++++++++------------- 1 file changed, 118 insertions(+), 115 deletions(-) diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index 8e0ebb054..e793c8a4d 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -27,8 +27,9 @@ import asyncio from decimal import Decimal from functools import partial from typing import TYPE_CHECKING, Optional, Union +from concurrent.futures import Future -from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtCore import Qt, QTimer, pyqtSlot, pyqtSignal from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import (QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton, QComboBox, QTabWidget, QWidget, QStackedWidget) @@ -61,6 +62,8 @@ if TYPE_CHECKING: class TxEditor(WindowModalDialog, QtEventListener, Logger): + swap_availability_changed = pyqtSignal() + def __init__( self, *, title='', window: 'ElectrumWindow', @@ -98,7 +101,9 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): self.swap_manager = self.wallet.lnworker.swap_manager if self.wallet.has_lightning() else None self.swap_transport = None # type: Optional[Union[NostrTransport, HttpTransport]] - self.ongoing_swap_transport_connection_attempt = None # type: Optional[asyncio.Task] + self.swap_availability_changed.connect(self.on_swap_availability_changed, Qt.ConnectionType.QueuedConnection) + self.swapserver_button = SwapProvidersButton(lambda: self.swap_transport, self.config, self.main_window) + self.ongoing_swap_transport_connection_attempt = None # type: Optional[Future] self.did_swap = False # used to clear the PI on send tab self.locktime_e = LockTimeEdit(self) @@ -730,6 +735,105 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): def can_pay_assuming_zero_fees(self, confirmed_only: bool) -> bool: raise NotImplementedError + ### --- Shared functionality for submarine swaps (change to ln and submarine payments) --- + def prepare_swap_transport(self): + if not self.swap_manager: + return # no swaps possible, lightning disabled + if self.swap_transport is not None and self.swap_transport.is_connected.is_set(): + # we already have a connected transport, no need to create a new one + return + if self.ongoing_swap_transport_connection_attempt: + # another task is currently trying to connect + return + + # there should only be a connected transport. + # a useless transport should get cleaned up and not stored. + assert self.swap_transport is None, "swap transport wasn't cleaned up properly" + + new_swap_transport = self.main_window.create_sm_transport() + if not new_swap_transport: + # user declined to enable Nostr and has no http server configured + self.swap_availability_changed.emit() + return + + async def _initialize_transport(transport): + try: + if isinstance(transport, NostrTransport): + asyncio.create_task(transport.main_loop()) + else: + assert isinstance(transport, HttpTransport) + asyncio.create_task(transport.get_pairs_just_once()) + if not await self.wait_for_swap_transport(transport): + return + self.swap_transport = transport + except Exception: + self.logger.exception("failed to create swap transport") + finally: + self.ongoing_swap_transport_connection_attempt = None + self.swap_availability_changed.emit() + + # this task will get cancelled if the TxEditor gets closed + self.ongoing_swap_transport_connection_attempt = asyncio.run_coroutine_threadsafe( + _initialize_transport(new_swap_transport), + get_asyncio_loop(), + ) + + async def wait_for_swap_transport(self, new_swap_transport: Union[HttpTransport, NostrTransport]) -> bool: + """ + Wait until we found the announcement event of the configured swap server. + If it is not found but the relay connection is established return True anyway, + the user will then need to select a different swap server. + """ + timeout = new_swap_transport.connect_timeout + 1 + try: + # swap_manager.is_initialized gets set once we got pairs of the configured swap server + await wait_for2(self.swap_manager.is_initialized.wait(), timeout) + except asyncio.TimeoutError: + self.logger.debug(f"swap transport initialization timed out after {timeout} sec") + + if self.swap_manager.is_initialized.is_set(): + return True + + # timed out above + if self.config.SWAPSERVER_URL: + # http swapserver didn't return pairs + self.logger.error(f"couldn't request pairs from {self.config.SWAPSERVER_URL=}") + return False + elif new_swap_transport.is_connected.is_set(): + assert isinstance(new_swap_transport, NostrTransport) + # couldn't find announcement of configured swapserver, maybe it is gone. + # update_submarine_payment_tab will tell the user to select a different swap server. + return True + + # we couldn't even connect to the relays, this transport is useless. maybe network issues. + return False + + @qt_event_listener + def on_event_swap_provider_changed(self): + self.swap_availability_changed.emit() + + @qt_event_listener + def on_event_channel(self, wallet, _channel): + # useful e.g. if the user quickly opens the tab after startup before the channels are initialized + if wallet == self.wallet and self.swap_manager and self.swap_manager.is_initialized.is_set(): + self.swap_availability_changed.emit() + + @qt_event_listener + def on_event_swap_offers_changed(self, _): + self.swapserver_button.update() + if self.ongoing_swap_transport_connection_attempt \ + and not self.ongoing_swap_transport_connection_attempt.done(): + return + self.swap_availability_changed.emit() + + @pyqtSlot() + def on_swap_availability_changed(self): + # uses a signal/slot to update the gui so we can schedule an update from the asyncio thread + if self.tab_widget.currentWidget() == self.submarine_payment_tab: + self.update_submarine_payment_tab() + else: + pass + ### --- Functionality for reverse submarine swaps to external address --- def create_submarine_payment_tab(self) -> QWidget: """Returns widget for submarine payment functionality to be added as tab""" @@ -781,16 +885,14 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): vbox.addWidget(self.submarine_stacked_widget) vbox.addStretch(1) - self.server_button = SwapProvidersButton(lambda: self.swap_transport, self.config, self.main_window) - self.submarine_ok_button = QPushButton(_('OK')) self.submarine_ok_button.setDefault(True) self.submarine_ok_button.setEnabled(False) # pay button must not self.accept() as this triggers closing the transport - self.submarine_ok_button.clicked.connect(self.start_submarine_swap) + self.submarine_ok_button.clicked.connect(self.start_submarine_payment) buttons = Buttons(CancelButton(self), self.submarine_ok_button) - buttons.insertWidget(0, self.server_button) + buttons.insertWidget(0, self.swapserver_button) vbox.addLayout(buttons) return tab_widget @@ -800,97 +902,7 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): self.submarine_warning_label.setText(_("Connecting, please wait...")) self.submarine_ok_button.setEnabled(False) - def prepare_swap_transport(self): - if self.swap_transport is not None and self.swap_transport.is_connected.is_set(): - # we already have a connected transport, no need to create a new one - return - if self.ongoing_swap_transport_connection_attempt \ - and not self.ongoing_swap_transport_connection_attempt.done(): - # another task is currently trying to connect - return - - # there should only be a connected transport. - # a useless transport should get cleaned up and not stored. - assert self.swap_transport is None, "swap transport wasn't cleaned up properly" - - # give user feedback that we are connection now - self.show_swap_transport_connection_message() - new_swap_transport = self.main_window.create_sm_transport() - if not new_swap_transport: - # user declined to enable Nostr and has no http server configured - self.update_submarine_tab() - return - - async def connect_and_update_tab(transport): - try: - await self.initialize_swap_transport(transport) - self.update_submarine_tab() - except Exception: - self.logger.exception("failed to create swap transport") - - task = asyncio.run_coroutine_threadsafe( - connect_and_update_tab(new_swap_transport), - get_asyncio_loop(), - ) - # this task will get cancelled if the TxEditor gets closed - self.ongoing_swap_transport_connection_attempt = task - - async def initialize_swap_transport(self, new_swap_transport): - # start the transport - if isinstance(new_swap_transport, NostrTransport): - asyncio.create_task(new_swap_transport.main_loop()) - else: - assert isinstance(new_swap_transport, HttpTransport) - asyncio.create_task(new_swap_transport.get_pairs_just_once()) - # wait for the transport to be connected - if not await self.wait_for_swap_transport(new_swap_transport): - return - self.swap_transport = new_swap_transport - - async def wait_for_swap_transport(self, new_swap_transport: Union[HttpTransport, NostrTransport]) -> bool: - """ - Wait until we found the announcement event of the configured swap server. - If it is not found but the relay connection is established return True anyway, - the user will then need to select a different swap server. - """ - timeout = new_swap_transport.connect_timeout + 1 - try: - # swap_manager.is_initialized gets set once we got pairs of the configured swap server - await wait_for2(self.swap_manager.is_initialized.wait(), timeout) - except asyncio.TimeoutError: - self.logger.debug(f"swap transport initialization timed out after {timeout} sec") - except Exception: - self.logger.exception("failed to initialize swap transport") - return False - - if self.swap_manager.is_initialized.is_set(): - return True - - # timed out above - if self.config.SWAPSERVER_URL: - # http swapserver didn't return pairs - self.logger.error(f"couldn't request pairs from {self.config.SWAPSERVER_URL=}") - return False - elif new_swap_transport.is_connected.is_set(): - assert isinstance(new_swap_transport, NostrTransport) - # couldn't find announcement of configured swapserver, maybe it is gone. - # update_submarine_tab will tell the user to select a different swap server. - return True - - # we couldn't even connect to the relays, this transport is useless. maybe network issues. - return False - - @qt_event_listener - def on_event_swap_provider_changed(self): - self.update_submarine_tab() - - @qt_event_listener - def on_event_channel(self, wallet, _channel): - # useful e.g. if the user quickly opens the tab after startup before the channels are initialized - if wallet == self.wallet and self.swap_manager and self.swap_manager.is_initialized.is_set(): - self.update_submarine_tab() - - def start_submarine_swap(self): + def start_submarine_payment(self): assert self.payee_outputs and len(self.payee_outputs) == 1 payee_output = self.payee_outputs[0] @@ -920,23 +932,24 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): self.accept() self.main_window.on_swap_result(funding_txid, is_reverse=True) - def update_submarine_tab(self): + def update_submarine_payment_tab(self): + assert self.tab_widget.currentWidget() == self.submarine_payment_tab assert self.payee_outputs, "Opened submarine payment tab without outputs?" assert len(self.payee_outputs) == \ len([o for o in self.payee_outputs if not o.is_change and not isinstance(o.value, str)]) f = self.main_window.format_amount_and_units - self.logger.debug(f"TxEditor updating submarine tab") + self.logger.debug(f"TxEditor updating submarine payment tab") if not self.swap_manager: - self.set_swap_tab_warning(_("Enable Lightning in the 'Channels' tab to use Submarine Swaps.")) + self.set_submarine_payment_tab_warning(_("Enable Lightning in the 'Channels' tab to use Submarine Swaps.")) return if not self.swap_transport: # couldn't connect to nostr relays or http server didn't respond - self.set_swap_tab_warning(_("Submarine swap provider unavailable.")) + self.set_submarine_payment_tab_warning(_("Submarine swap provider unavailable.")) return # Update the swapserver selection button text - self.server_button.update() + self.swapserver_button.update() if not self.swap_manager.is_initialized.is_set(): # connected to nostr relays but couldn't find swapserver announcement @@ -946,7 +959,7 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): msg = _("Couldn't connect to your swap provider. Please select a different provider.") else: msg = _('Please select a submarine swap provider.') - self.set_swap_tab_warning(msg) + self.set_submarine_payment_tab_warning(msg) return # update values @@ -962,7 +975,7 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): # get warning warning_text = self.get_swap_warning() if warning_text: - self.set_swap_tab_warning(warning_text) + self.set_submarine_payment_tab_warning(warning_text) return # There is no warning, show the normal view (amounts etc.) @@ -1030,22 +1043,12 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): return None - def set_swap_tab_warning(self, warning: str): + def set_submarine_payment_tab_warning(self, warning: str): msg = _('Submarine Payment not possible:') + '\n' + warning self.submarine_warning_label.setText(msg) self.submarine_stacked_widget.setCurrentIndex(1) self.submarine_ok_button.setEnabled(False) - @qt_event_listener - def on_event_swap_offers_changed(self, _): - self.server_button.update() - if self.ongoing_swap_transport_connection_attempt \ - and not self.ongoing_swap_transport_connection_attempt.done(): - return - if not self.submarine_payment_tab.isVisible(): - return - self.update_submarine_tab() - class ConfirmTxDialog(TxEditor): help_text = '' #_('Set the mining fee of your transaction')