Skip to content

Commit d8aced5

Browse files
committed
common_qt: move submarine swap support code from qt gui to common_qt as SubmarineSwapMixin
This covers the functionality for swap use during payments (so, change-to-ln and submarine-payments) present in gui/qt/confirm_tx_dialog, not the 'standalone' swap. This is in preparation for adding the same functionality to qml. - move swap support code from TxEditor to SubmarineSwapMixin - don't require wallet instance at construction, allow late setting of wallet swaps: add initialize/destroy methods to SwapServerTransport, to simplify consumer side, adds a done callback so the transport can be used once ready or failure can be handled.
1 parent ffec9df commit d8aced5

3 files changed

Lines changed: 150 additions & 81 deletions

File tree

electrum/gui/common_qt/swaps.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#!/usr/bin/env python
2+
#
3+
# Electrum - lightweight Bitcoin client
4+
# Copyright (C) 2026 The Electrum Developers
5+
#
6+
# Permission is hereby granted, free of charge, to any person
7+
# obtaining a copy of this software and associated documentation files
8+
# (the "Software"), to deal in the Software without restriction,
9+
# including without limitation the rights to use, copy, modify, merge,
10+
# publish, distribute, sublicense, and/or sell copies of the Software,
11+
# and to permit persons to whom the Software is furnished to do so,
12+
# subject to the following conditions:
13+
#
14+
# The above copyright notice and this permission notice shall be
15+
# included in all copies or substantial portions of the Software.
16+
#
17+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
21+
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
22+
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
23+
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24+
# SOFTWARE.
25+
26+
from concurrent.futures import Future
27+
from typing import Optional, Callable, TYPE_CHECKING
28+
29+
from PyQt6.QtCore import pyqtSignal, pyqtProperty
30+
31+
from electrum import get_logger
32+
from electrum.gui.common_qt.util import qt_event_listener, QtEventListener
33+
from electrum.submarine_swaps import SwapServerTransport
34+
35+
if TYPE_CHECKING:
36+
from electrum.wallet import Abstract_Wallet
37+
38+
39+
class SubmarineSwapMixin(QtEventListener):
40+
41+
_swaps_logger = get_logger(__name__)
42+
swapAvailabilityChanged = pyqtSignal()
43+
44+
def __init__(self, create_sm_transport: Callable = None):
45+
self.swap_wallet = None
46+
self.config = None
47+
self.create_sm_transport = create_sm_transport
48+
self.swap_manager = None
49+
self.swap_transport = None # type: Optional[SwapServerTransport]
50+
51+
def set_wallet_for_swap(self, wallet: 'Abstract_Wallet'):
52+
self.swap_wallet = wallet
53+
self.config = wallet.config
54+
self.swap_manager = wallet.lnworker.swap_manager if wallet.has_lightning() else None
55+
56+
# --- Shared functionality for submarine swaps (change to ln and submarine payments) ---
57+
def prepare_swap_transport(self):
58+
if not self.swap_manager:
59+
return # no swaps possible, lightning disabled
60+
if self.swap_transport is not None:
61+
if self.swap_transport.is_connected.is_set():
62+
# we already have a connected transport, no need to create a new one
63+
return
64+
if self.swap_transport.ongoing_connection_attempt:
65+
# another task is currently trying to connect
66+
return
67+
68+
# there should only be a connected transport.
69+
# a useless transport should get cleaned up and not stored.
70+
assert self.swap_transport is None, "swap transport wasn't cleaned up properly"
71+
72+
self.swap_transport = self.create_sm_transport() if self.create_sm_transport \
73+
else self.swap_manager.create_transport()
74+
75+
if not self.swap_transport:
76+
# could not create transport, e.g. user declined to enable Nostr and has no http server configured
77+
self._swaps_logger.debug('could not create swap transport')
78+
self.swapAvailabilityChanged.emit()
79+
return
80+
81+
def transport_initialize_done(future: Future):
82+
if future.cancelled() or future.exception() is not None:
83+
self.swap_transport = None
84+
self.swapAvailabilityChanged.emit()
85+
86+
self.swap_transport.initialize(transport_initialize_done)
87+
88+
def swap_transport_cleanup(self):
89+
self.unregister_callbacks()
90+
if self.swap_transport is not None:
91+
self.swap_transport.destroy()
92+
self.swap_transport = None
93+
94+
@qt_event_listener
95+
def on_event_swap_provider_changed(self):
96+
self.swapAvailabilityChanged.emit()
97+
98+
@qt_event_listener
99+
def on_event_channel(self, wallet, _channel):
100+
# useful e.g. if the user quickly opens the tab after startup before the channels are initialized
101+
if wallet == self.swap_wallet and self.swap_manager and self.swap_manager.is_initialized.is_set():
102+
self.swapAvailabilityChanged.emit()
103+
104+
@qt_event_listener
105+
def on_event_swap_offers_changed(self, _):
106+
if self.swap_transport.ongoing_connection_attempt:
107+
return
108+
self.swapAvailabilityChanged.emit()

electrum/gui/qt/confirm_tx_dialog.py

Lines changed: 12 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -23,31 +23,30 @@
2323
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2424
# SOFTWARE.
2525

26-
import asyncio
2726
from decimal import Decimal
2827
from functools import partial
2928
from typing import TYPE_CHECKING, Optional, Union, Sequence
30-
from concurrent.futures import Future
3129
from enum import Enum, auto
3230

33-
from PyQt6.QtCore import Qt, QTimer, pyqtSlot, pyqtSignal
31+
from PyQt6.QtCore import Qt, QTimer, pyqtSlot
3432
from PyQt6.QtGui import QIcon
3533
from PyQt6.QtWidgets import (QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton,
3634
QComboBox, QTabWidget, QWidget, QStackedWidget)
3735

3836
from electrum.i18n import _
3937
from electrum.util import (UserCancelled, quantize_feerate, profiler, NotEnoughFunds, NoDynamicFeeEstimates,
40-
get_asyncio_loop, wait_for2, UserFacingException)
38+
UserFacingException)
4139
from electrum.plugin import run_hook
4240
from electrum.transaction import PartialTransaction, PartialTxOutput, Transaction
4341
from electrum.wallet import InternalAddressCorruption
4442
from electrum.bitcoin import DummyAddress
4543
from electrum.fee_policy import FeePolicy, FixedFeePolicy, FeeMethod
4644
from electrum.logging import Logger
47-
from electrum.submarine_swaps import NostrTransport, HttpTransport, SwapServerTransport, SwapServerError
45+
from electrum.submarine_swaps import NostrTransport, SwapServerError
4846
from electrum.gui.messages import MSG_SUBMARINE_PAYMENT_HELP_TEXT
4947

50-
from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
48+
from electrum.gui.common_qt.util import qt_event_listener
49+
from electrum.gui.common_qt.swaps import SubmarineSwapMixin
5150

5251
from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, WWLabel,
5352
read_QIcon, IconLabel, HelpButton, RunCoroutineDialog)
@@ -71,9 +70,7 @@ class TxEditorContext(Enum):
7170
CHANNEL_FUNDING = auto()
7271

7372

74-
class TxEditor(WindowModalDialog, QtEventListener, Logger):
75-
76-
swap_availability_changed = pyqtSignal()
73+
class TxEditor(WindowModalDialog, SubmarineSwapMixin, Logger):
7774

7875
def __init__(
7976
self, *, title='',
@@ -87,6 +84,7 @@ def __init__(
8784

8885
WindowModalDialog.__init__(self, window, title=title)
8986
Logger.__init__(self)
87+
SubmarineSwapMixin.__init__(self, window.create_sm_transport)
9088
self.main_window = window
9189
self.make_tx = make_tx
9290
self.output_value = output_value
@@ -109,9 +107,8 @@ def __init__(
109107
self._base_tx = None # type: Optional[Transaction] # for batching
110108
self.batching_candidates = batching_candidates
111109

112-
self.swap_manager = self.wallet.lnworker.swap_manager if self.wallet.has_lightning() else None
113-
self.swap_transport = None # type: Optional[SwapServerTransport]
114-
self.swap_availability_changed.connect(self.on_swap_availability_changed, Qt.ConnectionType.QueuedConnection)
110+
self.swapAvailabilityChanged.connect(self.on_swap_availability_changed, Qt.ConnectionType.QueuedConnection)
111+
self.set_wallet_for_swap(window.wallet)
115112
self.did_swap = False # used to clear the PI on send tab
116113

117114
self.locktime_e = LockTimeEdit(self)
@@ -168,25 +165,17 @@ def __init__(
168165
# debug_widget_layouts(self) # enable to show red lines around all elements
169166

170167
def accept(self):
171-
self._cleanup()
168+
self.swap_transport_cleanup()
172169
super().accept()
173170

174171
def reject(self):
175-
self._cleanup()
172+
self.swap_transport_cleanup()
176173
super().reject()
177174

178175
def closeEvent(self, event):
179-
self._cleanup()
176+
self.swap_transport_cleanup()
180177
super().closeEvent(event)
181178

182-
def _cleanup(self):
183-
self.unregister_callbacks()
184-
if self.swap_transport and self.swap_transport.ongoing_connection_attempt:
185-
self.swap_transport.ongoing_connection_attempt.cancel()
186-
if isinstance(self.swap_transport, NostrTransport):
187-
asyncio.run_coroutine_threadsafe(self.swap_transport.stop(), get_asyncio_loop())
188-
self.swap_transport = None # HTTPTransport doesn't need to be closed
189-
190179
def on_tab_changed(self, index):
191180
if self.tab_widget.widget(index) == self.submarine_payment_tab:
192181
self.prepare_swap_transport()
@@ -742,67 +731,10 @@ def _update_send_button(self):
742731
def can_pay_assuming_zero_fees(self, confirmed_only: bool) -> bool:
743732
raise NotImplementedError
744733

745-
### --- Shared functionality for submarine swaps (change to ln and submarine payments) ---
746-
def prepare_swap_transport(self):
747-
if not self.swap_manager:
748-
return # no swaps possible, lightning disabled
749-
if self.swap_transport is not None:
750-
if self.swap_transport.is_connected.is_set():
751-
# we already have a connected transport, no need to create a new one
752-
return
753-
if self.swap_transport.ongoing_connection_attempt:
754-
# another task is currently trying to connect
755-
return
756-
757-
# there should only be a connected transport.
758-
# a useless transport should get cleaned up and not stored.
759-
assert self.swap_transport is None, "swap transport wasn't cleaned up properly"
760-
761-
new_swap_transport = self.main_window.create_sm_transport()
762-
if not new_swap_transport:
763-
# user declined to enable Nostr and has no http server configured
764-
self.swap_availability_changed.emit()
765-
return
766-
767-
async def _initialize_transport(transport):
768-
try:
769-
if isinstance(transport, NostrTransport):
770-
asyncio.create_task(transport.main_loop())
771-
else:
772-
assert isinstance(transport, HttpTransport)
773-
asyncio.create_task(transport.get_pairs_just_once())
774-
if not await self.swap_manager.wait_for_swap_transport(transport):
775-
return
776-
self.swap_transport = transport
777-
except Exception:
778-
self.logger.exception("failed to create swap transport")
779-
finally:
780-
self.swap_transport.ongoing_connection_attempt = None
781-
self.swap_availability_changed.emit()
782-
783-
# this task will get cancelled if the TxEditor gets closed
784-
self.swap_transport.ongoing_connection_attempt = asyncio.run_coroutine_threadsafe(
785-
_initialize_transport(new_swap_transport),
786-
get_asyncio_loop(),
787-
)
788-
789-
@qt_event_listener
790-
def on_event_swap_provider_changed(self):
791-
self.swap_availability_changed.emit()
792-
793-
@qt_event_listener
794-
def on_event_channel(self, wallet, _channel):
795-
# useful e.g. if the user quickly opens the tab after startup before the channels are initialized
796-
if wallet == self.wallet and self.swap_manager and self.swap_manager.is_initialized.is_set():
797-
self.swap_availability_changed.emit()
798-
799734
@qt_event_listener
800735
def on_event_swap_offers_changed(self, _):
801736
self.change_to_ln_swap_providers_button.update()
802737
self.submarine_payment_provider_button.update()
803-
if self.swap_transport and self.swap_transport.ongoing_connection_attempt:
804-
return
805-
self.swap_availability_changed.emit()
806738

807739
@pyqtSlot()
808740
def on_swap_availability_changed(self):

electrum/submarine_swaps.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import ssl
55
import threading
66
from concurrent.futures import Future
7-
from typing import TYPE_CHECKING, Optional, Dict, Sequence, Tuple, Iterable, List
7+
from typing import TYPE_CHECKING, Optional, Dict, Sequence, Tuple, Iterable, List, Callable
88
from decimal import Decimal
99
import math
1010
import time
@@ -1658,6 +1658,31 @@ async def send_request_to_server(self, method: str, request_data: Optional[dict]
16581658
def uses_proxy(self):
16591659
return self.network.proxy and self.network.proxy.enabled
16601660

1661+
def initialize(self, done_callback: Optional[Callable[[Future], None]] = None):
1662+
async def _initialize_transport(transport):
1663+
try:
1664+
if isinstance(transport, NostrTransport):
1665+
asyncio.create_task(transport.main_loop())
1666+
else:
1667+
assert isinstance(transport, HttpTransport)
1668+
asyncio.create_task(transport.get_pairs_just_once())
1669+
if not await self.sm.wait_for_swap_transport(transport):
1670+
raise Exception(_('Swap transport failed.'))
1671+
return True
1672+
finally:
1673+
self.ongoing_connection_attempt = None
1674+
1675+
self.ongoing_connection_attempt = asyncio.run_coroutine_threadsafe(
1676+
_initialize_transport(self),
1677+
self.network.asyncio_loop,
1678+
)
1679+
if done_callback:
1680+
self.ongoing_connection_attempt.add_done_callback(done_callback)
1681+
1682+
def destroy(self):
1683+
if self.ongoing_connection_attempt:
1684+
self.ongoing_connection_attempt.cancel()
1685+
16611686

16621687
class HttpTransport(SwapServerTransport):
16631688

@@ -1759,6 +1784,10 @@ async def __aenter__(self):
17591784
async def __aexit__(self, exc_type, exc_val, exc_tb):
17601785
await wait_for2(self.stop(), timeout=5)
17611786

1787+
def destroy(self):
1788+
super().destroy()
1789+
asyncio.run_coroutine_threadsafe(self.stop(), self.network.asyncio_loop)
1790+
17621791
@log_exceptions
17631792
async def main_loop(self):
17641793
self.logger.info(f'starting nostr transport with pubkey: {self.nostr_pubkey}')

0 commit comments

Comments
 (0)