From 8a70fdcb81172d26b83b9e9055139e4cab55de9b Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 7 Nov 2025 10:24:05 +0100 Subject: [PATCH] qt: expose swaps to address as Submarine Payments Exposes reverse submarine swaps to an external/specific address in the TxEditor gui as "Submarine Payments". The user can enter a onchain address in the Send Tab and then pay it from the lightning balance in the send tab by enabling the Submarine Payments option in the TxEditor dialog menu and switching to the Submarine Payment tab in the Tab bar. --- electrum/gui/qt/confirm_tx_dialog.py | 474 +++++++++++++++++++++++++-- electrum/gui/qt/main_window.py | 24 +- electrum/gui/qt/send_tab.py | 11 +- electrum/gui/qt/swap_dialog.py | 6 +- electrum/gui/qt/util.py | 15 + electrum/simple_config.py | 7 + electrum/submarine_swaps.py | 3 +- 7 files changed, 501 insertions(+), 39 deletions(-) diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index d6047eb24..272241786 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -23,50 +23,61 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import asyncio from decimal import Decimal from functools import partial -from typing import TYPE_CHECKING, Optional, Union, Callable +from typing import TYPE_CHECKING, Optional, Union +from electrum_aionostr.util import from_nip19 from PyQt6.QtCore import Qt, QTimer from PyQt6.QtGui import QIcon -from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton, QMenu, QComboBox +from PyQt6.QtWidgets import (QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton, + QComboBox, QTabWidget, QWidget, QStackedWidget) from electrum.i18n import _ -from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates -from electrum.util import quantize_feerate +from electrum.util import (quantize_feerate, profiler, NotEnoughFunds, NoDynamicFeeEstimates, + get_asyncio_loop, wait_for2, nostr_pow_worker) from electrum.plugin import run_hook -from electrum.transaction import Transaction, PartialTransaction +from electrum.transaction import PartialTransaction, TxOutput from electrum.wallet import InternalAddressCorruption from electrum.bitcoin import DummyAddress from electrum.fee_policy import FeePolicy, FixedFeePolicy, FeeMethod +from electrum.logging import Logger +from electrum.submarine_swaps import NostrTransport, HttpTransport +from .seed_dialog import seed_warning_msg -from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, - WWLabel, read_QIcon) +from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, WWLabel, + read_QIcon, debug_widget_layouts, qt_event_listener, QtEventListener, IconLabel) from .transaction_dialog import TxSizeLabel, TxFiatLabel, TxInOutWidget from .fee_slider import FeeSlider, FeeComboBox from .amountedit import FeerateEdit, BTCAmountEdit from .locktimeedit import LockTimeEdit from .my_treeview import QMenuWithConfig +from .swap_dialog import SwapServerDialog if TYPE_CHECKING: from .main_window import ElectrumWindow -class TxEditor(WindowModalDialog): +class TxEditor(WindowModalDialog, QtEventListener, Logger): def __init__( self, *, title='', window: 'ElectrumWindow', make_tx, output_value: Union[int, str], + payee_outputs: Optional[list[TxOutput]] = None, allow_preview=True, batching_candidates=None, ): WindowModalDialog.__init__(self, window, title=title) + Logger.__init__(self) self.main_window = window self.make_tx = make_tx self.output_value = output_value + # used only for submarine payments as they construct tx independently of make_tx + self.payee_outputs = payee_outputs self.tx = None # type: Optional[PartialTransaction] self.messages = [] self.error = '' # set by side effect @@ -85,40 +96,87 @@ class TxEditor(WindowModalDialog): self._base_tx = None # for batching self.batching_candidates = batching_candidates + self.swap_manager = self.wallet.lnworker.swap_manager if self.wallet.has_lightning() else None + self.swap_transport = None # type: Optional[Union[NostrTransport, HttpTransport]] + self.ongoing_swap_transport_connection_attempt = None # type: Optional[asyncio.Task] + self.did_swap = False # used to clear the PI on send tab + self.locktime_e = LockTimeEdit(self) self.locktime_e.valueEdited.connect(self.trigger_update) self.locktime_label = QLabel(_("LockTime") + ": ") self.io_widget = TxInOutWidget(self.main_window, self.wallet) self.create_fee_controls() - vbox = QVBoxLayout() - self.setLayout(vbox) - - top = self.create_top_bar(self.help_text) - grid = self.create_grid() - - vbox.addLayout(top) - vbox.addLayout(grid) - vbox.addWidget(self.io_widget) + onchain_vbox = QVBoxLayout() + onchain_top = self.create_top_bar(self.help_text) + onchain_grid = self.create_grid() + onchain_vbox.addLayout(onchain_top) + onchain_vbox.addLayout(onchain_grid) + onchain_vbox.addWidget(self.io_widget) self.message_label = WWLabel('') self.message_label.setMinimumHeight(70) - vbox.addWidget(self.message_label) + onchain_vbox.addWidget(self.message_label) - buttons = self.create_buttons_bar() - vbox.addStretch(1) - vbox.addLayout(buttons) + onchain_buttons = self.create_buttons_bar() + onchain_vbox.addStretch(1) + onchain_vbox.addLayout(onchain_buttons) + + # onchain tab is the main tab and the content is also shown if tabs are disabled + self.onchain_tab = QWidget() + self.onchain_tab.setContentsMargins(0,0,0,0) + self.onchain_tab.setLayout(onchain_vbox) + + # optional submarine payment tab, the tab is only shown if the option is enabled + self.submarine_payment_tab = self.create_submarine_payment_tab() + + self.tab_widget = QTabWidget() + self.tab_widget.setTabBarAutoHide(True) # hides the tab bar if there is only one tab + self.tab_widget.setContentsMargins(0, 0, 0, 0) + self.tab_widget.tabBarClicked.connect(self.on_tab_clicked) + + self.main_layout = QVBoxLayout() + self.main_layout.addWidget(self.tab_widget) + self.main_layout.setContentsMargins(6, 6, 6, 6) # reduce outermost margins a bit + self.setLayout(self.main_layout) self.set_io_visible() self.set_fee_edit_visible() self.set_locktime_visible() self.update_fee_target() - self.resize(self.layout().sizeHint()) + self.update_tab_visibility() + self.resize_to_fit_content() self.timer = QTimer(self) self.timer.setInterval(500) self.timer.setSingleShot(False) self.timer.timeout.connect(self.timer_actions) self.timer.start() + self.register_callbacks() + # debug_widget_layouts(self) # enable to show red lines around all elements + + def accept(self): + self._cleanup() + super().accept() + + def reject(self): + self._cleanup() + super().reject() + + def closeEvent(self, event): + self._cleanup() + super().closeEvent(event) + + def _cleanup(self): + self.unregister_callbacks() + if self.ongoing_swap_transport_connection_attempt: + self.ongoing_swap_transport_connection_attempt.cancel() + if isinstance(self.swap_transport, NostrTransport): + asyncio.run_coroutine_threadsafe(self.swap_transport.stop(), get_asyncio_loop()) + self.swap_transport = None # HTTPTransport doesn't need to be closed + + def on_tab_clicked(self, index): + if self.tab_widget.widget(index) == self.submarine_payment_tab: + self.prepare_swap_transport() def is_batching(self) -> bool: return self._base_tx is not None @@ -140,6 +198,13 @@ class TxEditor(WindowModalDialog): # expected to set self.tx, self.message and self.error raise NotImplementedError() + def create_grid(self) -> QGridLayout: + raise NotImplementedError() + + @property + def help_text(self) -> str: + raise NotImplementedError() + def update_fee_target(self): if self.fee_slider.is_active(): text = self.fee_policy.get_target_text() @@ -230,6 +295,32 @@ class TxEditor(WindowModalDialog): self.fee_slider.setFixedWidth(200) self.fee_target.setFixedSize(self.feerate_e.sizeHint()) + def update_tab_visibility(self): + """Update self.tab_widget to show all tabs that are enabled.""" + # first remove all tabs + while self.tab_widget.count() > 0: + self.tab_widget.removeTab(0) + + # always show onchain payment tab + self.tab_widget.addTab(self.onchain_tab, _('Onchain')) + + allow_swaps = self.allow_preview and self.payee_outputs # allow_preview is false for ln channel opening txs + if self.config.WALLET_ENABLE_SUBMARINE_PAYMENTS and allow_swaps: + i = self.tab_widget.addTab(self.submarine_payment_tab, _('Submarine Payment')) + tooltip = self.config.cv.WALLET_ENABLE_SUBMARINE_PAYMENTS.get_long_desc() + if len(self.payee_outputs) > 1: + self.tab_widget.setTabEnabled(i, False) + tooltip = _("Submarine Payments don't support multiple outputs (Pay-to-many).") + elif self.payee_outputs[0].value == '!': + self.tab_widget.setTabEnabled(i, False) + self.submarine_payment_tab.setEnabled(False) + tooltip = _("Submarine Payments don't support 'Max' value spends.") + self.tab_widget.tabBar().setTabToolTip(i, tooltip) + + # enable document mode if there is only one tab to hide the frame + self.tab_widget.setDocumentMode(self.tab_widget.count() < 2) + self.resize_to_fit_content() + def trigger_update(self): # set tx to None so that the ok button is disabled while we compute the new tx self.tx = None @@ -311,7 +402,6 @@ class TxEditor(WindowModalDialog): feerate_color = ColorScheme.BLUE self.fee_e.setStyleSheet(fee_color.as_stylesheet()) self.feerate_e.setStyleSheet(feerate_color.as_stylesheet()) - # self.needs_update = True def update_fee_fields(self): @@ -411,6 +501,7 @@ class TxEditor(WindowModalDialog): self.pref_menu.addConfig(self.config.cv.GUI_QT_TX_EDITOR_SHOW_LOCKTIME, callback=cb) self.pref_menu.addSeparator() self.pref_menu.addConfig(self.config.cv.WALLET_SEND_CHANGE_TO_LIGHTNING, callback=self.trigger_update) + self.pref_menu.addConfig(self.config.cv.WALLET_ENABLE_SUBMARINE_PAYMENTS, callback=self.update_tab_visibility) self.pref_menu.addToggle( _('Use change addresses'), self.toggle_use_change, @@ -442,11 +533,12 @@ class TxEditor(WindowModalDialog): hbox.addWidget(self.pref_button) return hbox + @profiler(min_threshold=0.02) def resize_to_fit_content(self): - # fixme: calling resize once is not enough... - size = self.layout().sizeHint() - self.resize(size) - self.resize(size) + # update all geometries so the updated size hints are used for size adjustment + for widget in self.findChildren(QWidget): + widget.updateGeometry() + self.adjustSize() def toggle_use_change(self): self.wallet.use_change = not self.wallet.use_change @@ -593,17 +685,343 @@ class TxEditor(WindowModalDialog): def can_pay_assuming_zero_fees(self, confirmed_only: bool) -> bool: raise NotImplementedError + ### --- Functionality for reverse submarine swaps to external address --- + def create_submarine_payment_tab(self) -> QWidget: + """Returns widget for submarine payment functionality to be added as tab""" + tab_widget = QWidget() + vbox = QVBoxLayout(tab_widget) + + # stack two views, a warning view and the regular one. The warning view is shown if + # the swap cannot be performed, e.g. due to missing liquidity. + self.submarine_stacked_widget = QStackedWidget() + + # Normal layout page + normal_page = QWidget() + h = QGridLayout(normal_page) + self.submarine_lightning_send_amount_label = QLabel() + self.submarine_onchain_send_amount_label = QLabel() + self.submarine_claim_mining_fee_label = QLabel() + self.submarine_server_fee_label = QLabel() + self.submarine_we_send_label = IconLabel(text=_('You send')+':') + self.submarine_we_send_label.setIcon(read_QIcon('lightning.png')) + self.submarine_they_receive_label = IconLabel(text=_('They receive')+':') + self.submarine_they_receive_label.setIcon(read_QIcon('bitcoin.png')) + h.addWidget(self.submarine_we_send_label, 0, 0) + h.addWidget(self.submarine_lightning_send_amount_label, 0, 1) + h.addWidget(self.submarine_they_receive_label, 1, 0) + h.addWidget(self.submarine_onchain_send_amount_label, 1, 1) + h.addWidget(QLabel(_('Swap fee')+':'), 2, 0) + h.addWidget(self.submarine_server_fee_label, 2, 1, 1, 2) + h.addWidget(QLabel(_('Mining fee')+':'), 3, 0) + h.addWidget(self.submarine_claim_mining_fee_label, 3, 1, 1, 2) + + # Warning layout page + warning_page = QWidget() + warning_layout = QVBoxLayout(warning_page) + self.submarine_warning_label = QLabel('') + warning_layout.addWidget(self.submarine_warning_label) + + self.submarine_stacked_widget.addWidget(normal_page) + self.submarine_stacked_widget.addWidget(warning_page) + + vbox.addWidget(self.submarine_stacked_widget) + vbox.addStretch(1) + + self.server_button = QPushButton() + self.server_button.clicked.connect(self.choose_swap_server) + self.server_button.setEnabled(False) + self.server_button.setVisible(False) + + self.submarine_ok_button = QPushButton(_('OK')) + self.submarine_ok_button.setDefault(True) + self.submarine_ok_button.setEnabled(False) + # pay button must not self.accept() as this triggers closing the transport + self.submarine_ok_button.clicked.connect(self.start_submarine_swap) + + buttons = Buttons(CancelButton(self), self.submarine_ok_button) + buttons.insertWidget(0, self.server_button) + vbox.addLayout(buttons) + + return tab_widget + + def show_swap_transport_connection_message(self): + self.submarine_stacked_widget.setCurrentIndex(1) + self.submarine_warning_label.setText(_("Connecting, please wait...")) + self.submarine_ok_button.setEnabled(False) + + def prepare_swap_transport(self): + if self.swap_transport is not None and self.swap_transport.is_connected.is_set(): + # we already have a connected transport, no need to create a new one + return + if self.ongoing_swap_transport_connection_attempt \ + and not self.ongoing_swap_transport_connection_attempt.done(): + # another task is currently trying to connect + return + + # there should only be a connected transport. + # a useless transport should get cleaned up and not stored. + assert self.swap_transport is None, "swap transport wasn't cleaned up properly" + + # give user feedback that we are connection now + self.show_swap_transport_connection_message() + new_swap_transport = self.main_window.create_sm_transport() + if not new_swap_transport: + # user declined to enable Nostr and has no http server configured + self.update_submarine_tab() + return + + async def connect_and_update_tab(transport): + try: + await self.initialize_swap_transport(transport) + self.update_submarine_tab() + except Exception: + self.logger.exception("failed to create swap transport") + + task = asyncio.run_coroutine_threadsafe( + connect_and_update_tab(new_swap_transport), + get_asyncio_loop(), + ) + # this task will get cancelled if the TxEditor gets closed + self.ongoing_swap_transport_connection_attempt = task + + async def initialize_swap_transport(self, new_swap_transport): + # start the transport + if isinstance(new_swap_transport, NostrTransport): + asyncio.create_task(new_swap_transport.main_loop()) + else: + assert isinstance(new_swap_transport, HttpTransport) + asyncio.create_task(new_swap_transport.get_pairs_just_once()) + # wait for the transport to be connected + if not await self.wait_for_swap_transport(new_swap_transport): + return + self.swap_transport = new_swap_transport + + async def wait_for_swap_transport(self, new_swap_transport: Union[HttpTransport, NostrTransport]) -> bool: + """ + Wait until we found the announcement event of the configured swap server. + If it is not found but the relay connection is established return True anyway, + the user will then need to select a different swap server. + """ + timeout = new_swap_transport.connect_timeout + 1 + try: + # swap_manager.is_initialized gets set once we got pairs of the configured swap server + await wait_for2(self.swap_manager.is_initialized.wait(), timeout) + except asyncio.TimeoutError: + self.logger.debug(f"swap transport initialization timed out after {timeout} sec") + except Exception: + self.logger.exception("failed to initialize swap transport") + return False + + if self.swap_manager.is_initialized.is_set(): + return True + + # timed out above + if self.config.SWAPSERVER_URL: + # http swapserver didn't return pairs + self.logger.error(f"couldn't request pairs from {self.config.SWAPSERVER_URL=}") + return False + elif new_swap_transport.is_connected.is_set(): + assert isinstance(new_swap_transport, NostrTransport) + # couldn't find announcement of configured swapserver, maybe it is gone. + # update_submarine_tab will tell the user to select a different swap server. + return True + + # we couldn't even connect to the relays, this transport is useless. maybe network issues. + return False + + def choose_swap_server(self) -> None: + assert isinstance(self.swap_transport, NostrTransport), self.swap_transport + self.main_window.choose_swapserver_dialog(self.swap_transport) + self.update_submarine_tab() + + def start_submarine_swap(self): + assert self.payee_outputs and len(self.payee_outputs) == 1 + payee_output = self.payee_outputs[0] + + assert self.expected_onchain_amount_sat is not None + assert self.lightning_send_amount_sat is not None + assert self.last_server_mining_fee_sat is not None + assert self.swap_transport.is_connected.is_set() + assert self.swap_manager.is_initialized.is_set() + + self.tx = None # prevent broadcasting + self.submarine_ok_button.setEnabled(False) + coro = self.swap_manager.reverse_swap( + transport=self.swap_transport, + lightning_amount_sat=self.lightning_send_amount_sat, + expected_onchain_amount_sat=self.expected_onchain_amount_sat, + prepayment_sat=2 * self.last_server_mining_fee_sat, + claim_to_output=payee_output, + ) + try: + funding_txid = self.main_window.run_coroutine_dialog(coro, _('Initiating Submarine Payment...')) + except Exception as e: + self.close() + self.main_window.show_error(_("Submarine Payment failed:") + "\n" + str(e)) + return + self.did_swap = True + # accepting closes the swap transport, so it needs to happen after the swap + self.accept() + self.main_window.on_swap_result(funding_txid, is_reverse=True) + + def update_submarine_tab(self): + assert self.payee_outputs, "Opened submarine payment tab without outputs?" + assert len(self.payee_outputs) == \ + len([o for o in self.payee_outputs if not o.is_change and not isinstance(o.value, str)]) + f = self.main_window.format_amount_and_units + self.logger.debug(f"TxEditor updating submarine tab") + + if not self.swap_manager: + self.set_swap_tab_warning(_("Enable Lightning in the 'Channels' tab to use Submarine Swaps.")) + return + if not self.swap_transport: + # couldn't connect to nostr relays or http server didn't respond + self.set_swap_tab_warning(_("Submarine swap provider unavailable.")) + return + + # Update the swapserver selection button text + if isinstance(self.swap_transport, NostrTransport): + self.server_button.setVisible(True) + self.server_button.setEnabled(True) + if self.config.SWAPSERVER_NPUB: + pubkey = from_nip19(self.config.SWAPSERVER_NPUB)['object'].hex() + self.server_button.setIcon(SwapServerDialog.pubkey_to_q_icon(pubkey)) + self.server_button.setText( + f' {len(self.swap_transport.get_recent_offers())} ' + + (_('providers') if len(self.swap_transport.get_recent_offers()) != 1 else _('provider')) + ) + else: + # HTTPTransport or no Network, not showing server selection button + self.server_button.setEnabled(False) + self.server_button.setVisible(False) + + if not self.swap_manager.is_initialized.is_set(): + # connected to nostr relays but couldn't find swapserver announcement + assert isinstance(self.swap_transport, NostrTransport), "HTTPTransport shouldn't get set if it cannot fetch pairs" + assert self.swap_transport.is_connected.is_set(), "closed transport wasn't cleaned up" + if self.config.SWAPSERVER_NPUB: + msg = _("Couldn't connect to your swap provider. Please select a different provider.") + else: + msg = _('Please select a submarine swap provider.') + self.set_swap_tab_warning(msg) + return + + # update values + self.lightning_send_amount_sat = self.swap_manager.get_send_amount( + self.payee_outputs[0].value, # claim tx fee reserve gets added in get_send_amount + is_reverse=True, + ) + self.last_server_mining_fee_sat = self.swap_manager.mining_fee + self.expected_onchain_amount_sat = ( + self.payee_outputs[0].value + self.swap_manager.get_fee_for_txbatcher() + ) + + # get warning + warning_text = self.get_swap_warning() + if warning_text: + self.set_swap_tab_warning(warning_text) + return + + # There is no warning, show the normal view (amounts etc.) + self.submarine_stacked_widget.setCurrentIndex(0) + + # label showing the payment amount (the amount the user entered in SendTab) + self.submarine_onchain_send_amount_label.setText(f(self.payee_outputs[0].value)) + + # the fee we pay to claim the funding output to the onchain address, shown as "Mining Fee" + claim_tx_mining_fee = self.swap_manager.get_fee_for_txbatcher() + self.submarine_claim_mining_fee_label.setText(f(claim_tx_mining_fee)) + + assert self.lightning_send_amount_sat is not None + self.submarine_lightning_send_amount_label.setText(f(self.lightning_send_amount_sat)) + # complete fee we pay to the server + server_fee = self.lightning_send_amount_sat - self.expected_onchain_amount_sat + self.submarine_server_fee_label.setText(f(server_fee)) + + self.submarine_ok_button.setEnabled(True) + + def get_swap_warning(self) -> Optional[str]: + f = self.main_window.format_amount_and_units + ln_can_send = int(self.wallet.lnworker.num_sats_can_send()) + + if self.expected_onchain_amount_sat < self.swap_manager.get_min_amount(): + return '\n'.join([ + _("Payment amount below the minimum possible swap amount."), + _("You need to send a higher amount to be able to do a Submarine Payment."), + _("Minimum amount: {}").format(f(self.swap_manager.get_min_amount())), + ]) + + too_low_outbound_liquidity_msg = '\n'.join([ + _("You don't have enough outgoing capacity in your lightning channels."), + _("To add outgoing capacity you can open a new lightning channel or do a submarine swap."), + _("Your lightning channels can send: {}").format(f(ln_can_send)), + ]) + + # prioritize showing the swap provider liquidity warning before the channel liquidity warning + # as it could be annoying for the user to be told to open a new channel just to come back to + # notice there is no provider supporting their swap amount + if self.lightning_send_amount_sat is None: + provider_liquidity = self.swap_manager.get_provider_max_forward_amount() + if provider_liquidity < self.swap_manager.get_min_amount(): + provider_liquidity = 0 + msg = [ + _("The selected swap provider is unable to offer a forward swap of this value."), + _("In order to continue select a different provider or try to send a smaller amount."), + _("Available liquidity") + f": {f(provider_liquidity)}", + ] + # we don't know exactly how much we need to send on ln yet, so we can assume 0 provider fees + probably_too_low_outbound_liquidity = self.expected_onchain_amount_sat > ln_can_send + if probably_too_low_outbound_liquidity: + msg.extend([ + "", + "Please also note:", + too_low_outbound_liquidity_msg, + ]) + return "\n".join(msg) + + # if we have lightning_send_amount_sat our provider has enough liquidity, so we know the exact + # amount we need to send including the providers fees + too_low_outbound_liquidity = self.lightning_send_amount_sat > ln_can_send + if too_low_outbound_liquidity: + return too_low_outbound_liquidity_msg + + return None + + def set_swap_tab_warning(self, warning: str): + msg = _('Submarine Payment not possible:') + '\n' + warning + self.submarine_warning_label.setText(msg) + self.submarine_stacked_widget.setCurrentIndex(1) + self.submarine_ok_button.setEnabled(False) + + @qt_event_listener + def on_event_swap_offers_changed(self, _): + if self.ongoing_swap_transport_connection_attempt \ + and not self.ongoing_swap_transport_connection_attempt.done(): + return + if not self.submarine_payment_tab.isVisible(): + return + self.update_submarine_tab() + class ConfirmTxDialog(TxEditor): help_text = '' #_('Set the mining fee of your transaction') - def __init__(self, *, window: 'ElectrumWindow', make_tx, output_value: Union[int, str], allow_preview=True, batching_candidates=None): + def __init__( + self, *, + window: 'ElectrumWindow', + make_tx, + output_value: Union[int, str], + payee_outputs: Optional[list[TxOutput]] = None, + allow_preview=True, + batching_candidates=None, + ): TxEditor.__init__( self, window=window, make_tx=make_tx, output_value=output_value, + payee_outputs=payee_outputs, title=_("New Transaction"), # todo: adapt title for channel funding tx, swaps allow_preview=allow_preview, # false for channel funding batching_candidates=batching_candidates, diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index add360113..ff860f323 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -62,7 +62,7 @@ from electrum.util import (format_time, UserCancelled, profiler, bfh, InvalidPas from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME from electrum.payment_identifier import PaymentIdentifier from electrum.invoices import PR_PAID, Invoice -from electrum.transaction import (Transaction, PartialTxInput, +from electrum.transaction import (Transaction, PartialTxInput, TxOutput, PartialTransaction, PartialTxOutput) from electrum.wallet import (Multisig_Wallet, Abstract_Wallet, sweep_preparations, InternalAddressCorruption, @@ -1504,14 +1504,28 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): return # we need to know the fee before we broadcast, because the txid is required make_tx = self.mktx_for_open_channel(funding_sat=funding_sat, node_id=node_id) - funding_tx, _ = self.confirm_tx_dialog(make_tx, funding_sat, allow_preview=False) + funding_tx, _, _ = self.confirm_tx_dialog(make_tx, funding_sat, allow_preview=False) if not funding_tx: return self._open_channel(connect_str, funding_sat, push_amt, funding_tx) - def confirm_tx_dialog(self, make_tx, output_value, *, allow_preview=True, batching_candidates=None) -> tuple[Optional[PartialTransaction], bool]: - d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=output_value, allow_preview=allow_preview, batching_candidates=batching_candidates) - return d.run(), d.is_preview + def confirm_tx_dialog( + self, + make_tx, + output_value, *, + payee_outputs: Optional[list[TxOutput]] = None, + allow_preview=True, + batching_candidates=None, + ) -> tuple[Optional[PartialTransaction], bool, bool]: + d = ConfirmTxDialog( + window=self, + make_tx=make_tx, + output_value=output_value, + payee_outputs=payee_outputs, + allow_preview=allow_preview, + batching_candidates=batching_candidates, + ) + return d.run(), d.is_preview, d.did_swap @protected def _open_channel(self, connect_str, funding_sat, push_amt, funding_tx, password): diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 96264c3ae..6c927b11d 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -338,9 +338,16 @@ class SendTab(QWidget, MessageBoxMixin, Logger): coins_conservative = get_coins(nonlocal_only=True, confirmed_only=True) candidates = self.wallet.get_candidates_for_batching(outputs, coins=coins_conservative) - tx, is_preview = self.window.confirm_tx_dialog(make_tx, output_value, batching_candidates=candidates) + tx, is_preview, paid_with_swap = self.window.confirm_tx_dialog( + make_tx, + output_value, + payee_outputs=[o for o in outputs if not o.is_change], + batching_candidates=candidates, + ) if tx is None: - # user cancelled + if paid_with_swap: + self.do_clear() + # user cancelled or paid with swap return if swap_dummy_output := tx.get_dummy_output(DummyAddress.SWAP): diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py index 73a003c04..859da784d 100644 --- a/electrum/gui/qt/swap_dialog.py +++ b/electrum/gui/qt/swap_dialog.py @@ -301,7 +301,7 @@ class SwapDialog(WindowModalDialog, QtEventListener): self.needs_tx_update = True # update icon pubkey = from_nip19(self.config.SWAPSERVER_NPUB)['object'].hex() if self.config.SWAPSERVER_NPUB else '' - self.server_button.setIcon(SwapServerDialog._pubkey_to_q_icon(pubkey)) + self.server_button.setIcon(SwapServerDialog.pubkey_to_q_icon(pubkey)) def get_client_swap_limits_sat(self) -> Tuple[int, int]: """Returns the (min, max) client swap limits in sat.""" @@ -531,12 +531,12 @@ class SwapServerDialog(WindowModalDialog, QtEventListener): labels[self.Columns.LAST_SEEN] = age(x.timestamp) item = QTreeWidgetItem(labels) item.setData(self.Columns.PUBKEY, ROLE_NPUB, x.server_npub) - item.setIcon(self.Columns.PUBKEY, self._pubkey_to_q_icon(x.server_pubkey)) + item.setIcon(self.Columns.PUBKEY, self.pubkey_to_q_icon(x.server_pubkey)) items.append(item) self.servers_list.insertTopLevelItems(0, items) @staticmethod - def _pubkey_to_q_icon(server_pubkey: str) -> QIcon: + def pubkey_to_q_icon(server_pubkey: str) -> QIcon: color = QColor(*pubkey_to_rgb_color(server_pubkey)) color_pixmap = QPixmap(100, 100) color_pixmap.fill(color) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 007c687fc..aad0db5cd 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -1522,6 +1522,21 @@ def set_windows_os_screenshot_protection_drm_flag(window: QWidget) -> None: except Exception: _logger.exception(f"failed to set windows screenshot protection flag") + +def debug_widget_layouts(gui_element: QObject): + """Draw red borders around all widgets of given QObject for debugging. + E.g. add util.debug_widget_layouts(self) at the end of TxEditor.__init__ + """ + assert isinstance(gui_element, QObject) and hasattr(gui_element, 'findChildren') + def set_border(widget): + if widget is not None: + widget.setStyleSheet(widget.styleSheet() + " * { border: 1px solid red; }") + + # Apply to all child widgets recursively + for widget in gui_element.findChildren(QWidget): + set_border(widget) + + class _ABCQObjectMeta(type(QObject), ABCMeta): pass class _ABCQWidgetMeta(type(QWidget), ABCMeta): pass class AbstractQObject(QObject, ABC, metaclass=_ABCQObjectMeta): pass diff --git a/electrum/simple_config.py b/electrum/simple_config.py index b545d899b..93621c61d 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -688,6 +688,13 @@ class SimpleConfig(Logger): short_desc=lambda: _('Send change to Lightning'), long_desc=lambda: _('If possible, send the change of this transaction to your channels, with a submarine swap'), ) + WALLET_ENABLE_SUBMARINE_PAYMENTS = ConfigVar( + 'enable_submarine_payments', default=False, type_=bool, + short_desc=lambda: _('Submarine Payments'), + long_desc=lambda: _('Send onchain payments directly from your Lightning balance with a ' + 'submarine swap. This allows you to do onchain transactions even if your entire ' + 'wallet balance is inside Lightning channels.') + ) WALLET_FREEZE_REUSED_ADDRESS_UTXOS = ConfigVar( 'wallet_freeze_reused_address_utxos', default=False, type_=bool, short_desc=lambda: _('Avoid spending from used addresses'), diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 6fb5229a4..3705aa572 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -548,7 +548,7 @@ class SwapManager(Logger): """ Construct claim tx that spends exactly the funding utxo to the swap output, independent of the current fee environment to guarantee the correct amount is being sent to the claim output which - might be an external address and to keep the claim transaction uncorrelated to the wallets utxos. + might be an external address. """ assert swap.claim_to_output, swap txout = PartialTxOutput.from_address_and_value(swap.claim_to_output[0], swap.claim_to_output[1]) @@ -1720,6 +1720,7 @@ class NostrTransport(SwapServerTransport): async def stop(self): self.logger.info("shutting down nostr transport") self.sm.is_initialized.clear() + self.is_connected.clear() await self.taskgroup.cancel_remaining() await self.relay_manager.close() self.logger.info("nostr transport shut down")