common_qt: move submarine swap support code from qt gui to common_qt as SubmarineSwapMixin
What changed, and why it matters
This commit is a straightforward internal code refactor. It moves the user-interface code that handles 'submarine swaps' (a way to exchange on-chain Bitcoin for Lightning funds, or vice versa) out of the Qt desktop dialog and into a shared module so it can later be reused by the QML mobile-style interface. It also adds explicit initialize/destroy lifecycle methods to the swap server connection class. There is no indication this change fixes a security bug or introduces a new vulnerability.
No security action required. Treat as normal code-quality review.
Security signals we found
No security-relevant keywords in commit title or message
No changes to cryptographic primitives, wallet signing, or network protocol parsing
Refactor only: code moved between files with equivalent control flow
No new dependencies or external interfaces introduced
No vendor disclosure or advisory references present
Evidence from the diff
The patch refactors Electrum’s submarine-swap GUI support. It introduces electrum/gui/common_qt/swaps.py containing SubmarineSwapMixin, which encapsulates transport preparation, cleanup, and event listeners previously inline in TxEditor. TxEditor now inherits from the mixin. SwapServerTransport gains initialize(done_callback) and destroy() methods, moving the async setup/cleanup logic from the GUI into the transport class. The behavior is functionally equivalent: transport creation, connection attempt tracking, cancellation on dialog close, and Nostr stop handling are preserved.
Changed components
electrum/gui/common_qt/swaps.py (new shared mixin)electrum/gui/qt/confirm_tx_dialog.py (TxEditor refactor)electrum/submarine_swaps.py (SwapServerTransport lifecycle methods)Inspect captured patch +154 / −83
diff --git a/electrum/gui/common_qt/swaps.py b/electrum/gui/common_qt/swaps.py
new file mode 100644
index 0000000..a45a854
--- /dev/null
+++ b/electrum/gui/common_qt/swaps.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2026 The Electrum Developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from concurrent.futures import Future
+from typing import Optional, Callable, TYPE_CHECKING
+
+from PyQt6.QtCore import pyqtSignal, pyqtProperty
+
+from electrum import get_logger
+from electrum.gui.common_qt.util import qt_event_listener, QtEventListener
+from electrum.submarine_swaps import SwapServerTransport
+
+if TYPE_CHECKING:
+ from electrum.wallet import Abstract_Wallet
+
+
+class SubmarineSwapMixin(QtEventListener):
+
+ _swaps_logger = get_logger(__name__)
+ swapAvailabilityChanged = pyqtSignal()
+ swapOffersChanged = pyqtSignal()
+
+ def __init__(self, create_sm_transport: Callable = None):
+ self.swap_wallet = None
+ self.config = None
+ self.create_sm_transport = create_sm_transport
+ self.swap_manager = None
+ self.swap_transport = None # type: Optional[SwapServerTransport]
+
+ def set_wallet_for_swap(self, wallet: 'Abstract_Wallet'):
+ self.swap_wallet = wallet
+ self.config = wallet.config
+ self.swap_manager = wallet.lnworker.swap_manager if wallet.has_lightning() else None
+
+ # --- 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:
+ if self.swap_transport.is_connected.is_set():
+ # we already have a connected transport, no need to create a new one
+ return
+ if self.swap_transport.ongoing_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"
+
+ self.swap_transport = self.create_sm_transport() if self.create_sm_transport \
+ else self.swap_manager.create_transport()
+
+ if not self.swap_transport:
+ # could not create transport, e.g. user declined to enable Nostr and has no http server configured
+ self._swaps_logger.debug('could not create swap transport')
+ self.swapAvailabilityChanged.emit()
+ return
+
+ def transport_initialize_done(future: Future):
+ if future.cancelled() or future.exception() is not None:
+ self.swap_transport = None
+ self.swapAvailabilityChanged.emit()
+
+ self.swap_transport.initialize(transport_initialize_done)
+
+ def swap_transport_cleanup(self):
+ self.unregister_callbacks()
+ if self.swap_transport is not None:
+ self.swap_transport.destroy()
+ self.swap_transport = None
+
+ @qt_event_listener
+ def on_event_swap_provider_changed(self):
+ self.swapAvailabilityChanged.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.swap_wallet and self.swap_manager and self.swap_manager.is_initialized.is_set():
+ self.swapAvailabilityChanged.emit()
+
+ @qt_event_listener
+ def on_event_swap_offers_changed(self, _):
+ if self.swap_transport and self.swap_transport.ongoing_connection_attempt:
+ return
+ self.swapOffersChanged.emit()
diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py
index a53f36c..dec7077 100644
--- a/electrum/gui/qt/confirm_tx_dialog.py
+++ b/electrum/gui/qt/confirm_tx_dialog.py
@@ -23,31 +23,30 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
-import asyncio
from decimal import Decimal
from functools import partial
from typing import TYPE_CHECKING, Optional, Union, Sequence
-from concurrent.futures import Future
from enum import Enum, auto
-from PyQt6.QtCore import Qt, QTimer, pyqtSlot, pyqtSignal
+from PyQt6.QtCore import Qt, QTimer, pyqtSlot
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import (QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton,
QComboBox, QTabWidget, QWidget, QStackedWidget)
from electrum.i18n import _
from electrum.util import (UserCancelled, quantize_feerate, profiler, NotEnoughFunds, NoDynamicFeeEstimates,
- get_asyncio_loop, wait_for2, UserFacingException)
+ UserFacingException)
from electrum.plugin import run_hook
from electrum.transaction import PartialTransaction, PartialTxOutput, Transaction
from electrum.wallet import InternalAddressCorruption
from electrum.bitcoin import DummyAddress
from electrum.fee_policy import FeePolicy, FixedFeePolicy, FeeMethod
from electrum.logging import Logger
-from electrum.submarine_swaps import NostrTransport, HttpTransport, SwapServerTransport, SwapServerError
+from electrum.submarine_swaps import NostrTransport, SwapServerError
from electrum.gui.messages import MSG_SUBMARINE_PAYMENT_HELP_TEXT
-from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+from electrum.gui.common_qt.util import qt_event_listener
+from electrum.gui.common_qt.swaps import SubmarineSwapMixin
from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, WWLabel,
read_QIcon, IconLabel, HelpButton, RunCoroutineDialog)
@@ -71,9 +70,7 @@ class TxEditorContext(Enum):
CHANNEL_FUNDING = auto()
-class TxEditor(WindowModalDialog, QtEventListener, Logger):
-
- swap_availability_changed = pyqtSignal()
+class TxEditor(WindowModalDialog, SubmarineSwapMixin, Logger):
def __init__(
self, *, title='',
@@ -87,6 +84,7 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger):
WindowModalDialog.__init__(self, window, title=title)
Logger.__init__(self)
+ SubmarineSwapMixin.__init__(self, window.create_sm_transport)
self.main_window = window
self.make_tx = make_tx
self.output_value = output_value
@@ -109,9 +107,9 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger):
self._base_tx = None # type: Optional[Transaction] # for batching
self.batching_candidates = batching_candidates
- self.swap_manager = self.wallet.lnworker.swap_manager if self.wallet.has_lightning() else None
- self.swap_transport = None # type: Optional[SwapServerTransport]
- self.swap_availability_changed.connect(self.on_swap_availability_changed, Qt.ConnectionType.QueuedConnection)
+ self.swapAvailabilityChanged.connect(self.on_swap_availability_changed, Qt.ConnectionType.QueuedConnection)
+ self.swapOffersChanged.connect(self.on_swap_offers_changed, Qt.ConnectionType.QueuedConnection)
+ self.set_wallet_for_swap(window.wallet)
self.did_swap = False # used to clear the PI on send tab
self.locktime_e = LockTimeEdit(self)
@@ -168,25 +166,17 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger):
# debug_widget_layouts(self) # enable to show red lines around all elements
def accept(self):
- self._cleanup()
+ self.swap_transport_cleanup()
super().accept()
def reject(self):
- self._cleanup()
+ self.swap_transport_cleanup()
super().reject()
def closeEvent(self, event):
- self._cleanup()
+ self.swap_transport_cleanup()
super().closeEvent(event)
- def _cleanup(self):
- self.unregister_callbacks()
- if self.swap_transport and self.swap_transport.ongoing_connection_attempt:
- self.swap_transport.ongoing_connection_attempt.cancel()
- if isinstance(self.swap_transport, NostrTransport):
- asyncio.run_coroutine_threadsafe(self.swap_transport.stop(), get_asyncio_loop())
- self.swap_transport = None # HTTPTransport doesn't need to be closed
-
def on_tab_changed(self, index):
if self.tab_widget.widget(index) == self.submarine_payment_tab:
self.prepare_swap_transport()
@@ -742,67 +732,10 @@ 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:
- if self.swap_transport.is_connected.is_set():
- # we already have a connected transport, no need to create a new one
- return
- if self.swap_transport.ongoing_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.swap_manager.wait_for_swap_transport(transport):
- return
- self.swap_transport = transport
- except Exception:
- self.logger.exception("failed to create swap transport")
- finally:
- self.swap_transport.ongoing_connection_attempt = None
- self.swap_availability_changed.emit()
-
- # this task will get cancelled if the TxEditor gets closed
- self.swap_transport.ongoing_connection_attempt = asyncio.run_coroutine_threadsafe(
- _initialize_transport(new_swap_transport),
- get_asyncio_loop(),
- )
-
- @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, _):
+ @pyqtSlot()
+ def on_swap_offers_changed(self):
self.change_to_ln_swap_providers_button.update()
self.submarine_payment_provider_button.update()
- if self.swap_transport and self.swap_transport.ongoing_connection_attempt:
- return
- self.swap_availability_changed.emit()
@pyqtSlot()
def on_swap_availability_changed(self):
diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py
index 9607f8a..6acd5a5 100644
--- a/electrum/submarine_swaps.py
+++ b/electrum/submarine_swaps.py
@@ -4,7 +4,7 @@ import os
import ssl
import threading
from concurrent.futures import Future
-from typing import TYPE_CHECKING, Optional, Dict, Sequence, Tuple, Iterable, List
+from typing import TYPE_CHECKING, Optional, Dict, Sequence, Tuple, Iterable, List, Callable
from decimal import Decimal
import math
import time
@@ -1658,6 +1658,31 @@ class SwapServerTransport(Logger):
def uses_proxy(self):
return self.network.proxy and self.network.proxy.enabled
+ def initialize(self, done_callback: Optional[Callable[[Future], None]] = None):
+ 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.sm.wait_for_swap_transport(transport):
+ raise Exception(_('Swap transport failed.'))
+ return True
+ finally:
+ self.ongoing_connection_attempt = None
+
+ self.ongoing_connection_attempt = asyncio.run_coroutine_threadsafe(
+ _initialize_transport(self),
+ self.network.asyncio_loop,
+ )
+ if done_callback:
+ self.ongoing_connection_attempt.add_done_callback(done_callback)
+
+ def destroy(self):
+ if self.ongoing_connection_attempt:
+ self.ongoing_connection_attempt.cancel()
+
class HttpTransport(SwapServerTransport):
@@ -1759,6 +1784,10 @@ class NostrTransport(SwapServerTransport):
async def __aexit__(self, exc_type, exc_val, exc_tb):
await wait_for2(self.stop(), timeout=5)
+ def destroy(self):
+ super().destroy()
+ asyncio.run_coroutine_threadsafe(self.stop(), self.network.asyncio_loop)
+
@log_exceptions
async def main_loop(self):
self.logger.info(f'starting nostr transport with pubkey: {self.nostr_pubkey}')
Why this scored 12/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.