1
0

Merge pull request #10303 from f321x/submarine_payment_func

swaps/qt: expose swaps to external address
This commit is contained in:
ThomasV
2025-11-27 11:39:09 +01:00
committed by GitHub
9 changed files with 605 additions and 66 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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