1
0

Merge pull request #10247 from f321x/improve_send_change_to_ln

qt: improve send_change_to_lightning feedback
This commit is contained in:
ThomasV
2026-01-22 10:31:05 +01:00
committed by GitHub
6 changed files with 276 additions and 185 deletions

View File

@@ -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.")

View File

@@ -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()

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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()