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 3e7fc482e..b4ec13134 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -27,27 +27,29 @@ import asyncio 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 +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) from electrum.i18n import _ -from electrum.util import (quantize_feerate, profiler, NotEnoughFunds, NoDynamicFeeEstimates, - get_asyncio_loop, wait_for2, nostr_pow_worker) +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 -from .seed_dialog import seed_warning_msg +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, debug_widget_layouts, qt_event_listener, QtEventListener, IconLabel, - HelpButton) + read_QIcon, qt_event_listener, QtEventListener, IconLabel, + HelpButton, RunCoroutineDialog) from .transaction_dialog import TxSizeLabel, TxFiatLabel, TxInOutWidget from .fee_slider import FeeSlider, FeeComboBox from .amountedit import FeerateEdit, BTCAmountEdit @@ -59,31 +61,26 @@ 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): - 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.'), - )) + + swap_availability_changed = pyqtSignal() def __init__( self, *, title='', 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, ): @@ -106,15 +103,15 @@ 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 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_transport = None # type: Optional[SwapServerTransport] + self.swap_availability_changed.connect(self.on_swap_availability_changed, Qt.ConnectionType.QueuedConnection) + 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) @@ -148,7 +145,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) @@ -190,9 +187,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 @@ -320,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() @@ -483,13 +482,15 @@ 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) + 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) 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() @@ -520,7 +521,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( @@ -606,15 +607,22 @@ 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 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() @@ -647,6 +655,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() @@ -667,8 +682,39 @@ 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 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)) + 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 '.')) + 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.')) @@ -712,13 +758,116 @@ 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: 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.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() + + @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: + self.update() + ### --- 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""" @@ -732,7 +881,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() @@ -770,16 +919,16 @@ 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_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) # 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.submarine_payment_provider_button) vbox.addLayout(buttons) return tab_widget @@ -789,97 +938,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] @@ -909,23 +968,28 @@ 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_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_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.submarine_payment_provider_button.update() if not self.swap_manager.is_initialized.is_set(): # connected to nostr relays but couldn't find swapserver announcement @@ -935,7 +999,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 @@ -951,7 +1015,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.) @@ -979,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 @@ -998,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 @@ -1019,21 +1085,31 @@ 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() + # --- 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): @@ -1044,8 +1120,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, ): @@ -1056,7 +1132,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, 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 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()