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:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user