1
0

Swaps over Nostr

- Separation between SwapManager and its transport:
   Legacy transpport uses http, Nostr uses websockets
 - The transport uses a context to open/close connections.
   This context is not async, because it needs to be called
   from the GUI
 - Swapserver fees values are initialized to None instead
   of 0, so that any attempt to use them before the swap
   manager is initialized will raise an exception.
 - Remove swapserver fees disk caching (swap_pairs file)
 - Regtests use http transport
 - Android uses http transport (until QML is ready)
This commit is contained in:
ThomasV
2024-10-10 12:30:27 +02:00
parent 7fdf1e0669
commit 60f13a977e
15 changed files with 549 additions and 211 deletions

View File

@@ -1160,19 +1160,98 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
if not self.wallet.lnworker.num_sats_can_send() and not self.wallet.lnworker.num_sats_can_receive():
self.show_error(_("You do not have liquidity in your active channels."))
return
try:
self.run_coroutine_dialog(
self.wallet.lnworker.swap_manager.get_pairs(), _('Please wait...'))
except SwapServerError as e:
self.show_error(str(e))
return
d = SwapDialog(self, is_reverse=is_reverse, recv_amount_sat=recv_amount_sat, channels=channels)
try:
return d.run()
except InvalidSwapParameters as e:
self.show_error(str(e))
transport = self.create_sm_transport()
if not transport:
return
with transport:
if not self.initialize_swap_manager(transport):
return
d = SwapDialog(self, transport, is_reverse=is_reverse, recv_amount_sat=recv_amount_sat, channels=channels)
try:
return d.run(transport)
except InvalidSwapParameters as e:
self.show_error(str(e))
return
def create_sm_transport(self):
sm = self.wallet.lnworker.swap_manager
if sm.is_server:
self.show_error(_('Swap server is active'))
return False
if self.network is None:
return False
if not self.config.SWAPSERVER_URL and not self.config.SWAPSERVER_NPUB:
if not self.question('\n'.join([
_('Electrum uses Nostr in order to find liquidity providers.'),
_('Do you want to enable Nostr?'),
])):
return False
return sm.create_transport()
def initialize_swap_manager(self, transport):
sm = self.wallet.lnworker.swap_manager
if not sm.is_initialized.is_set():
async def wait_until_initialized():
try:
await asyncio.wait_for(sm.is_initialized.wait(), timeout=5)
except asyncio.TimeoutError:
return
try:
self.run_coroutine_dialog(wait_until_initialized(), _('Please wait...'))
except Exception as e:
self.show_error(str(e))
return False
if not self.config.SWAPSERVER_URL and not sm.is_initialized.is_set():
if not self.choose_swapserver_dialog(transport):
return False
assert sm.is_initialized.is_set()
return True
def choose_swapserver_dialog(self, transport):
if not transport.is_connected.is_set():
self.show_message(
'\n'.join([
_('Could not connect to a Nostr relay.'),
_('Please check your relays and network connection'),
]))
return False
now = int(time.time())
recent_offers = [x for x in transport.offers.values() if now - x['timestamp'] < 3600]
if not recent_offers:
self.show_message(
'\n'.join([
_('Could not find a swap provider.'),
]))
return False
sm = self.wallet.lnworker.swap_manager
def descr(x):
last_seen = util.age(x['timestamp'])
return f"pubkey={x['pubkey'][0:10]}, fee={x['percentage_fee']}% + {x['reverse_mining_fee']} sats"
server_keys = [(x['pubkey'], descr(x)) for x in recent_offers]
msg = '\n'.join([
_("Please choose a server from this list."),
_("Note that fees may be updated frequently.")
])
choice = self.query_choice(
msg = msg,
choices = server_keys,
title = _("Choose Swap Server"),
default_choice = self.config.SWAPSERVER_NPUB
)
if choice not in transport.offers:
return False
self.config.SWAPSERVER_NPUB = choice
pairs = transport.get_offer(choice)
sm.update_pairs(pairs)
return True
@qt_event_listener
def on_event_request_status(self, wallet, key, status):
if wallet != self.wallet:
@@ -1309,12 +1388,22 @@ 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)
d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=funding_sat, allow_preview=False)
funding_tx = d.run()
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):
d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=output_value, allow_preview=allow_preview)
if d.not_enough_funds:
# note: use confirmed_only=False here, regardless of config setting,
# as the user needs to get to ConfirmTxDialog to change the config setting
if not d.can_pay_assuming_zero_fees(confirmed_only=False):
text = self.send_tab.get_text_not_enough_funds_mentioning_frozen()
self.show_message(text)
return
return d.run(), d.is_preview
@protected
def _open_channel(self, connect_str, funding_sat, push_amt, funding_tx, password):
# read funding_sat from tx; converts '!' to int value

View File

@@ -21,7 +21,7 @@ if TYPE_CHECKING:
from .main_window import ElectrumWindow
from .confirm_tx_dialog import ConfirmTxDialog, TxEditor, TxSizeLabel, HelpLabel
from .confirm_tx_dialog import TxEditor, TxSizeLabel, HelpLabel
class _BaseRBFDialog(TxEditor):

View File

@@ -14,7 +14,7 @@ from electrum.i18n import _
from electrum.logging import Logger
from electrum.bitcoin import DummyAddress
from electrum.plugin import run_hook
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, parse_max_spend
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, parse_max_spend, UserCancelled
from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST
from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput
from electrum.network import TxBroadcastError, BestEffortRequestFailed
@@ -26,7 +26,6 @@ from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
from .paytoedit import InvalidPaymentIdentifier
from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit,
get_iconname_camera, read_QIcon, ColorScheme, icon_path)
from .confirm_tx_dialog import ConfirmTxDialog
from .invoice_list import InvoiceList
if TYPE_CHECKING:
@@ -321,31 +320,26 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
output_values = [x.value for x in outputs]
is_max = any(parse_max_spend(outval) for outval in output_values)
output_value = '!' if is_max else sum(output_values)
conf_dlg = ConfirmTxDialog(window=self.window, make_tx=make_tx, output_value=output_value)
if conf_dlg.not_enough_funds:
# note: use confirmed_only=False here, regardless of config setting,
# as the user needs to get to ConfirmTxDialog to change the config setting
if not conf_dlg.can_pay_assuming_zero_fees(confirmed_only=False):
text = self.get_text_not_enough_funds_mentioning_frozen()
self.show_message(text)
return
tx = conf_dlg.run()
tx, is_preview = self.window.confirm_tx_dialog(make_tx, output_value)
if tx is None:
# user cancelled
return
is_preview = conf_dlg.is_preview
if tx.has_dummy_output(DummyAddress.SWAP):
sm = self.wallet.lnworker.swap_manager
coro = sm.request_swap_for_tx(tx)
try:
swap, invoice, tx = self.network.run_from_another_thread(coro)
except SwapServerError as e:
self.show_error(str(e))
return
assert not tx.has_dummy_output(DummyAddress.SWAP)
tx.swap_invoice = invoice
tx.swap_payment_hash = swap.payment_hash
with self.window.create_sm_transport() as transport:
if not self.window.initialize_swap_manager(transport):
return
coro = sm.request_swap_for_tx(transport, tx)
try:
swap, invoice, tx = self.window.run_coroutine_dialog(coro, _('Requesting swap invoice...'))
except SwapServerError as e:
self.show_error(str(e))
return
assert not tx.has_dummy_output(DummyAddress.SWAP)
tx.swap_invoice = invoice
tx.swap_payment_hash = swap.payment_hash
if is_preview:
self.window.show_transaction(tx, external_keypairs=external_keypairs, payment_identifier=payment_identifier)
@@ -744,12 +738,14 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
if hasattr(tx, 'swap_payment_hash'):
sm = self.wallet.lnworker.swap_manager
swap = sm.get_swap(tx.swap_payment_hash)
coro = sm.wait_for_htlcs_and_broadcast(swap=swap, invoice=tx.swap_invoice, tx=tx)
self.window.run_coroutine_dialog(
coro, _('Awaiting swap payment...'),
on_result=lambda funding_txid: self.window.on_swap_result(funding_txid, is_reverse=False),
on_cancelled=lambda: sm.cancel_normal_swap(swap))
return
with sm.create_transport() as transport:
coro = sm.wait_for_htlcs_and_broadcast(transport, swap=swap, invoice=tx.swap_invoice, tx=tx)
try:
funding_txid = self.window.run_coroutine_dialog(coro, _('Awaiting lightning payment...'))
except UserCancelled:
sm.cancel_normal_swap(swap)
return
self.window.on_swap_result(funding_txid, is_reverse=False)
def broadcast_thread():
# non-GUI thread

View File

@@ -33,7 +33,7 @@ class InvalidSwapParameters(Exception): pass
class SwapDialog(WindowModalDialog, QtEventListener):
def __init__(self, window: 'ElectrumWindow', is_reverse=None, recv_amount_sat=None, channels=None):
def __init__(self, window: 'ElectrumWindow', transport, is_reverse=None, recv_amount_sat=None, channels=None):
WindowModalDialog.__init__(self, window, _('Submarine Swap'))
self.window = window
self.config = window.config
@@ -47,6 +47,7 @@ class SwapDialog(WindowModalDialog, QtEventListener):
menu.addConfig(
self.config.cv.LIGHTNING_ALLOW_INSTANT_SWAPS,
).setEnabled(self.lnworker.can_have_recoverable_channels())
menu.addAction(_('Choose swap server'), lambda: self.window.choose_swapserver_dialog(transport))
vbox.addLayout(toolbar)
self.description_label = WWLabel(self.get_description())
self.send_amount_e = BTCAmountEdit(self.window.get_decimal_point)
@@ -242,7 +243,7 @@ class SwapDialog(WindowModalDialog, QtEventListener):
self.fee_label.setText(fee_text)
self.fee_label.repaint() # macOS hack for #6269
def run(self):
def run(self, transport):
"""Can raise InvalidSwapParameters."""
if not self.exec():
return
@@ -251,14 +252,15 @@ class SwapDialog(WindowModalDialog, QtEventListener):
onchain_amount = self.recv_amount_e.get_amount()
if lightning_amount is None or onchain_amount is None:
return
coro = self.swap_manager.reverse_swap(
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount + self.swap_manager.get_claim_fee(),
)
self.window.run_coroutine_from_thread(
coro, _('Swapping funds'),
on_result=lambda funding_txid: self.window.on_swap_result(funding_txid, is_reverse=True),
)
sm = self.swap_manager
coro = sm.reverse_swap(
transport,
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount + self.swap_manager.get_claim_fee(),
)
# we must not leave the context, so we use run_couroutine_dialog
funding_txid = self.window.run_coroutine_dialog(coro, _('Initiating swap...'))
self.window.on_swap_result(funding_txid, is_reverse=True)
return True
else:
lightning_amount = self.recv_amount_e.get_amount()
@@ -268,7 +270,7 @@ class SwapDialog(WindowModalDialog, QtEventListener):
if lightning_amount > self.lnworker.num_sats_can_receive():
if not self.window.question(CANNOT_RECEIVE_WARNING):
return
self.window.protect(self.do_normal_swap, (lightning_amount, onchain_amount))
self.window.protect(self.do_normal_swap, (transport, lightning_amount, onchain_amount))
return True
def update_tx(self) -> None:
@@ -319,23 +321,24 @@ class SwapDialog(WindowModalDialog, QtEventListener):
recv_amount = self.recv_amount_e.get_amount()
self.ok_button.setEnabled(bool(send_amount) and bool(recv_amount))
async def _do_normal_swap(self, lightning_amount, onchain_amount, password):
async def _do_normal_swap(self, transport, lightning_amount, onchain_amount, password):
dummy_tx = self._create_tx(onchain_amount)
assert dummy_tx
sm = self.swap_manager
swap, invoice = await sm.request_normal_swap(
transport=transport,
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount,
channels=self.channels,
)
self._current_swap = swap
tx = sm.create_funding_tx(swap, dummy_tx, password=password)
txid = await sm.wait_for_htlcs_and_broadcast(swap=swap, invoice=invoice, tx=tx)
txid = await sm.wait_for_htlcs_and_broadcast(transport=transport, swap=swap, invoice=invoice, tx=tx)
return txid
def do_normal_swap(self, lightning_amount, onchain_amount, password):
def do_normal_swap(self, transport, lightning_amount, onchain_amount, password):
self._current_swap = None
coro = self._do_normal_swap(lightning_amount, onchain_amount, password)
coro = self._do_normal_swap(transport, lightning_amount, onchain_amount, password)
try:
funding_txid = self.window.run_coroutine_dialog(coro, _('Awaiting swap payment...'))
except UserCancelled: