What changed, and why it matters
This commit moves the code that requests a 'swap invoice' from the send tab into the transaction editor dialog. It also disables the Preview button when a transaction will send leftover change into a Lightning swap. The goal is to make sure the swap is requested over the already-open connection and to stop users from saving or exporting a swap transaction and broadcasting it later, because the swap server needs to be told about the transaction right before it is broadcast. The change is a defensive hardening measure rather than a fix for an active exploit.
Treat as a hardening/refactoring change. Review that request_forward_swap() failures correctly block accept() and that the swap transport is always initialized before TxEditor is opened for swap transactions. No urgent user action is required unless the project tags this as a security fix.
Security signals we found
Refactors swap request to use already-open transport, reducing reconnection attack surface
Disables Preview button when transaction sends change to Lightning swap
Prevents saving/exporting and later broadcasting of swap funding transactions
Ensures swapserver RPC and on-chain broadcast happen in the correct sequence
Adds defensive assertions for dummy swap output state
Evidence from the diff
The patch refactors submarine-swap (‘send change to lightning’) handling in the Qt GUI. Previously, SendTab.do_send() requested the forward swap after the TxEditor closed, creating a new SwapServerTransport each time. Now, TxEditor.on_send() calls request_forward_swap() using the transport already opened by the editor, and the swap dummy output is replaced with the real lockup address before accept(). The allow_preview parameter is replaced by a TxEditorContext enum; preview is hidden/disabled for CHANNEL_FUNDING and disabled when the tx has a DummyAddress.SWAP output. This prevents the user from previewing/exporting/broadcasting a swap tx outside the immediate flow, which would otherwise race against the swapserver’s HTLC expectation.
Changed components
electrum/gui/qt/confirm_tx_dialog.pyelectrum/gui/qt/main_window.pyelectrum/gui/qt/send_tab.pySubmarine swap / send-change-to-Lightning featureInspect captured patch +56 / −38
diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py
index f59e2ed..a4c9b4f 100644
--- a/electrum/gui/qt/confirm_tx_dialog.py
+++ b/electrum/gui/qt/confirm_tx_dialog.py
@@ -28,6 +28,7 @@ from decimal import Decimal
from functools import partial
from typing import TYPE_CHECKING, Optional, Union
from concurrent.futures import Future
+from enum import Enum, auto
from PyQt6.QtCore import Qt, QTimer, pyqtSlot, pyqtSignal
from PyQt6.QtGui import QIcon
@@ -35,20 +36,20 @@ from PyQt6.QtWidgets import (QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPus
QComboBox, QTabWidget, QWidget, QStackedWidget)
from electrum.i18n import _
-from electrum.util import (quantize_feerate, profiler, NotEnoughFunds, NoDynamicFeeEstimates,
- get_asyncio_loop, wait_for2)
+from electrum.util import (UserCancelled, quantize_feerate, profiler, NotEnoughFunds, NoDynamicFeeEstimates,
+ get_asyncio_loop, wait_for2, UserFacingException)
from electrum.plugin import run_hook
-from electrum.transaction import PartialTransaction, TxOutput
+from electrum.transaction import PartialTransaction, PartialTxOutput
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
+from electrum.submarine_swaps import NostrTransport, HttpTransport, SwapServerTransport, SwapServerError
from electrum.gui.messages import MSG_SUBMARINE_PAYMENT_HELP_TEXT
from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, WWLabel,
read_QIcon, qt_event_listener, QtEventListener, IconLabel,
- HelpButton)
+ HelpButton, RunCoroutineDialog)
from .transaction_dialog import TxSizeLabel, TxFiatLabel, TxInOutWidget
from .fee_slider import FeeSlider, FeeComboBox
from .amountedit import FeerateEdit, BTCAmountEdit
@@ -60,6 +61,15 @@ if TYPE_CHECKING:
from .main_window import ElectrumWindow
+class TxEditorContext(Enum):
+ """
+ Context for which the TxEditor gets launched.
+ Allows to enable/disable certain features.
+ """
+ PAYMENT = auto()
+ CHANNEL_FUNDING = auto()
+
+
class TxEditor(WindowModalDialog, QtEventListener, Logger):
swap_availability_changed = pyqtSignal()
@@ -69,8 +79,8 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger):
window: 'ElectrumWindow',
make_tx,
output_value: Union[int, str],
- payee_outputs: Optional[list[TxOutput]] = None,
- allow_preview=True,
+ payee_outputs: Optional[list[PartialTxOutput]] = None,
+ context: TxEditorContext = TxEditorContext.PAYMENT,
batching_candidates=None,
):
@@ -93,8 +103,7 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger):
self.not_enough_funds = False
self.no_dynfee_estimates = False
self.needs_update = False
- # preview is disabled for lightning channel funding
- self.allow_preview = allow_preview
+ self.context = context
self.is_preview = False
self._base_tx = None # for batching
self.batching_candidates = batching_candidates
@@ -311,8 +320,7 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger):
# always show onchain payment tab
self.tab_widget.addTab(self.onchain_tab, _('Onchain Transaction'))
- # allow_preview is false for ln channel opening txs
- allow_swaps = self.allow_preview and self.payee_outputs and self.swap_manager
+ allow_swaps = self.context == TxEditorContext.PAYMENT and self.payee_outputs and self.swap_manager
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()
@@ -477,7 +485,7 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger):
self.change_to_ln_swap_providers_button = SwapProvidersButton(lambda: self.swap_transport, self.config, self.main_window)
self.preview_button = QPushButton(_('Preview'))
self.preview_button.clicked.connect(self.on_preview)
- self.preview_button.setVisible(self.allow_preview)
+ self.preview_button.setVisible(self.context != TxEditorContext.CHANNEL_FUNDING)
self.ok_button = QPushButton(_('OK'))
self.ok_button.clicked.connect(self.on_send)
self.ok_button.setDefault(True)
@@ -608,9 +616,13 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger):
return self.tx if not cancelled else None
def on_send(self):
+ if self.tx and self.tx.get_dummy_output(DummyAddress.SWAP):
+ if not self.request_forward_swap():
+ return
self.accept()
def on_preview(self):
+ assert not self.tx.get_dummy_output(DummyAddress.SWAP), "no preview when sending change to ln"
self.is_preview = True
self.accept()
@@ -746,8 +758,12 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger):
self.message_label.setText(self.error or message_str)
def _update_send_button(self):
+ # disable preview button when sending change to lightning to prevent the user from saving or
+ # exporting the transaction and broadcasting it later somehow.
+ send_change_to_ln = self.tx and self.tx.get_dummy_output(DummyAddress.SWAP)
enabled = bool(self.tx) and not self.error
- self.preview_button.setEnabled(enabled)
+ self.preview_button.setEnabled(enabled and not send_change_to_ln)
+ self.preview_button.setToolTip(_("Can't show preview when sending change to lightning") if send_change_to_ln else "")
self.ok_button.setEnabled(enabled)
def can_pay_assuming_zero_fees(self, confirmed_only: bool) -> bool:
@@ -1073,6 +1089,26 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger):
self.submarine_stacked_widget.setCurrentIndex(1)
self.submarine_ok_button.setEnabled(False)
+ # --- send change to lightning swap functionality ---
+ def request_forward_swap(self):
+ swap_dummy_output = self.tx.get_dummy_output(DummyAddress.SWAP)
+ sm, transport = self.swap_manager, self.swap_transport
+ assert sm and transport and swap_dummy_output and isinstance(swap_dummy_output.value, int)
+ coro = sm.request_swap_for_amount(transport=transport, onchain_amount=int(swap_dummy_output.value))
+ coro_dialog = RunCoroutineDialog(self, _('Requesting swap invoice...'), coro)
+ try:
+ swap, swap_invoice = coro_dialog.run()
+ except (SwapServerError, UserFacingException) as e:
+ self.show_error(str(e))
+ return False
+ except UserCancelled:
+ return False
+ self.tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address)
+ assert self.tx.get_dummy_output(DummyAddress.SWAP) is None
+ self.tx.swap_invoice = swap_invoice
+ self.tx.swap_payment_hash = swap.payment_hash
+ return True
+
class ConfirmTxDialog(TxEditor):
help_text = '' #_('Set the mining fee of your transaction')
@@ -1082,8 +1118,8 @@ class ConfirmTxDialog(TxEditor):
window: 'ElectrumWindow',
make_tx,
output_value: Union[int, str],
- payee_outputs: Optional[list[TxOutput]] = None,
- allow_preview=True,
+ payee_outputs: Optional[list[PartialTxOutput]] = None,
+ context: TxEditorContext = TxEditorContext.PAYMENT,
batching_candidates=None,
):
@@ -1094,7 +1130,7 @@ class ConfirmTxDialog(TxEditor):
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
+ context=context,
batching_candidates=batching_candidates,
)
self.trigger_update()
diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py
index 482a4bb..fe4e7fb 100644
--- a/electrum/gui/qt/main_window.py
+++ b/electrum/gui/qt/main_window.py
@@ -96,7 +96,7 @@ from .wizard.wallet import WIF_HELP_TEXT
from .history_list import HistoryList, HistoryModel
from .update_checker import UpdateCheck, UpdateCheckThread
from .channels_list import ChannelsList
-from .confirm_tx_dialog import ConfirmTxDialog
+from .confirm_tx_dialog import ConfirmTxDialog, TxEditorContext
from .rbf_dialog import BumpFeeDialog, DSCancelDialog
from .qrreader import scan_qrcode_from_camera
from .swap_dialog import SwapDialog, InvalidSwapParameters
@@ -1504,7 +1504,7 @@ 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, context=TxEditorContext.CHANNEL_FUNDING)
if not funding_tx:
return
self._open_channel(connect_str, funding_sat, push_amt, funding_tx)
@@ -1514,7 +1514,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
make_tx,
output_value, *,
payee_outputs: Optional[list[TxOutput]] = None,
- allow_preview=True,
+ context: TxEditorContext = TxEditorContext.PAYMENT,
batching_candidates=None,
) -> tuple[Optional[PartialTransaction], bool, bool]:
d = ConfirmTxDialog(
@@ -1522,7 +1522,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
make_tx=make_tx,
output_value=output_value,
payee_outputs=payee_outputs,
- allow_preview=allow_preview,
+ context=context,
batching_candidates=batching_candidates,
)
return d.run(), d.is_preview, d.did_swap
diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py
index bff1b45..fc02d07 100644
--- a/electrum/gui/qt/send_tab.py
+++ b/electrum/gui/qt/send_tab.py
@@ -351,24 +351,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
# user cancelled or paid with swap
return
- if swap_dummy_output := tx.get_dummy_output(DummyAddress.SWAP):
- sm = self.wallet.lnworker.swap_manager
- with self.window.create_sm_transport() as transport:
- if not self.window.initialize_swap_manager(transport):
- return
- coro = sm.request_swap_for_amount(transport=transport, onchain_amount=swap_dummy_output.value)
- try:
- swap, swap_invoice = self.window.run_coroutine_dialog(coro, _('Requesting swap invoice...'))
- except (SwapServerError, UserFacingException) as e:
- self.show_error(str(e))
- return
- except UserCancelled:
- return
- tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address)
- assert tx.get_dummy_output(DummyAddress.SWAP) is None
- tx.swap_invoice = swap_invoice
- tx.swap_payment_hash = swap.payment_hash
-
if is_preview:
self.window.show_transaction(
tx,
Why this scored 33/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.