From 4f3274e4c653486c2a08cc7395c35a463e8ffd01 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 8 Jan 2026 17:21:35 +0100 Subject: [PATCH 1/7] qt: TxEditor: extend messages for send change to ln Extend TxEditor.get_messages() with errors for the send change to lightning function so users see why it wasn't used even though it is enabled. --- electrum/gui/qt/confirm_tx_dialog.py | 31 ++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index 3e7fc482e..ce908c5ff 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -667,8 +667,35 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): messages.append(long_warning) if self.no_dynfee_estimates: self.error = _('Fee estimates not available. Please set a fixed fee or feerate.') - if self.tx.get_dummy_output(DummyAddress.SWAP): - messages.append(_('This transaction will send funds to a submarine swap.')) + if dummy_output := self.tx.get_dummy_output(DummyAddress.SWAP): + swap_msg = _('Will send change to lightning') + swap_fee_msg = "." + if self.swap_manager and self.swap_manager.is_initialized.is_set() and isinstance(dummy_output.value, int): + ln_amount_we_recv = self.swap_manager.get_recv_amount(send_amount=dummy_output.value, is_reverse=False) + if ln_amount_we_recv: + swap_fees = dummy_output.value - ln_amount_we_recv + swap_fee_msg = " [" + _("Swap fees:") + " " + self.main_window.format_amount_and_units(swap_fees) + "]." + messages.append(swap_msg + swap_fee_msg) + elif self.config.WALLET_SEND_CHANGE_TO_LIGHTNING and self.tx.has_change(): + swap_msg = _('Will not send change to Lightning') + swap_msg_reason = None + change_amount = sum(c.value for c in self.tx.get_change_outputs() if isinstance(c.value, int)) + if not self.wallet.has_lightning(): + swap_msg_reason = _('Lightning is not enabled.') + elif change_amount > int(self.wallet.lnworker.num_sats_can_receive()): + swap_msg_reason = _("Your channels cannot receive this amount.") + elif self.wallet.lnworker.swap_manager.is_initialized.is_set(): + min_amount = self.wallet.lnworker.swap_manager.get_min_amount() + max_amount = self.wallet.lnworker.swap_manager.get_provider_max_reverse_amount() + if change_amount < min_amount: + swap_msg_reason = _("Below the swap providers minimum value of {}.").format( + self.main_window.format_amount_and_units(min_amount) + ) + else: + swap_msg_reason = _('Change amount exceeds the swap providers maximum value of {}.').format( + self.main_window.format_amount_and_units(max_amount) + ) + messages.append(swap_msg + f": {swap_msg_reason}" if swap_msg_reason else '') # warn if spending unconf if any((txin.block_height is not None and txin.block_height<=0) for txin in self.tx.inputs()): messages.append(_('This transaction will spend unconfirmed coins.')) From 844312f5f62e719ae17b978d127ab56dcdbf4142 Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 9 Jan 2026 14:42:36 +0100 Subject: [PATCH 2/7] TxEditor: move submarine payment help text to messages Moves the help text for submarine payments to messages.py --- electrum/gui/messages.py | 17 +++++++++++++++++ electrum/gui/qt/confirm_tx_dialog.py | 24 ++++-------------------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/electrum/gui/messages.py b/electrum/gui/messages.py index bd9639a86..c823eabd8 100644 --- a/electrum/gui/messages.py +++ b/electrum/gui/messages.py @@ -142,6 +142,23 @@ MSG_CONNECTMODE_ONESERVER_HELP = _( "Using this option on a public server is a security risk and is discouraged." ) +MSG_SUBMARINE_PAYMENT_HELP_TEXT = ''.join(( + _("Submarine Payments use a reverse submarine swap to do on-chain transactions directly " + "from your lightning balance."), '\n\n', + _("Submarine Payments happen in two stages. In the first stage, your wallet sends a lightning " + "payment to the submarine swap provider. The swap provider will lock funds to a " + "funding output in an on-chain transaction (the funding transaction)."), '\n', + _("Once the funding transaction has one confirmation, your wallet will broadcast a claim " + "transaction as the second stage of the payment. This claim transaction spends the funding " + "output to the payee's address."), '\n\n', + _("Warning:"), '\n', + _('The funding transaction is not visible to the payee. They will only see a pending ' + 'transaction in the mempool after your wallet broadcasts the claim transaction. ' + 'Since confirmation of the funding transaction can take over 30 minutes, avoid using ' + 'Submarine Payments when the payee expects to see the transaction within a limited ' + 'time frame (e.g., an online shop checkout). Use a regular on-chain payment instead.'), +)) + MSG_RELAYFEE = ' '.join([ _("This transaction requires a higher fee, or it will not be propagated by your current server."), _("Try to raise your transaction fee, or use a server with a lower relay fee.") diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index ce908c5ff..8e0ebb054 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -35,7 +35,7 @@ from PyQt6.QtWidgets import (QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPus from electrum.i18n import _ from electrum.util import (quantize_feerate, profiler, NotEnoughFunds, NoDynamicFeeEstimates, - get_asyncio_loop, wait_for2, nostr_pow_worker) + get_asyncio_loop, wait_for2) from electrum.plugin import run_hook from electrum.transaction import PartialTransaction, TxOutput from electrum.wallet import InternalAddressCorruption @@ -43,10 +43,10 @@ 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 electrum.gui.messages import MSG_SUBMARINE_PAYMENT_HELP_TEXT from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, WWLabel, - read_QIcon, debug_widget_layouts, qt_event_listener, QtEventListener, IconLabel, + read_QIcon, qt_event_listener, QtEventListener, IconLabel, HelpButton) from .transaction_dialog import TxSizeLabel, TxFiatLabel, TxInOutWidget from .fee_slider import FeeSlider, FeeComboBox @@ -60,22 +60,6 @@ if TYPE_CHECKING: class TxEditor(WindowModalDialog, QtEventListener, Logger): - SUBMARINE_PAYMENT_HELP_TEXT = ''.join(( - _("Submarine Payments use a reverse submarine swap to do on-chain transactions directly " - "from your lightning balance."), '\n\n', - _("Submarine Payments happen in two stages. In the first stage, your wallet sends a lightning " - "payment to the submarine swap provider. The swap provider will lock funds to a " - "funding output in an on-chain transaction (the funding transaction)."), '\n', - _("Once the funding transaction has one confirmation, your wallet will broadcast a claim " - "transaction as the second stage of the payment. This claim transaction spends the funding " - "output to the payee's address."), '\n\n', - _("Warning:"), '\n', - _('The funding transaction is not visible to the payee. They will only see a pending ' - 'transaction in the mempool after your wallet broadcasts the claim transaction. ' - 'Since confirmation of the funding transaction can take over 30 minutes, avoid using ' - 'Submarine Payments when the payee expects to see the transaction within a limited ' - 'time frame (e.g., an online shop checkout). Use a regular on-chain payment instead.'), - )) def __init__( self, *, title='', @@ -759,7 +743,7 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): # Normal layout page normal_page = QWidget() h = QGridLayout(normal_page) - help_button = HelpButton(self.SUBMARINE_PAYMENT_HELP_TEXT) + help_button = HelpButton(MSG_SUBMARINE_PAYMENT_HELP_TEXT) self.submarine_lightning_send_amount_label = QLabel() self.submarine_onchain_send_amount_label = QLabel() self.submarine_claim_mining_fee_label = QLabel() From 40811abe45b02e98666fdf96dde102b18b328f8f Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 9 Jan 2026 16:07:09 +0100 Subject: [PATCH 3/7] 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') From 7b828a831751bfeb34f4f8265244b62f71ec2340 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 13 Jan 2026 14:51:38 +0100 Subject: [PATCH 4/7] wallet: check swap provider liquidity for send change to ln Check the swap providers liquidity as well if we try to send change to lightning in `make_unsigned_transaction`. It is now expected that the swap_manager is already initialized when calling `make_unsigned_transaction`, otherwise no dummy output will get added. --- electrum/wallet.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/electrum/wallet.py b/electrum/wallet.py index c852603af..0c608c9b7 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -2058,11 +2058,14 @@ class Abstract_Wallet(ABC, Logger, EventListener): fee_estimator_vb=fee_estimator, dust_threshold=self.dust_threshold(), BIP69_sort=BIP69_sort) - if self.lnworker and send_change_to_lightning: + if send_change_to_lightning and self.lnworker and self.lnworker.swap_manager.is_initialized.is_set(): + sm = self.lnworker.swap_manager change = tx.get_change_outputs() if len(change) == 1: amount = change[0].value - if amount <= self.lnworker.num_sats_can_receive(): + min_swap_amount = sm.get_min_amount() + max_swap_amount = sm.client_max_amount_forward_swap() or 0 + if min_swap_amount <= amount <= max_swap_amount: tx.replace_output_address(change[0].address, DummyAddress.SWAP) if self.should_keep_reserve_utxo(tx.inputs(), tx.outputs(), is_anchor_channel_opening): raise NotEnoughFunds() From a1841600a1319eb49bc294b0f70250e762603348 Mon Sep 17 00:00:00 2001 From: f321x Date: Sat, 10 Jan 2026 15:53:06 +0100 Subject: [PATCH 5/7] TxEditor: update dynamically based on swap transport Update the TxEditor (onchain tab) if Send change to lightning is enabled and the swap transport changes. Connect to swap transport if send change to lightning gets enabled or if it is enabled and the TxEditor gets opened. This allows to nicely show the swap fees without blocking the UI to wait until the swap manager gets initialized. --- electrum/gui/qt/confirm_tx_dialog.py | 52 ++++++++++++++++++++-------- electrum/gui/qt/swap_dialog.py | 17 +++++++-- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index e793c8a4d..f59e2ed3f 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -43,7 +43,7 @@ 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 electrum.submarine_swaps import NostrTransport, HttpTransport, SwapServerTransport from electrum.gui.messages import MSG_SUBMARINE_PAYMENT_HELP_TEXT from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, WWLabel, @@ -100,9 +100,8 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): 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.swap_transport = None # type: Optional[SwapServerTransport] 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 @@ -137,7 +136,7 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): 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.tab_widget.currentChanged.connect(self.on_tab_changed) self.main_layout = QVBoxLayout() self.main_layout.addWidget(self.tab_widget) @@ -179,9 +178,12 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): 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): + def on_tab_changed(self, index): if self.tab_widget.widget(index) == self.submarine_payment_tab: self.prepare_swap_transport() + self.update_submarine_payment_tab() + else: + self.update() def is_batching(self) -> bool: return self._base_tx is not None @@ -472,6 +474,7 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): self.update_fee_target() def create_buttons_bar(self): + 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) @@ -479,6 +482,7 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): self.ok_button.clicked.connect(self.on_send) self.ok_button.setDefault(True) buttons = Buttons(CancelButton(self), self.preview_button, self.ok_button) + buttons.insertWidget(0, self.change_to_ln_swap_providers_button) if self.batching_candidates is not None and len(self.batching_candidates) > 0: batching_combo = QComboBox() @@ -509,7 +513,7 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): can_have_lightning = self.wallet.can_have_lightning() send_ch_to_ln = self.pref_menu.addConfig( self.config.cv.WALLET_SEND_CHANGE_TO_LIGHTNING, - callback=self.trigger_update, + callback=lambda: (self.prepare_swap_transport(), self.trigger_update()), # type: ignore checked=False if not can_have_lightning else None, ) sub_payments = self.pref_menu.addConfig( @@ -595,6 +599,9 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): w.setVisible(b) def run(self): + if self.config.WALLET_SEND_CHANGE_TO_LIGHTNING: + # if disabled but submarine payments are enabled we only connect once the other tab gets opened + self.prepare_swap_transport() cancelled = not self.exec() self.stop_editor_updates() self.deleteLater() # see #3956 @@ -636,6 +643,13 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): self.fee_label.setText(self.main_window.config.format_amount_and_units(self.tx.get_fee())) self._update_extra_fees() + if self.config.WALLET_SEND_CHANGE_TO_LIGHTNING: + self.change_to_ln_swap_providers_button.setVisible(True) + self.change_to_ln_swap_providers_button.fetching = bool(self.ongoing_swap_transport_connection_attempt) + self.change_to_ln_swap_providers_button.update() + else: + self.change_to_ln_swap_providers_button.setVisible(False) + self._update_send_button() self._update_message() @@ -665,7 +679,9 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): swap_fees = dummy_output.value - ln_amount_we_recv swap_fee_msg = " [" + _("Swap fees:") + " " + self.main_window.format_amount_and_units(swap_fees) + "]." messages.append(swap_msg + swap_fee_msg) - elif self.config.WALLET_SEND_CHANGE_TO_LIGHTNING and self.tx.has_change(): + elif self.config.WALLET_SEND_CHANGE_TO_LIGHTNING \ + and not self.ongoing_swap_transport_connection_attempt \ + and self.tx.has_change(): swap_msg = _('Will not send change to Lightning') swap_msg_reason = None change_amount = sum(c.value for c in self.tx.get_change_outputs() if isinstance(c.value, int)) @@ -684,7 +700,9 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): swap_msg_reason = _('Change amount exceeds the swap providers maximum value of {}.').format( self.main_window.format_amount_and_units(max_amount) ) - messages.append(swap_msg + f": {swap_msg_reason}" if swap_msg_reason else '') + messages.append(swap_msg + (f": {swap_msg_reason}" if swap_msg_reason else '.')) + elif self.ongoing_swap_transport_connection_attempt: + messages.append(_("Fetching submarine swap providers...")) # warn if spending unconf if any((txin.block_height is not None and txin.block_height<=0) for txin in self.tx.inputs()): messages.append(_('This transaction will spend unconfirmed coins.')) @@ -820,9 +838,9 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): @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(): + self.change_to_ln_swap_providers_button.update() + self.submarine_payment_provider_button.update() + if self.ongoing_swap_transport_connection_attempt: return self.swap_availability_changed.emit() @@ -832,7 +850,7 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): if self.tab_widget.currentWidget() == self.submarine_payment_tab: self.update_submarine_payment_tab() else: - pass + self.update() ### --- Functionality for reverse submarine swaps to external address --- def create_submarine_payment_tab(self) -> QWidget: @@ -885,6 +903,8 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): vbox.addWidget(self.submarine_stacked_widget) vbox.addStretch(1) + self.submarine_payment_provider_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) @@ -892,7 +912,7 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): self.submarine_ok_button.clicked.connect(self.start_submarine_payment) buttons = Buttons(CancelButton(self), self.submarine_ok_button) - buttons.insertWidget(0, self.swapserver_button) + buttons.insertWidget(0, self.submarine_payment_provider_button) vbox.addLayout(buttons) return tab_widget @@ -943,13 +963,17 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): if not self.swap_manager: self.set_submarine_payment_tab_warning(_("Enable Lightning in the 'Channels' tab to use Submarine Swaps.")) return + if not self.swap_manager.is_initialized.is_set() \ + and self.ongoing_swap_transport_connection_attempt: + self.show_swap_transport_connection_message() + return if not self.swap_transport: # couldn't connect to nostr relays or http server didn't respond self.set_submarine_payment_tab_warning(_("Submarine swap provider unavailable.")) return # Update the swapserver selection button text - self.swapserver_button.update() + self.submarine_payment_provider_button.update() if not self.swap_manager.is_initialized.is_set(): # connected to nostr relays but couldn't find swapserver announcement diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py index 4c501f44c..667620540 100644 --- a/electrum/gui/qt/swap_dialog.py +++ b/electrum/gui/qt/swap_dialog.py @@ -1,5 +1,5 @@ import enum -from typing import TYPE_CHECKING, Optional, Union, Tuple, Sequence +from typing import TYPE_CHECKING, Optional, Union, Tuple, Sequence, Callable from PyQt6.QtCore import pyqtSignal, Qt, QTimer from PyQt6.QtGui import QIcon, QPixmap, QColor @@ -29,6 +29,7 @@ if TYPE_CHECKING: from .main_window import ElectrumWindow from electrum.submarine_swaps import SwapServerTransport, SwapOffer from electrum.lnchannel import Channel + from electrum.simple_config import SimpleConfig CANNOT_RECEIVE_WARNING = _( """The requested amount is higher than what you can receive in your currently open channels. @@ -45,16 +46,28 @@ class InvalidSwapParameters(Exception): pass class SwapProvidersButton(QPushButton): - def __init__(self, transport_getter, config, main_window): + def __init__( + self, + transport_getter: Callable[[], Optional['SwapServerTransport']], + config: 'SimpleConfig', + main_window: 'ElectrumWindow', + ): """parent must have a transport() method""" QPushButton.__init__(self) self.config = config self.transport_getter = transport_getter self.main_window = main_window self.clicked.connect(self.choose_swap_server) + self.fetching = False self.update() def update(self): + if self.fetching: + self.setEnabled(False) + self.setText(_("Fetching...")) + self.setVisible(True) + return + transport = self.transport_getter() if not isinstance(transport, NostrTransport): # HTTPTransport or no Network, not showing server selection button From 1b28e6bf733eb8357fdfba66d735ce080609d0f2 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 14 Jan 2026 16:03:41 +0100 Subject: [PATCH 6/7] TxEditor: move swap request to TxEditor Moves the logic requesting the forward swap into the TxEditor so it can use the open transport and doesn't have to reconnect to the relays again. Also disables the "Preview" button in the TxEditor when the transaction will send change to lightning. This should prevent the user from saving the transaction to history and broadcasting it later or exporting it and broadcasting it through some external way. Broadcasting needs to happen directly after the TxEditor so we can send the second rpc call to the swapserver and await the incoming htlcs before broadcasting the (funding-) transaction. --- electrum/gui/qt/confirm_tx_dialog.py | 68 +++++++++++++++++++++------- electrum/gui/qt/main_window.py | 8 ++-- electrum/gui/qt/send_tab.py | 18 -------- 3 files changed, 56 insertions(+), 38 deletions(-) diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index f59e2ed3f..a4c9b4f44 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 482a4bba0..fe4e7fb5f 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 bff1b458f..fc02d072c 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, From 2d97717e992dea4846bc08460ca896eabd62f7b8 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 20 Jan 2026 14:59:12 +0100 Subject: [PATCH 7/7] qt: TxEditor: restructure submarine payment messages Show: error amount details \n proposed action instead of: error proposed action amount details --- electrum/gui/qt/confirm_tx_dialog.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index a4c9b4f44..b4ec13134 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -1043,14 +1043,16 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): if self.expected_onchain_amount_sat < self.swap_manager.get_min_amount(): return '\n'.join([ _("Payment amount below the minimum possible swap amount."), + _("Minimum amount: {}").format(f(self.swap_manager.get_min_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."), + too_low_outbound_liquidity_msg = ''.join([ + _("You don't have enough outgoing capacity in your lightning channels."), '\n', + _("Your lightning channels can send: {}").format(f(ln_can_send)), '\n', + _("For this transaction you need: {}").format(f(self.lightning_send_amount_sat)) if self.lightning_send_amount_sat else '', + '\n\n' if self.lightning_send_amount_sat else '\n', _("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 @@ -1062,8 +1064,8 @@ class TxEditor(WindowModalDialog, QtEventListener, Logger): provider_liquidity = 0 msg = [ _("The selected swap provider is unable to offer a forward swap of this value."), + _("Available liquidity") + f": {f(provider_liquidity)}", "", _("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