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