diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index d6047eb24..3935db7fc 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -23,50 +23,62 @@ # 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, + pubkey_to_q_icon) 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 +97,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 +199,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 +296,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 +403,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 +502,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 +534,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 +686,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(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..2a612e92d 100644 --- a/electrum/gui/qt/swap_dialog.py +++ b/electrum/gui/qt/swap_dialog.py @@ -13,12 +13,13 @@ from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, UserCancelled from electrum.bitcoin import DummyAddress from electrum.transaction import PartialTxOutput, PartialTransaction from electrum.fee_policy import FeePolicy -from electrum.submarine_swaps import NostrTransport, pubkey_to_rgb_color +from electrum.submarine_swaps import NostrTransport from electrum.gui import messages from . import util from .util import (WindowModalDialog, Buttons, OkButton, CancelButton, - EnterButton, ColorScheme, WWLabel, read_QIcon, IconLabel, char_width_in_lineedit) + EnterButton, ColorScheme, WWLabel, read_QIcon, IconLabel, char_width_in_lineedit, + pubkey_to_q_icon) from .util import qt_event_listener, QtEventListener from .amountedit import BTCAmountEdit from .fee_slider import FeeSlider, FeeComboBox @@ -301,7 +302,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(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,13 +532,7 @@ 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, 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: - color = QColor(*pubkey_to_rgb_color(server_pubkey)) - color_pixmap = QPixmap(100, 100) - color_pixmap.fill(color) - return QIcon(color_pixmap) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 007c687fc..3f11a609e 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -26,6 +26,7 @@ from electrum.util import (FileImportFailed, FileExportFailed, resource_path, Ev from electrum.invoices import (PR_UNPAID, PR_PAID, PR_EXPIRED, PR_INFLIGHT, PR_UNKNOWN, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED, PR_BROADCASTING, PR_BROADCAST) from electrum.qrreader import MissingQrDetectionLib, QrCodeResult +from electrum.submarine_swaps import pubkey_to_rgb_color from electrum.gui.common_qt.util import TaskThread @@ -726,6 +727,13 @@ def get_icon_camera() -> QIcon: return read_QIcon(name) +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) + return QIcon(color_pixmap) + + def add_input_actions_to_context_menu(gih: 'GenericInputHandler', m: QMenu) -> None: if gih.on_qr_from_camera_input_btn: m.addAction(get_icon_camera(), _("Read QR code with camera"), gih.on_qr_from_camera_input_btn) @@ -1522,6 +1530,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 dfc5ebfe9..3705aa572 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -24,11 +24,12 @@ from collections import defaultdict from .i18n import _ from .logging import Logger from .crypto import sha256, ripemd -from .bitcoin import script_to_p2wsh, opcodes, dust_threshold, DummyAddress, construct_witness, construct_script +from .bitcoin import (script_to_p2wsh, opcodes, dust_threshold, DummyAddress, construct_witness, + construct_script, address_to_script) from . import bitcoin from .transaction import ( PartialTxInput, PartialTxOutput, PartialTransaction, Transaction, TxInput, TxOutpoint, script_GetOp, - match_script_against_template, OPPushDataGeneric, OPPushDataPubkey + match_script_against_template, OPPushDataGeneric, OPPushDataPubkey, TxOutput, ) from .util import ( log_exceptions, ignore_exceptions, BelowDustLimit, OldTaskGroup, ca_path, gen_nostr_ann_pow, @@ -199,7 +200,7 @@ class SwapData(StoredObject): prepay_hash = attr.ib(type=Optional[bytes], converter=hex_to_bytes) privkey = attr.ib(type=bytes, converter=hex_to_bytes) lockup_address = attr.ib(type=str) - receive_address = attr.ib(type=str) + claim_to_output = attr.ib(type=Optional[Tuple[str, int]]) # address, amount to claim the funding utxo to funding_txid = attr.ib(type=Optional[str]) spending_txid = attr.ib(type=Optional[str]) is_redeemed = attr.ib(type=bool) @@ -520,6 +521,9 @@ class SwapManager(Logger): if spent_height is not None and spent_height > 0: return txin, locktime = self.create_claim_txin(txin=txin, swap=swap) + if swap.is_reverse and swap.claim_to_output: + asyncio.create_task(self._claim_to_output(swap, txin)) + return # note: there is no csv in the script, we just set this so that txbatcher waits for one confirmation name = 'swap claim' if swap.is_reverse else 'swap refund' can_be_batched = True @@ -540,6 +544,42 @@ class SwapManager(Logger): self.logger.info('got NoDynamicFeeEstimates') return + async def _claim_to_output(self, swap: SwapData, claim_txin: PartialTxInput): + """ + 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. + """ + assert swap.claim_to_output, swap + txout = PartialTxOutput.from_address_and_value(swap.claim_to_output[0], swap.claim_to_output[1]) + tx = PartialTransaction.from_io([claim_txin], [txout]) + can_be_broadcast = self.wallet.adb.get_tx_height(swap.funding_txid).height() > 0 + already_broadcast = self.wallet.adb.get_tx_height(tx.txid()).height() >= 0 + self.logger.debug(f"_claim_to_output: {can_be_broadcast=} {already_broadcast=}") + + # add tx to db so it can be shown as future tx + if not self.wallet.adb.get_transaction(tx.txid()): + try: + self.wallet.adb.add_transaction(tx) + except Exception: + self.logger.exception("") + return + trigger_callback('wallet_updated', self) + + # set or update future tx wanted height if it has not been broadcast yet + local_height = self.network.get_local_height() + wanted_height = local_height + claim_txin.get_block_based_relative_locktime() + if not already_broadcast and self.wallet.adb.future_tx.get(tx.txid(), 0) < wanted_height: + self.wallet.adb.set_future_tx(tx.txid(), wanted_height=wanted_height) + + if can_be_broadcast and not already_broadcast: + tx = self.wallet.sign_transaction(tx, password=None, ignore_warnings=True) + assert tx and tx.is_complete(), tx + try: + await self.wallet.network.broadcast_transaction(tx) + except Exception: + self.logger.exception(f"cannot broadcast swap to output claim tx") + def get_fee_for_txbatcher(self): return self._get_tx_fee(self.config.FEE_POLICY_SWAPS) @@ -687,7 +727,6 @@ class SwapManager(Logger): prepay_hash = None lockup_address = script_to_p2wsh(redeem_script) - receive_address = self.wallet.get_receiving_address() swap = SwapData( redeem_script=redeem_script, locktime=locktime, @@ -696,7 +735,7 @@ class SwapManager(Logger): prepay_hash=prepay_hash, lockup_address=lockup_address, onchain_amount=onchain_amount_sat, - receive_address=receive_address, + claim_to_output=None, lightning_amount=lightning_amount_sat, is_reverse=False, is_redeemed=False, @@ -749,12 +788,17 @@ class SwapManager(Logger): preimage: bytes, payment_hash: bytes, prepay_hash: Optional[bytes] = None, + claim_to_output: Optional[TxOutput] = None, ) -> SwapData: if payment_hash.hex() in self._swaps: raise Exception("payment_hash already in use") assert sha256(preimage) == payment_hash lockup_address = script_to_p2wsh(redeem_script) - receive_address = self.wallet.get_receiving_address() + if claim_to_output is not None: + # the claim_to_output value needs to be lower than the funding utxo value, otherwise + # there are no funds left for the fee of the claim tx + assert claim_to_output.value < onchain_amount_sat, f"{claim_to_output=} >= {onchain_amount_sat=}" + claim_to_output = (claim_to_output.address, claim_to_output.value) swap = SwapData( redeem_script=redeem_script, locktime=locktime, @@ -763,7 +807,7 @@ class SwapManager(Logger): prepay_hash=prepay_hash, lockup_address=lockup_address, onchain_amount=onchain_amount_sat, - receive_address=receive_address, + claim_to_output=claim_to_output, lightning_amount=lightning_amount_sat, is_reverse=True, is_redeemed=False, @@ -1014,6 +1058,7 @@ class SwapManager(Logger): expected_onchain_amount_sat: int, prepayment_sat: int, channels: Optional[Sequence['Channel']] = None, + claim_to_output: Optional[TxOutput] = None, ) -> Optional[str]: """send on Lightning, receive on-chain @@ -1116,7 +1161,9 @@ class SwapManager(Logger): payment_hash=payment_hash, prepay_hash=prepay_hash, onchain_amount_sat=onchain_amount, - lightning_amount_sat=lightning_amount_sat) + lightning_amount_sat=lightning_amount_sat, + claim_to_output=claim_to_output, + ) # initiate fee payment. if fee_invoice: fee_invoice_obj = Invoice.from_bech32(fee_invoice) @@ -1124,7 +1171,7 @@ class SwapManager(Logger): # we return if we detect funding async def wait_for_funding(swap): while swap.funding_txid is None: - await asyncio.sleep(1) + await asyncio.sleep(0.1) # initiate main payment invoice_obj = Invoice.from_bech32(invoice) tasks = [asyncio.create_task(self.lnworker.pay_invoice(invoice_obj, channels=channels)), asyncio.create_task(wait_for_funding(swap))] @@ -1421,23 +1468,16 @@ class SwapManager(Logger): def get_groups_for_onchain_history(self): current_height = self.wallet.adb.get_local_height() d = {} - # add info about submarine swaps - settled_payments = self.lnworker.get_payments(status='settled') with self.swaps_lock: swaps_items = list(self._swaps.items()) for payment_hash_hex, swap in swaps_items: txid = swap.spending_txid if swap.is_reverse else swap.funding_txid if txid is None: continue - payment_hash = bytes.fromhex(payment_hash_hex) - if payment_hash in settled_payments: - plist = settled_payments[payment_hash] - info = self.lnworker.get_payment_info(payment_hash) - direction, amount_msat, fee_msat, timestamp = self.lnworker.get_payment_value(info, plist) - else: - amount_msat = 0 - if swap.is_reverse: + if swap.is_reverse and swap.claim_to_output: + group_label = 'Submarine Payment' + ' ' + self.config.format_amount_and_units(swap.claim_to_output[1]) + elif swap.is_reverse: group_label = 'Reverse swap' + ' ' + self.config.format_amount_and_units(swap.lightning_amount) else: group_label = 'Forward swap' + ' ' + self.config.format_amount_and_units(swap.onchain_amount) @@ -1466,6 +1506,27 @@ class SwapManager(Logger): 'label': _('Refund transaction'), } self.wallet._accounting_addresses.add(swap.lockup_address) + elif swap.is_reverse and swap.claim_to_output: # submarine payment + claim_tx = self.lnwatcher.adb.get_transaction(swap.spending_txid) + payee_spk = address_to_script(swap.claim_to_output[0]) + if claim_tx and payee_spk not in (o.scriptpubkey for o in claim_tx.outputs()): + # the swapserver must have refunded itself as the claim_tx did not spend + # to the address we intended it to spend to, remove the funding + # address again from accounting addresses so the refund tx is not incorrectly + # shown in the wallet history as tx spending from this wallet + self.wallet._accounting_addresses.discard(swap.lockup_address) + # add the funding tx to the group as the total amount of the group would + # otherwise be ~2x the actual payment as the claim tx gets counted as negative + # value (as it sends from the wallet/accounting address balance) + d[swap.funding_txid] = { + 'group_id': txid, + 'label': _('Funding transaction'), + 'group_label': group_label, + } + # add the lockup_address as the claim tx would otherwise not touch the wallet and + # wouldn't be shown in the history. + self.wallet._accounting_addresses.add(swap.lockup_address) + return d def get_group_id_for_payment_hash(self, payment_hash: bytes) -> Optional[str]: @@ -1659,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") diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py index 586afb42e..09cf9d948 100644 --- a/electrum/wallet_db.py +++ b/electrum/wallet_db.py @@ -73,7 +73,7 @@ class WalletUnfinished(WalletFileException): # seed_version is now used for the version of the wallet file OLD_SEED_VERSION = 4 # electrum versions < 2.0 NEW_SEED_VERSION = 11 # electrum versions >= 2.0 -FINAL_SEED_VERSION = 61 # electrum >= 2.7 will set this to prevent +FINAL_SEED_VERSION = 62 # electrum >= 2.7 will set this to prevent # old versions from overwriting new format @@ -237,6 +237,7 @@ class WalletDBUpgrader(Logger): self._convert_version_59() self._convert_version_60() self._convert_version_61() + self._convert_version_62() self.put('seed_version', FINAL_SEED_VERSION) # just to be sure def _convert_wallet_type(self): @@ -1170,6 +1171,17 @@ class WalletDBUpgrader(Logger): lightning_payments[rhash] = new self.data['seed_version'] = 61 + def _convert_version_62(self): + if not self._is_upgrade_method_needed(61, 61): + return + swaps = self.data.get('submarine_swaps', {}) + # remove unused receive_address field which is getting replaced by a claim_to_output field + # which also allows specifying an amount + for swap in swaps.values(): + del swap['receive_address'] + swap['claim_to_output'] = None + self.data['seed_version'] = 62 + def _convert_imported(self): if not self._is_upgrade_method_needed(0, 13): return diff --git a/tests/test_txbatcher.py b/tests/test_txbatcher.py index f038bb7e5..86dd5fbbf 100644 --- a/tests/test_txbatcher.py +++ b/tests/test_txbatcher.py @@ -69,7 +69,7 @@ SWAPDATA = SwapData( prepay_hash=None, privkey=bytes.fromhex('58fd0018a9a2737d1d6b81d380df96bf0c858473a9592015508a270a7c9b1d8d'), lockup_address='tb1q2pvugjl4w56rqw4c7zg0q6mmmev0t5jjy3qzg7sl766phh9fxjxsrtl77t', - receive_address='tb1ql0adrj58g88xgz375yct63rclhv29hv03u0mel', + claim_to_output=None, funding_txid='897eea7f53e917323e7472d7a2e3099173f7836c57f1b6850f5cbdfe8085dbf9', spending_txid=None, is_redeemed=False,