qt: expose swaps to address as Submarine Payments
What changed, and why it matters
This commit adds a new optional feature in Electrum's desktop (Qt) GUI called 'Submarine Payments'. It lets users pay a regular on-chain Bitcoin address directly from their Lightning balance by doing a reverse submarine swap through a third-party swap provider. The change is a feature addition, not a fix for a known vulnerability. It exposes existing swap logic in the transaction editor and adds UI controls, configuration, and cleanup handling. There is no direct evidence in the commit or supplied references that this resolves a security bug or introduces a new attack.
Treat this as a normal feature review. Verify that the new async cleanup paths cannot be bypassed, that the submarine swap amount/fee calculations cannot be manipulated by a malicious swap provider, and that enabling the feature does not weaken existing transaction preview/broadcast checks. No emergency action is indicated by the diff alone.
Security signals we found
New opt-in feature integrating third-party swap providers (Nostr/HTTP) into the send workflow
Adds cleanup of async swap transport on dialog close/accept/reject to avoid leaked connections
NostrTransport.stop now clears is_connected, which may improve state consistency
Submarine swap claim transaction no longer claims to be uncorrelated to wallet UTXOs; comment updated
Feature is disabled by default and requires user to enable it in preferences
Evidence from the diff
The patch extends TxEditor/ConfirmTxDialog with a new ‘Submarine Payment’ tab, gated by a config flag WALLET_ENABLE_SUBMARINE_PAYMENTS (default false). It wires the existing reverse-swap flow (SwapManager.reverse_swap with claim_to_output) into the send-tab workflow so a Lightning payment can settle to an external on-chain output. It also adds transport lifecycle cleanup, a NostrTransport.stop improvement that clears is_connected, and a debug helper for Qt layouts. The code paths are opt-in and require Lightning to be enabled. No cryptographic, consensus, or privilege-escalation changes are visible.
Changed components
electrum/gui/qt/confirm_tx_dialog.pyelectrum/gui/qt/main_window.pyelectrum/gui/qt/send_tab.pyelectrum/gui/qt/swap_dialog.pyelectrum/gui/qt/util.pyelectrum/simple_config.pyelectrum/submarine_swaps.pyInspect captured patch +501 / −39
diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py
index d6047eb..2722417 100644
--- a/electrum/gui/qt/confirm_tx_dialog.py
+++ b/electrum/gui/qt/confirm_tx_dialog.py
@@ -23,50 +23,61 @@
# 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, Callable
+from typing import TYPE_CHECKING, Optional, Union
+from electrum_aionostr.util import from_nip19
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QIcon
-from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton, QMenu, QComboBox
+from PyQt6.QtWidgets import (QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton,
+ QComboBox, QTabWidget, QWidget, QStackedWidget)
from electrum.i18n import _
-from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates
-from electrum.util import quantize_feerate
+from electrum.util import (quantize_feerate, profiler, NotEnoughFunds, NoDynamicFeeEstimates,
+ get_asyncio_loop, wait_for2, nostr_pow_worker)
from electrum.plugin import run_hook
-from electrum.transaction import Transaction, PartialTransaction
+from electrum.transaction import PartialTransaction, TxOutput
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
+from .seed_dialog import seed_warning_msg
-from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton,
- WWLabel, read_QIcon)
+from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, WWLabel,
+ read_QIcon, debug_widget_layouts, qt_event_listener, QtEventListener, IconLabel)
from .transaction_dialog import TxSizeLabel, TxFiatLabel, TxInOutWidget
from .fee_slider import FeeSlider, FeeComboBox
from .amountedit import FeerateEdit, BTCAmountEdit
from .locktimeedit import LockTimeEdit
from .my_treeview import QMenuWithConfig
+from .swap_dialog import SwapServerDialog
if TYPE_CHECKING:
from .main_window import ElectrumWindow
-class TxEditor(WindowModalDialog):
+class TxEditor(WindowModalDialog, QtEventListener, Logger):
def __init__(
self, *, title='',
window: 'ElectrumWindow',
make_tx,
output_value: Union[int, str],
+ payee_outputs: Optional[list[TxOutput]] = None,
allow_preview=True,
batching_candidates=None,
):
WindowModalDialog.__init__(self, window, title=title)
+ Logger.__init__(self)
self.main_window = window
self.make_tx = make_tx
self.output_value = output_value
+ # used only for submarine payments as they construct tx independently of make_tx
+ self.payee_outputs = payee_outputs
self.tx = None # type: Optional[PartialTransaction]
self.messages = []
self.error = '' # set by side effect
@@ -85,40 +96,87 @@ class TxEditor(WindowModalDialog):
self._base_tx = None # 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[Union[NostrTransport, HttpTransport]]
+ self.ongoing_swap_transport_connection_attempt = None # type: Optional[asyncio.Task]
+ self.did_swap = False # used to clear the PI on send tab
+
self.locktime_e = LockTimeEdit(self)
self.locktime_e.valueEdited.connect(self.trigger_update)
self.locktime_label = QLabel(_("LockTime") + ": ")
self.io_widget = TxInOutWidget(self.main_window, self.wallet)
self.create_fee_controls()
- vbox = QVBoxLayout()
- self.setLayout(vbox)
-
- top = self.create_top_bar(self.help_text)
- grid = self.create_grid()
-
- vbox.addLayout(top)
- vbox.addLayout(grid)
- vbox.addWidget(self.io_widget)
+ onchain_vbox = QVBoxLayout()
+ onchain_top = self.create_top_bar(self.help_text)
+ onchain_grid = self.create_grid()
+ onchain_vbox.addLayout(onchain_top)
+ onchain_vbox.addLayout(onchain_grid)
+ onchain_vbox.addWidget(self.io_widget)
self.message_label = WWLabel('')
self.message_label.setMinimumHeight(70)
- vbox.addWidget(self.message_label)
+ onchain_vbox.addWidget(self.message_label)
- buttons = self.create_buttons_bar()
- vbox.addStretch(1)
- vbox.addLayout(buttons)
+ onchain_buttons = self.create_buttons_bar()
+ onchain_vbox.addStretch(1)
+ onchain_vbox.addLayout(onchain_buttons)
+
+ # onchain tab is the main tab and the content is also shown if tabs are disabled
+ self.onchain_tab = QWidget()
+ self.onchain_tab.setContentsMargins(0,0,0,0)
+ self.onchain_tab.setLayout(onchain_vbox)
+
+ # optional submarine payment tab, the tab is only shown if the option is enabled
+ self.submarine_payment_tab = self.create_submarine_payment_tab()
+
+ self.tab_widget = QTabWidget()
+ self.tab_widget.setTabBarAutoHide(True) # hides the tab bar if there is only one tab
+ self.tab_widget.setContentsMargins(0, 0, 0, 0)
+ self.tab_widget.tabBarClicked.connect(self.on_tab_clicked)
+
+ self.main_layout = QVBoxLayout()
+ self.main_layout.addWidget(self.tab_widget)
+ self.main_layout.setContentsMargins(6, 6, 6, 6) # reduce outermost margins a bit
+ self.setLayout(self.main_layout)
self.set_io_visible()
self.set_fee_edit_visible()
self.set_locktime_visible()
self.update_fee_target()
- self.resize(self.layout().sizeHint())
+ self.update_tab_visibility()
+ self.resize_to_fit_content()
self.timer = QTimer(self)
self.timer.setInterval(500)
self.timer.setSingleShot(False)
self.timer.timeout.connect(self.timer_actions)
self.timer.start()
+ self.register_callbacks()
+ # debug_widget_layouts(self) # enable to show red lines around all elements
+
+ def accept(self):
+ self._cleanup()
+ super().accept()
+
+ def reject(self):
+ self._cleanup()
+ super().reject()
+
+ def closeEvent(self, event):
+ self._cleanup()
+ super().closeEvent(event)
+
+ def _cleanup(self):
+ self.unregister_callbacks()
+ if self.ongoing_swap_transport_connection_attempt:
+ self.ongoing_swap_transport_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_clicked(self, index):
+ if self.tab_widget.widget(index) == self.submarine_payment_tab:
+ self.prepare_swap_transport()
def is_batching(self) -> bool:
return self._base_tx is not None
@@ -140,6 +198,13 @@ class TxEditor(WindowModalDialog):
# expected to set self.tx, self.message and self.error
raise NotImplementedError()
+ def create_grid(self) -> QGridLayout:
+ raise NotImplementedError()
+
+ @property
+ def help_text(self) -> str:
+ raise NotImplementedError()
+
def update_fee_target(self):
if self.fee_slider.is_active():
text = self.fee_policy.get_target_text()
@@ -230,6 +295,32 @@ class TxEditor(WindowModalDialog):
self.fee_slider.setFixedWidth(200)
self.fee_target.setFixedSize(self.feerate_e.sizeHint())
+ def update_tab_visibility(self):
+ """Update self.tab_widget to show all tabs that are enabled."""
+ # first remove all tabs
+ while self.tab_widget.count() > 0:
+ self.tab_widget.removeTab(0)
+
+ # always show onchain payment tab
+ self.tab_widget.addTab(self.onchain_tab, _('Onchain'))
+
+ allow_swaps = self.allow_preview and self.payee_outputs # allow_preview is false for ln channel opening txs
+ if self.config.WALLET_ENABLE_SUBMARINE_PAYMENTS and allow_swaps:
+ i = self.tab_widget.addTab(self.submarine_payment_tab, _('Submarine Payment'))
+ tooltip = self.config.cv.WALLET_ENABLE_SUBMARINE_PAYMENTS.get_long_desc()
+ if len(self.payee_outputs) > 1:
+ self.tab_widget.setTabEnabled(i, False)
+ tooltip = _("Submarine Payments don't support multiple outputs (Pay-to-many).")
+ elif self.payee_outputs[0].value == '!':
+ self.tab_widget.setTabEnabled(i, False)
+ self.submarine_payment_tab.setEnabled(False)
+ tooltip = _("Submarine Payments don't support 'Max' value spends.")
+ self.tab_widget.tabBar().setTabToolTip(i, tooltip)
+
+ # enable document mode if there is only one tab to hide the frame
+ self.tab_widget.setDocumentMode(self.tab_widget.count() < 2)
+ self.resize_to_fit_content()
+
def trigger_update(self):
# set tx to None so that the ok button is disabled while we compute the new tx
self.tx = None
@@ -311,7 +402,6 @@ class TxEditor(WindowModalDialog):
feerate_color = ColorScheme.BLUE
self.fee_e.setStyleSheet(fee_color.as_stylesheet())
self.feerate_e.setStyleSheet(feerate_color.as_stylesheet())
- #
self.needs_update = True
def update_fee_fields(self):
@@ -411,6 +501,7 @@ class TxEditor(WindowModalDialog):
self.pref_menu.addConfig(self.config.cv.GUI_QT_TX_EDITOR_SHOW_LOCKTIME, callback=cb)
self.pref_menu.addSeparator()
self.pref_menu.addConfig(self.config.cv.WALLET_SEND_CHANGE_TO_LIGHTNING, callback=self.trigger_update)
+ self.pref_menu.addConfig(self.config.cv.WALLET_ENABLE_SUBMARINE_PAYMENTS, callback=self.update_tab_visibility)
self.pref_menu.addToggle(
_('Use change addresses'),
self.toggle_use_change,
@@ -442,11 +533,12 @@ class TxEditor(WindowModalDialog):
hbox.addWidget(self.pref_button)
return hbox
+ @profiler(min_threshold=0.02)
def resize_to_fit_content(self):
- # fixme: calling resize once is not enough...
- size = self.layout().sizeHint()
- self.resize(size)
- self.resize(size)
+ # update all geometries so the updated size hints are used for size adjustment
+ for widget in self.findChildren(QWidget):
+ widget.updateGeometry()
+ self.adjustSize()
def toggle_use_change(self):
self.wallet.use_change = not self.wallet.use_change
@@ -593,17 +685,343 @@ class TxEditor(WindowModalDialog):
def can_pay_assuming_zero_fees(self, confirmed_only: bool) -> bool:
raise NotImplementedError
+ ### --- 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"""
+ tab_widget = QWidget()
+ vbox = QVBoxLayout(tab_widget)
+
+ # stack two views, a warning view and the regular one. The warning view is shown if
+ # the swap cannot be performed, e.g. due to missing liquidity.
+ self.submarine_stacked_widget = QStackedWidget()
+
+ # Normal layout page
+ normal_page = QWidget()
+ h = QGridLayout(normal_page)
+ self.submarine_lightning_send_amount_label = QLabel()
+ self.submarine_onchain_send_amount_label = QLabel()
+ self.submarine_claim_mining_fee_label = QLabel()
+ self.submarine_server_fee_label = QLabel()
+ self.submarine_we_send_label = IconLabel(text=_('You send')+':')
+ self.submarine_we_send_label.setIcon(read_QIcon('lightning.png'))
+ self.submarine_they_receive_label = IconLabel(text=_('They receive')+':')
+ self.submarine_they_receive_label.setIcon(read_QIcon('bitcoin.png'))
+ h.addWidget(self.submarine_we_send_label, 0, 0)
+ h.addWidget(self.submarine_lightning_send_amount_label, 0, 1)
+ h.addWidget(self.submarine_they_receive_label, 1, 0)
+ h.addWidget(self.submarine_onchain_send_amount_label, 1, 1)
+ h.addWidget(QLabel(_('Swap fee')+':'), 2, 0)
+ h.addWidget(self.submarine_server_fee_label, 2, 1, 1, 2)
+ h.addWidget(QLabel(_('Mining fee')+':'), 3, 0)
+ h.addWidget(self.submarine_claim_mining_fee_label, 3, 1, 1, 2)
+
+ # Warning layout page
+ warning_page = QWidget()
+ warning_layout = QVBoxLayout(warning_page)
+ self.submarine_warning_label = QLabel('')
+ warning_layout.addWidget(self.submarine_warning_label)
+
+ self.submarine_stacked_widget.addWidget(normal_page)
+ self.submarine_stacked_widget.addWidget(warning_page)
+
+ vbox.addWidget(self.submarine_stacked_widget)
+ vbox.addStretch(1)
+
+ self.server_button = QPushButton()
+ self.server_button.clicked.connect(self.choose_swap_server)
+ self.server_button.setEnabled(False)
+ self.server_button.setVisible(False)
+
+ 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)
+
+ buttons = Buttons(CancelButton(self), self.submarine_ok_button)
+ buttons.insertWidget(0, self.server_button)
+ vbox.addLayout(buttons)
+
+ return tab_widget
+
+ def show_swap_transport_connection_message(self):
+ self.submarine_stacked_widget.setCurrentIndex(1)
+ 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
+
+ def choose_swap_server(self) -> None:
+ assert isinstance(self.swap_transport, NostrTransport), self.swap_transport
+ self.main_window.choose_swapserver_dialog(self.swap_transport)
+ self.update_submarine_tab()
+
+ def start_submarine_swap(self):
+ assert self.payee_outputs and len(self.payee_outputs) == 1
+ payee_output = self.payee_outputs[0]
+
+ assert self.expected_onchain_amount_sat is not None
+ assert self.lightning_send_amount_sat is not None
+ assert self.last_server_mining_fee_sat is not None
+ assert self.swap_transport.is_connected.is_set()
+ assert self.swap_manager.is_initialized.is_set()
+
+ self.tx = None # prevent broadcasting
+ self.submarine_ok_button.setEnabled(False)
+ coro = self.swap_manager.reverse_swap(
+ transport=self.swap_transport,
+ lightning_amount_sat=self.lightning_send_amount_sat,
+ expected_onchain_amount_sat=self.expected_onchain_amount_sat,
+ prepayment_sat=2 * self.last_server_mining_fee_sat,
+ claim_to_output=payee_output,
+ )
+ try:
+ funding_txid = self.main_window.run_coroutine_dialog(coro, _('Initiating Submarine Payment...'))
+ except Exception as e:
+ self.close()
+ self.main_window.show_error(_("Submarine Payment failed:") + "\n" + str(e))
+ return
+ self.did_swap = True
+ # accepting closes the swap transport, so it needs to happen after the swap
+ self.accept()
+ self.main_window.on_swap_result(funding_txid, is_reverse=True)
+
+ def update_submarine_tab(self):
+ 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")
+
+ if not self.swap_manager:
+ self.set_swap_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."))
+ return
+
+ # Update the swapserver selection button text
+ if isinstance(self.swap_transport, NostrTransport):
+ self.server_button.setVisible(True)
+ self.server_button.setEnabled(True)
+ if self.config.SWAPSERVER_NPUB:
+ pubkey = from_nip19(self.config.SWAPSERVER_NPUB)['object'].hex()
+ self.server_button.setIcon(SwapServerDialog.pubkey_to_q_icon(pubkey))
+ self.server_button.setText(
+ f' {len(self.swap_transport.get_recent_offers())} ' +
+ (_('providers') if len(self.swap_transport.get_recent_offers()) != 1 else _('provider'))
+ )
+ else:
+ # HTTPTransport or no Network, not showing server selection button
+ self.server_button.setEnabled(False)
+ self.server_button.setVisible(False)
+
+ if not self.swap_manager.is_initialized.is_set():
+ # connected to nostr relays but couldn't find swapserver announcement
+ assert isinstance(self.swap_transport, NostrTransport), "HTTPTransport shouldn't get set if it cannot fetch pairs"
+ assert self.swap_transport.is_connected.is_set(), "closed transport wasn't cleaned up"
+ if self.config.SWAPSERVER_NPUB:
+ 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)
+ return
+
+ # update values
+ self.lightning_send_amount_sat = self.swap_manager.get_send_amount(
+ self.payee_outputs[0].value, # claim tx fee reserve gets added in get_send_amount
+ is_reverse=True,
+ )
+ self.last_server_mining_fee_sat = self.swap_manager.mining_fee
+ self.expected_onchain_amount_sat = (
+ self.payee_outputs[0].value + self.swap_manager.get_fee_for_txbatcher()
+ )
+
+ # get warning
+ warning_text = self.get_swap_warning()
+ if warning_text:
+ self.set_swap_tab_warning(warning_text)
+ return
+
+ # There is no warning, show the normal view (amounts etc.)
+ self.submarine_stacked_widget.setCurrentIndex(0)
+
+ # label showing the payment amount (the amount the user entered in SendTab)
+ self.submarine_onchain_send_amount_label.setText(f(self.payee_outputs[0].value))
+
+ # the fee we pay to claim the funding output to the onchain address, shown as "Mining Fee"
+ claim_tx_mining_fee = self.swap_manager.get_fee_for_txbatcher()
+ self.submarine_claim_mining_fee_label.setText(f(claim_tx_mining_fee))
+
+ assert self.lightning_send_amount_sat is not None
+ self.submarine_lightning_send_amount_label.setText(f(self.lightning_send_amount_sat))
+ # complete fee we pay to the server
+ server_fee = self.lightning_send_amount_sat - self.expected_onchain_amount_sat
+ self.submarine_server_fee_label.setText(f(server_fee))
+
+ self.submarine_ok_button.setEnabled(True)
+
+ def get_swap_warning(self) -> Optional[str]:
+ f = self.main_window.format_amount_and_units
+ ln_can_send = int(self.wallet.lnworker.num_sats_can_send())
+
+ if self.expected_onchain_amount_sat < self.swap_manager.get_min_amount():
+ return '\n'.join([
+ _("Payment amount below the minimum possible swap amount."),
+ _("You need to send a higher amount to be able to do a Submarine Payment."),
+ _("Minimum amount: {}").format(f(self.swap_manager.get_min_amount())),
+ ])
+
+ too_low_outbound_liquidity_msg = '\n'.join([
+ _("You don't have enough outgoing capacity in your lightning channels."),
+ _("To add outgoing capacity you can open a new lightning channel or do a submarine swap."),
+ _("Your lightning channels can send: {}").format(f(ln_can_send)),
+ ])
+
+ # prioritize showing the swap provider liquidity warning before the channel liquidity warning
+ # as it could be annoying for the user to be told to open a new channel just to come back to
+ # notice there is no provider supporting their swap amount
+ if self.lightning_send_amount_sat is None:
+ provider_liquidity = self.swap_manager.get_provider_max_forward_amount()
+ if provider_liquidity < self.swap_manager.get_min_amount():
+ provider_liquidity = 0
+ msg = [
+ _("The selected swap provider is unable to offer a forward swap of this value."),
+ _("In order to continue select a different provider or try to send a smaller amount."),
+ _("Available liquidity") + f": {f(provider_liquidity)}",
+ ]
+ # we don't know exactly how much we need to send on ln yet, so we can assume 0 provider fees
+ probably_too_low_outbound_liquidity = self.expected_onchain_amount_sat > ln_can_send
+ if probably_too_low_outbound_liquidity:
+ msg.extend([
+ "",
+ "Please also note:",
+ too_low_outbound_liquidity_msg,
+ ])
+ return "\n".join(msg)
+
+ # if we have lightning_send_amount_sat our provider has enough liquidity, so we know the exact
+ # amount we need to send including the providers fees
+ too_low_outbound_liquidity = self.lightning_send_amount_sat > ln_can_send
+ if too_low_outbound_liquidity:
+ return too_low_outbound_liquidity_msg
+
+ return None
+
+ def set_swap_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, _):
+ 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')
- def __init__(self, *, window: 'ElectrumWindow', make_tx, output_value: Union[int, str], allow_preview=True, batching_candidates=None):
+ def __init__(
+ self, *,
+ window: 'ElectrumWindow',
+ make_tx,
+ output_value: Union[int, str],
+ payee_outputs: Optional[list[TxOutput]] = None,
+ allow_preview=True,
+ batching_candidates=None,
+ ):
TxEditor.__init__(
self,
window=window,
make_tx=make_tx,
output_value=output_value,
+ payee_outputs=payee_outputs,
title=_("New Transaction"), # todo: adapt title for channel funding tx, swaps
allow_preview=allow_preview, # false for channel funding
batching_candidates=batching_candidates,
diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py
index add3601..ff860f3 100644
--- a/electrum/gui/qt/main_window.py
+++ b/electrum/gui/qt/main_window.py
@@ -62,7 +62,7 @@ from electrum.util import (format_time, UserCancelled, profiler, bfh, InvalidPas
from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME
from electrum.payment_identifier import PaymentIdentifier
from electrum.invoices import PR_PAID, Invoice
-from electrum.transaction import (Transaction, PartialTxInput,
+from electrum.transaction import (Transaction, PartialTxInput, TxOutput,
PartialTransaction, PartialTxOutput)
from electrum.wallet import (Multisig_Wallet, Abstract_Wallet,
sweep_preparations, InternalAddressCorruption,
@@ -1504,14 +1504,28 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
return
# we need to know the fee before we broadcast, because the txid is required
make_tx = self.mktx_for_open_channel(funding_sat=funding_sat, node_id=node_id)
- funding_tx, _ = self.confirm_tx_dialog(make_tx, funding_sat, allow_preview=False)
+ funding_tx, _, _ = self.confirm_tx_dialog(make_tx, funding_sat, allow_preview=False)
if not funding_tx:
return
self._open_channel(connect_str, funding_sat, push_amt, funding_tx)
- def confirm_tx_dialog(self, make_tx, output_value, *, allow_preview=True, batching_candidates=None) -> tuple[Optional[PartialTransaction], bool]:
- d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=output_value, allow_preview=allow_preview, batching_candidates=batching_candidates)
- return d.run(), d.is_preview
+ def confirm_tx_dialog(
+ self,
+ make_tx,
+ output_value, *,
+ payee_outputs: Optional[list[TxOutput]] = None,
+ allow_preview=True,
+ batching_candidates=None,
+ ) -> tuple[Optional[PartialTransaction], bool, bool]:
+ d = ConfirmTxDialog(
+ window=self,
+ make_tx=make_tx,
+ output_value=output_value,
+ payee_outputs=payee_outputs,
+ allow_preview=allow_preview,
+ batching_candidates=batching_candidates,
+ )
+ return d.run(), d.is_preview, d.did_swap
@protected
def _open_channel(self, connect_str, funding_sat, push_amt, funding_tx, password):
diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py
index 96264c3..6c927b1 100644
--- a/electrum/gui/qt/send_tab.py
+++ b/electrum/gui/qt/send_tab.py
@@ -338,9 +338,16 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
coins_conservative = get_coins(nonlocal_only=True, confirmed_only=True)
candidates = self.wallet.get_candidates_for_batching(outputs, coins=coins_conservative)
- tx, is_preview = self.window.confirm_tx_dialog(make_tx, output_value, batching_candidates=candidates)
+ tx, is_preview, paid_with_swap = self.window.confirm_tx_dialog(
+ make_tx,
+ output_value,
+ payee_outputs=[o for o in outputs if not o.is_change],
+ batching_candidates=candidates,
+ )
if tx is None:
- # user cancelled
+ if paid_with_swap:
+ self.do_clear()
+ # user cancelled or paid with swap
return
if swap_dummy_output := tx.get_dummy_output(DummyAddress.SWAP):
diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py
index 73a003c..859da78 100644
--- a/electrum/gui/qt/swap_dialog.py
+++ b/electrum/gui/qt/swap_dialog.py
@@ -301,7 +301,7 @@ class SwapDialog(WindowModalDialog, QtEventListener):
self.needs_tx_update = True
# update icon
pubkey = from_nip19(self.config.SWAPSERVER_NPUB)['object'].hex() if self.config.SWAPSERVER_NPUB else ''
- self.server_button.setIcon(SwapServerDialog._pubkey_to_q_icon(pubkey))
+ self.server_button.setIcon(SwapServerDialog.pubkey_to_q_icon(pubkey))
def get_client_swap_limits_sat(self) -> Tuple[int, int]:
"""Returns the (min, max) client swap limits in sat."""
@@ -531,12 +531,12 @@ class SwapServerDialog(WindowModalDialog, QtEventListener):
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, self._pubkey_to_q_icon(x.server_pubkey))
+ item.setIcon(self.Columns.PUBKEY, self.pubkey_to_q_icon(x.server_pubkey))
items.append(item)
self.servers_list.insertTopLevelItems(0, items)
@staticmethod
- def _pubkey_to_q_icon(server_pubkey: str) -> QIcon:
+ def pubkey_to_q_icon(server_pubkey: str) -> QIcon:
color = QColor(*pubkey_to_rgb_color(server_pubkey))
color_pixmap = QPixmap(100, 100)
color_pixmap.fill(color)
diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py
index 007c687..aad0db5 100644
--- a/electrum/gui/qt/util.py
+++ b/electrum/gui/qt/util.py
@@ -1522,6 +1522,21 @@ def set_windows_os_screenshot_protection_drm_flag(window: QWidget) -> None:
except Exception:
_logger.exception(f"failed to set windows screenshot protection flag")
+
+def debug_widget_layouts(gui_element: QObject):
+ """Draw red borders around all widgets of given QObject for debugging.
+ E.g. add util.debug_widget_layouts(self) at the end of TxEditor.__init__
+ """
+ assert isinstance(gui_element, QObject) and hasattr(gui_element, 'findChildren')
+ def set_border(widget):
+ if widget is not None:
+ widget.setStyleSheet(widget.styleSheet() + " * { border: 1px solid red; }")
+
+ # Apply to all child widgets recursively
+ for widget in gui_element.findChildren(QWidget):
+ set_border(widget)
+
+
class _ABCQObjectMeta(type(QObject), ABCMeta): pass
class _ABCQWidgetMeta(type(QWidget), ABCMeta): pass
class AbstractQObject(QObject, ABC, metaclass=_ABCQObjectMeta): pass
diff --git a/electrum/simple_config.py b/electrum/simple_config.py
index b545d89..93621c6 100644
--- a/electrum/simple_config.py
+++ b/electrum/simple_config.py
@@ -688,6 +688,13 @@ class SimpleConfig(Logger):
short_desc=lambda: _('Send change to Lightning'),
long_desc=lambda: _('If possible, send the change of this transaction to your channels, with a submarine swap'),
)
+ WALLET_ENABLE_SUBMARINE_PAYMENTS = ConfigVar(
+ 'enable_submarine_payments', default=False, type_=bool,
+ short_desc=lambda: _('Submarine Payments'),
+ long_desc=lambda: _('Send onchain payments directly from your Lightning balance with a '
+ 'submarine swap. This allows you to do onchain transactions even if your entire '
+ 'wallet balance is inside Lightning channels.')
+ )
WALLET_FREEZE_REUSED_ADDRESS_UTXOS = ConfigVar(
'wallet_freeze_reused_address_utxos', default=False, type_=bool,
short_desc=lambda: _('Avoid spending from used addresses'),
diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py
index 6fb5229..3705aa5 100644
--- a/electrum/submarine_swaps.py
+++ b/electrum/submarine_swaps.py
@@ -548,7 +548,7 @@ class SwapManager(Logger):
"""
Construct claim tx that spends exactly the funding utxo to the swap output, independent of the
current fee environment to guarantee the correct amount is being sent to the claim output which
- might be an external address and to keep the claim transaction uncorrelated to the wallets utxos.
+ might be an external address.
"""
assert swap.claim_to_output, swap
txout = PartialTxOutput.from_address_and_value(swap.claim_to_output[0], swap.claim_to_output[1])
@@ -1720,6 +1720,7 @@ class NostrTransport(SwapServerTransport):
async def stop(self):
self.logger.info("shutting down nostr transport")
self.sm.is_initialized.clear()
+ self.is_connected.clear()
await self.taskgroup.cancel_remaining()
await self.relay_manager.close()
self.logger.info("nostr transport shut down")
Why this scored 27/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.