From 60f13a977ef2327165e1b0186521d35ca1c696ec Mon Sep 17 00:00:00 2001 From: ThomasV Date: Thu, 10 Oct 2024 12:30:27 +0200 Subject: [PATCH] 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) --- electrum/commands.py | 75 ++-- electrum/gui/qt/main_window.py | 115 +++++- electrum/gui/qt/rbf_dialog.py | 2 +- electrum/gui/qt/send_tab.py | 50 ++- electrum/gui/qt/swap_dialog.py | 33 +- electrum/lnutil.py | 6 + electrum/lnworker.py | 9 +- electrum/plugins/swapserver/server.py | 4 +- electrum/plugins/swapserver/swapserver.py | 12 +- electrum/simple_config.py | 16 +- electrum/submarine_swaps.py | 414 ++++++++++++++++++---- electrum/wallet.py | 3 +- run_electrum | 1 + tests/regtest.py | 4 + tests/test_simple_config.py | 16 - 15 files changed, 549 insertions(+), 211 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 6d713fb25..da6d709e3 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1320,24 +1320,26 @@ class Commands: Normal submarine swap: send on-chain BTC, receive on Lightning """ sm = wallet.lnworker.swap_manager - if lightning_amount == 'dryrun': - await sm.get_pairs() - onchain_amount_sat = satoshis(onchain_amount) - lightning_amount_sat = sm.get_recv_amount(onchain_amount_sat, is_reverse=False) - txid = None - elif onchain_amount == 'dryrun': - await sm.get_pairs() - lightning_amount_sat = satoshis(lightning_amount) - onchain_amount_sat = sm.get_send_amount(lightning_amount_sat, is_reverse=False) - txid = None - else: - lightning_amount_sat = satoshis(lightning_amount) - onchain_amount_sat = satoshis(onchain_amount) - txid = await wallet.lnworker.swap_manager.normal_swap( - lightning_amount_sat=lightning_amount_sat, - expected_onchain_amount_sat=onchain_amount_sat, - password=password, - ) + with sm.create_transport() as transport: + await sm.is_initialized.wait() + if lightning_amount == 'dryrun': + onchain_amount_sat = satoshis(onchain_amount) + lightning_amount_sat = sm.get_recv_amount(onchain_amount_sat, is_reverse=False) + txid = None + elif onchain_amount == 'dryrun': + lightning_amount_sat = satoshis(lightning_amount) + onchain_amount_sat = sm.get_send_amount(lightning_amount_sat, is_reverse=False) + txid = None + else: + lightning_amount_sat = satoshis(lightning_amount) + onchain_amount_sat = satoshis(onchain_amount) + txid = await wallet.lnworker.swap_manager.normal_swap( + transport, + lightning_amount_sat=lightning_amount_sat, + expected_onchain_amount_sat=onchain_amount_sat, + password=password, + ) + return { 'txid': txid, 'lightning_amount': format_satoshis(lightning_amount_sat), @@ -1349,24 +1351,25 @@ class Commands: """Reverse submarine swap: send on Lightning, receive on-chain """ sm = wallet.lnworker.swap_manager - if onchain_amount == 'dryrun': - await sm.get_pairs() - lightning_amount_sat = satoshis(lightning_amount) - onchain_amount_sat = sm.get_recv_amount(lightning_amount_sat, is_reverse=True) - funding_txid = None - elif lightning_amount == 'dryrun': - await sm.get_pairs() - onchain_amount_sat = satoshis(onchain_amount) - lightning_amount_sat = sm.get_send_amount(onchain_amount_sat, is_reverse=True) - funding_txid = None - else: - lightning_amount_sat = satoshis(lightning_amount) - claim_fee = sm.get_claim_fee() - onchain_amount_sat = satoshis(onchain_amount) + claim_fee - funding_txid = await wallet.lnworker.swap_manager.reverse_swap( - lightning_amount_sat=lightning_amount_sat, - expected_onchain_amount_sat=onchain_amount_sat, - ) + with sm.create_transport() as transport: + await sm.is_initialized.wait() + if onchain_amount == 'dryrun': + lightning_amount_sat = satoshis(lightning_amount) + onchain_amount_sat = sm.get_recv_amount(lightning_amount_sat, is_reverse=True) + funding_txid = None + elif lightning_amount == 'dryrun': + onchain_amount_sat = satoshis(onchain_amount) + lightning_amount_sat = sm.get_send_amount(onchain_amount_sat, is_reverse=True) + funding_txid = None + else: + lightning_amount_sat = satoshis(lightning_amount) + claim_fee = sm.get_claim_fee() + onchain_amount_sat = satoshis(onchain_amount) + claim_fee + funding_txid = await wallet.lnworker.swap_manager.reverse_swap( + transport, + lightning_amount_sat=lightning_amount_sat, + expected_onchain_amount_sat=onchain_amount_sat, + ) return { 'funding_txid': funding_txid, 'lightning_amount': format_satoshis(lightning_amount_sat), diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 71ab1a6c7..5541138de 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -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 diff --git a/electrum/gui/qt/rbf_dialog.py b/electrum/gui/qt/rbf_dialog.py index 9517165e6..5295d7661 100644 --- a/electrum/gui/qt/rbf_dialog.py +++ b/electrum/gui/qt/rbf_dialog.py @@ -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): diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 09f94eb05..edfe3d63d 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -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 diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py index 0cd91932f..b284e7fff 100644 --- a/electrum/gui/qt/swap_dialog.py +++ b/electrum/gui/qt/swap_dialog.py @@ -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: diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 480653484..f804e9d2b 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1520,6 +1520,7 @@ class LnKeyFamily(IntEnum): NODE_KEY = 6 BACKUP_CIPHER = 7 | BIP32_PRIME PAYMENT_SECRET_KEY = 8 | BIP32_PRIME + NOSTR_KEY = 9 | BIP32_PRIME def generate_keypair(node: BIP32Node, key_family: LnKeyFamily) -> Keypair: @@ -1528,6 +1529,11 @@ def generate_keypair(node: BIP32Node, key_family: LnKeyFamily) -> Keypair: cK = ecc.ECPrivkey(k).get_public_key_bytes() return Keypair(cK, k) +def generate_random_keypair() -> Keypair: + import secrets + k = secrets.token_bytes(32) + cK = ecc.ECPrivkey(k).get_public_key_bytes() + return Keypair(cK, k) NUM_MAX_HOPS_IN_PAYMENT_PATH = 20 diff --git a/electrum/lnworker.py b/electrum/lnworker.py index a6162360b..a6428745c 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -83,7 +83,7 @@ from .lnutil import ImportedChannelBackupStorage, OnchainChannelBackupStorage from .lnchannel import ChannelBackup from .channel_db import UpdateStatus, ChannelDBNotLoaded from .channel_db import get_mychannel_info, get_mychannel_policy -from .submarine_swaps import HttpSwapManager +from .submarine_swaps import SwapManager from .channel_db import ChannelInfo, Policy from .mpp_split import suggest_splits, SplitConfigRating from .trampoline import create_trampoline_route_and_onion, is_legacy_relay @@ -876,8 +876,9 @@ class LNWallet(LNWorker): # payment_hash -> callback: self.hold_invoice_callbacks = {} # type: Dict[bytes, Callable[[bytes], Awaitable[None]]] self.payment_bundles = [] # lists of hashes. todo:persist - self.swap_manager = HttpSwapManager(wallet=self.wallet, lnworker=self) + self.nostr_keypair = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.NOSTR_KEY) + self.swap_manager = SwapManager(wallet=self.wallet, lnworker=self) def has_deterministic_node_id(self) -> bool: return bool(self.db.get('lightning_xprv')) @@ -964,7 +965,7 @@ class LNWallet(LNWorker): def start_network(self, network: 'Network'): super().start_network(network) self.lnwatcher = LNWalletWatcher(self, network) - self.swap_manager.start_network(network=network, lnwatcher=self.lnwatcher) + self.swap_manager.start_network(network) self.lnrater = LNRater(self, network) for chan in self.channels.values(): @@ -994,7 +995,7 @@ class LNWallet(LNWorker): if self.lnwatcher: await self.lnwatcher.stop() self.lnwatcher = None - if self.swap_manager: # may not be present in tests + if self.swap_manager and self.swap_manager.network: # may not be present in tests await self.swap_manager.stop() async def wait_for_received_pending_htlcs_to_get_removed(self): diff --git a/electrum/plugins/swapserver/server.py b/electrum/plugins/swapserver/server.py index 21cb751ef..cd538ba9a 100644 --- a/electrum/plugins/swapserver/server.py +++ b/electrum/plugins/swapserver/server.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: from electrum.wallet import Abstract_Wallet -class SwapServer(Logger, EventListener): +class HttpSwapServer(Logger, EventListener): """ public API: - getpairs @@ -57,7 +57,7 @@ class SwapServer(Logger, EventListener): async def get_pairs(self, r): sm = self.sm - sm.init_pairs() + sm.server_update_pairs() pairs = { "info": [], "warnings": [], diff --git a/electrum/plugins/swapserver/swapserver.py b/electrum/plugins/swapserver/swapserver.py index 9a35e9714..c24c4e13e 100644 --- a/electrum/plugins/swapserver/swapserver.py +++ b/electrum/plugins/swapserver/swapserver.py @@ -29,7 +29,7 @@ from typing import TYPE_CHECKING from electrum.plugin import BasePlugin, hook -from .server import SwapServer +from .server import HttpSwapServer if TYPE_CHECKING: from electrum.simple_config import SimpleConfig @@ -49,12 +49,6 @@ class SwapServerPlugin(BasePlugin): # we use the first wallet loaded if self.server is not None: return - if self.config.NETWORK_OFFLINE: - return - - self.server = SwapServer(self.config, wallet) sm = wallet.lnworker.swap_manager - for coro in [ - self.server.run(), - ]: - asyncio.run_coroutine_threadsafe(daemon.taskgroup.spawn(coro), daemon.asyncio_loop) + sm.is_server = True + sm.http_server = HttpSwapServer(self.config, wallet) diff --git a/electrum/simple_config.py b/electrum/simple_config.py index 92645b66e..a17febcdf 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -922,15 +922,6 @@ class SimpleConfig(Logger): f"Either use config.cv.{name}.set() or assign to config.{name} instead.") return CVLookupHelper() - def _default_swapserver_url(self) -> str: - if constants.net == constants.BitcoinMainnet: - default = 'https://swaps.electrum.org/api' - elif constants.net == constants.BitcoinTestnet: - default = 'https://swaps.electrum.org/testnet' - else: - default = 'http://localhost:5455' - return default - # config variables -----> NETWORK_AUTO_CONNECT = ConfigVar('auto_connect', default=True, type_=bool) NETWORK_ONESERVER = ConfigVar('oneserver', default=False, type_=bool) @@ -1201,16 +1192,17 @@ Warning: setting this to too low will result in lots of payment failures."""), CONFIG_FORGET_CHANGES = ConfigVar('forget_config', default=False, type_=bool) # connect to remote submarine swap server - SWAPSERVER_URL = ConfigVar('swapserver_url', default=_default_swapserver_url, type_=str) + SWAPSERVER_URL = ConfigVar('swapserver_url', default='', type_=str) # run submarine swap server locally - SWAPSERVER_PORT = ConfigVar('swapserver_port', default=5455, type_=int) + SWAPSERVER_PORT = ConfigVar('swapserver_port', default=None, type_=int) SWAPSERVER_FEE_MILLIONTHS = ConfigVar('swapserver_fee_millionths', default=5000, type_=int) TEST_SWAPSERVER_REFUND = ConfigVar('test_swapserver_refund', default=False, type_=bool) + SWAPSERVER_NPUB = ConfigVar('swapserver_npub', default=None, type_=str) # nostr NOSTR_RELAYS = ConfigVar( 'nostr_relays', - default='wss://relay.damus.io,wss://brb.io,wss://nostr.mom', + default='wss://nos.lol,wss://relay.damus.io,wss://brb.io,wss://nostr.mom', type_=str, short_desc=lambda: _("Nostr relays"), long_desc=lambda: ' '.join([ diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 5730ddd8d..984272a41 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -8,15 +8,23 @@ import time import attr import aiohttp + +import electrum_ecc as ecc from electrum_ecc import ECPrivkey +import electrum_aionostr as aionostr +from electrum_aionostr.util import to_nip19 + +from collections import defaultdict + + from . import lnutil from .crypto import sha256, hash_160 from .bitcoin import (script_to_p2wsh, opcodes, construct_witness) from .transaction import PartialTxInput, PartialTxOutput, PartialTransaction, Transaction, TxInput, TxOutpoint from .transaction import script_GetOp, match_script_against_template, OPPushDataGeneric, OPPushDataPubkey -from .util import log_exceptions, BelowDustLimit, OldTaskGroup +from .util import log_exceptions, BelowDustLimit, OldTaskGroup, age from .lnutil import REDEEM_AFTER_DOUBLE_SPENT_DELAY from .bitcoin import dust_threshold, DummyAddress from .logging import Logger @@ -116,6 +124,15 @@ class SwapServerError(Exception): def now(): return int(time.time()) +@attr.s +class SwapFees: + percentage = attr.ib(type=int) + normal_fee = attr.ib(type=int) + lockup_fee = attr.ib(type=int) + claim_fee = attr.ib(type=int) + min_amount = attr.ib(type=int) + max_amount = attr.ib(type=int) + @stored_in('submarine_swaps') @attr.s class SwapData(StoredObject): @@ -166,17 +183,18 @@ class SwapManager(Logger): def __init__(self, *, wallet: 'Abstract_Wallet', lnworker: 'LNWallet'): Logger.__init__(self) - self.normal_fee = 0 - self.lockup_fee = 0 - self.claim_fee = 0 # part of the boltz prococol, not used by Electrum - self.percentage = 0 + self.normal_fee = None + self.lockup_fee = None + self.claim_fee = None # part of the boltz prococol, not used by Electrum + self.percentage = None self._min_amount = None self._max_amount = None self.wallet = wallet + self.config = wallet.config self.lnworker = lnworker self.config = wallet.config - self.taskgroup = None + self.taskgroup = OldTaskGroup() self.dummy_address = DummyAddress.SWAP self.swaps = self.wallet.db.get_dict('submarine_swaps') # type: Dict[str, SwapData] @@ -193,38 +211,59 @@ class SwapManager(Logger): for k, swap in self.swaps.items(): if swap.prepay_hash is not None: self.prepayments[swap.prepay_hash] = bytes.fromhex(k) - # api url - self.api_url = wallet.config.SWAPSERVER_URL - # init default min & max - self.init_min_max_values() + self.is_server = self.config.get('enable_plugin_swapserver', False) + self.is_initialized = asyncio.Event() - def start_network(self, *, network: 'Network', lnwatcher: 'LNWalletWatcher'): + def start_network(self, network: 'Network'): assert network - assert lnwatcher - assert self.network is None, "already started" + if self.network is not None: + self.logger.info('start_network: already started') + return + self.logger.info('start_network: starting main loop') self.network = network - self.lnwatcher = lnwatcher + self.lnwatcher = self.lnworker.lnwatcher for k, swap in self.swaps.items(): if swap.is_redeemed: continue self.add_lnwatcher_callback(swap) - - self.taskgroup = OldTaskGroup() asyncio.run_coroutine_threadsafe(self.main_loop(), self.network.asyncio_loop) + @log_exceptions + async def run_nostr_server(self): + with NostrTransport(self.config, self, self.lnworker.nostr_keypair) as transport: + await transport.is_connected.wait() + self.logger.info(f'nostr is connected') + while True: + # todo: publish everytime fees have changed + self.server_update_pairs() + await transport.publish_offer(self) + await asyncio.sleep(600) + + @log_exceptions async def main_loop(self): - self.logger.info("starting taskgroup.") - try: - async with self.taskgroup as group: - await group.spawn(self.pay_pending_invoices()) - except Exception as e: - self.logger.exception("taskgroup died.") - finally: - self.logger.info("taskgroup stopped.") + tasks = [self.pay_pending_invoices()] + if self.is_server: + # nostr and http are not mutually exclusive + if self.config.SWAPSERVER_PORT: + tasks.append(self.http_server.run()) + if self.config.NOSTR_RELAYS: + tasks.append(self.run_nostr_server()) + + async with self.taskgroup as group: + for task in tasks: + await group.spawn(task) async def stop(self): await self.taskgroup.cancel_remaining() + def create_transport(self): + from .lnutil import generate_random_keypair + if self.config.SWAPSERVER_URL: + return HttpTransport(self.config, self) + else: + keypair = self.lnworker.nostr_keypair if self.is_server else generate_random_keypair() + return NostrTransport(self.config, self, keypair) + async def pay_invoice(self, key): self.logger.info(f'trying to pay invoice {key}') self.invoices_to_pay[key] = 1000000000000 # lock @@ -605,9 +644,7 @@ class SwapManager(Logger): assert sha256(swap.preimage) == payment_hash assert swap.spending_txid is None self.invoices_to_pay[key] = 0 - - async def send_request_to_server(self, method, request_data): - raise NotImplementedError() + return {} async def normal_swap( self, @@ -648,21 +685,21 @@ class SwapManager(Logger): return await self.wait_for_htlcs_and_broadcast(swap=swap, invoice=invoice, tx=tx) async def request_normal_swap( - self, + self, transport, *, lightning_amount_sat: int, expected_onchain_amount_sat: int, channels: Optional[Sequence['Channel']] = None, ) -> Tuple[SwapData, str]: + await self.is_initialized.wait() # add timeout refund_privkey = os.urandom(32) refund_pubkey = ECPrivkey(refund_privkey).get_public_key_bytes(compressed=True) - self.logger.info('requesting preimage hash for swap') request_data = { "invoiceAmount": lightning_amount_sat, "refundPublicKey": refund_pubkey.hex() } - data = await self.send_request_to_server('createnormalswap', request_data) + data = await transport.send_request_to_server('createnormalswap', request_data) payment_hash = bytes.fromhex(data["preimageHash"]) zeroconf = data["acceptZeroConf"] @@ -707,12 +744,13 @@ class SwapManager(Logger): return swap, invoice async def wait_for_htlcs_and_broadcast( - self, + self, transport, *, swap: SwapData, invoice: str, tx: Transaction, ) -> Optional[str]: + await transport.is_connected.wait() payment_hash = swap.payment_hash refund_pubkey = ECPrivkey(swap.privkey).get_public_key_bytes(compressed=True) async def callback(payment_hash): @@ -728,7 +766,7 @@ class SwapManager(Logger): "invoice": invoice, "refundPublicKey": refund_pubkey.hex(), } - data = await self.send_request_to_server('addswapinvoice', request_data) + data = await transport.send_request_to_server('addswapinvoice', request_data) # wait for funding tx lnaddr = lndecode(invoice) while swap.funding_txid is None and not lnaddr.is_expired(): @@ -761,16 +799,17 @@ class SwapManager(Logger): return tx @log_exceptions - async def request_swap_for_tx(self, tx: 'PartialTransaction') -> Optional[Tuple[SwapData, str, PartialTransaction]]: + async def request_swap_for_tx(self, transport, tx: 'PartialTransaction') -> Optional[Tuple[SwapData, str, PartialTransaction]]: for o in tx.outputs(): if o.address == self.dummy_address: change_amount = o.value break else: return - await self.get_pairs() + await self.is_initialized.wait() lightning_amount_sat = self.get_recv_amount(change_amount, is_reverse=False) swap, invoice = await self.request_normal_swap( + transport, lightning_amount_sat = lightning_amount_sat, expected_onchain_amount_sat=change_amount) tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address) @@ -782,7 +821,7 @@ class SwapManager(Logger): await self.network.broadcast_transaction(tx) async def reverse_swap( - self, + self, transport, *, lightning_amount_sat: int, expected_onchain_amount_sat: int, @@ -817,7 +856,7 @@ class SwapManager(Logger): "preimageHash": payment_hash.hex(), "claimPublicKey": our_pubkey.hex() } - data = await self.send_request_to_server('createswap', request_data) + data = await transport.send_request_to_server('createswap', request_data) invoice = data['invoice'] fee_invoice = data.get('minerFeeInvoice') lockup_address = data['lockupAddress'] @@ -885,7 +924,7 @@ class SwapManager(Logger): self._swaps_by_funding_outpoint[swap._funding_prevout] = swap self._swaps_by_lockup_address[swap.lockup_address] = swap - def init_pairs(self) -> None: + def server_update_pairs(self) -> None: """ for server """ self.percentage = float(self.config.SWAPSERVER_FEE_MILLIONTHS) / 10000 self._min_amount = 20000 @@ -894,41 +933,15 @@ class SwapManager(Logger): self.lockup_fee = self.get_fee(LOCKUP_FEE_SIZE) self.claim_fee = self.get_fee(CLAIM_FEE_SIZE) - async def get_pairs(self) -> None: - """Might raise SwapServerError.""" - from .network import Network - try: - pairs = await self.send_request_to_server('getpairs', None) - except aiohttp.ClientError as e: - self.logger.error(f"Swap server errored: {e!r}") - raise SwapServerError() from e - # cache data to disk - with open(self.pairs_filename(), 'w', encoding='utf-8') as f: - f.write(json.dumps(pairs)) - fees = pairs['pairs']['BTC/BTC']['fees'] - self.percentage = fees['percentage'] - self.normal_fee = fees['minerFees']['baseAsset']['normal'] - self.lockup_fee = fees['minerFees']['baseAsset']['reverse']['lockup'] - self.claim_fee = fees['minerFees']['baseAsset']['reverse']['claim'] - limits = pairs['pairs']['BTC/BTC']['limits'] - self._min_amount = limits['minimal'] - self._max_amount = limits['maximal'] - assert pairs.get('htlcFirst') is True - - def pairs_filename(self): - return os.path.join(self.wallet.config.path, 'swap_pairs') - - def init_min_max_values(self): - # use default values if we never requested pairs - try: - with open(self.pairs_filename(), 'r', encoding='utf-8') as f: - pairs = json.loads(f.read()) - limits = pairs['pairs']['BTC/BTC']['limits'] - self._min_amount = limits['minimal'] - self._max_amount = limits['maximal'] - except Exception: - self._min_amount = 10000 - self._max_amount = 10000000 + def update_pairs(self, pairs): + self.logger.info(f'updating fees {pairs}') + self.normal_fee = pairs.normal_fee + self.lockup_fee = pairs.lockup_fee + self.claim_fee = pairs.claim_fee + self.percentage = pairs.percentage + self._min_amount = pairs.min_amount + self._max_amount = pairs.max_amount + self.is_initialized.set() def get_max_amount(self): return self._max_amount @@ -1139,7 +1152,6 @@ class SwapManager(Logger): def server_create_swap(self, request): # reverse for client, forward for server # requesting a normal swap (old protocol) will raise an exception - self.init_pairs() #request = await r.json() req_type = request['type'] assert request['pairId'] == 'BTC/BTC' @@ -1226,7 +1238,26 @@ class SwapManager(Logger): else: return swap.funding_txid -class HttpSwapManager(SwapManager): + + +class HttpTransport(Logger): + + def __init__(self, config, sm): + Logger.__init__(self) + self.sm = sm + self.network = sm.network + self.api_url = config.SWAPSERVER_URL + self.config = config + self.is_connected = asyncio.Event() + self.is_connected.set() + + def __enter__(self): + asyncio.run_coroutine_threadsafe(self.get_pairs(), self.network.asyncio_loop) + return self + + def __exit__(self, ex_type, ex, tb): + pass + async def send_request_to_server(self, method, request_data): response = await self.network.async_send_http_on_proxy( 'post' if request_data else 'get', @@ -1234,3 +1265,238 @@ class HttpSwapManager(SwapManager): json=request_data, timeout=30) return json.loads(response) + + async def get_pairs(self) -> None: + """Might raise SwapServerError.""" + try: + response = await self.send_request_to_server('getpairs', None) + except aiohttp.ClientError as e: + self.logger.error(f"Swap server errored: {e!r}") + raise SwapServerError() from e + assert response.get('htlcFirst') is True + fees = response['pairs']['BTC/BTC']['fees'] + limits = response['pairs']['BTC/BTC']['limits'] + pairs = SwapFees( + percentage = fees['percentage'], + normal_fee = fees['minerFees']['baseAsset']['normal'], + lockup_fee = fees['minerFees']['baseAsset']['reverse']['lockup'], + claim_fee = fees['minerFees']['baseAsset']['reverse']['claim'], + min_amount = limits['minimal'], + max_amount = limits['maximal'], + ) + self.sm.update_pairs(pairs) + + + +class NostrTransport(Logger): + # uses nostr: + # - to advertise servers + # - for client-server RPCs (using DMs) + # (todo: we should use onion messages for that) + + NOSTR_DM = 4 + NOSTR_SWAP_OFFER = 10943 + NOSTR_EVENT_TIMEOUT = 60*60*24 + NOSTR_EVENT_VERSION = 1 + + def __init__(self, config, sm, keypair): + Logger.__init__(self) + self.config = config + self.network = sm.network + self.sm = sm + self.offers = {} + self.private_key = keypair.privkey + self.nostr_private_key = to_nip19('nsec', keypair.privkey.hex()) + self.nostr_pubkey = keypair.pubkey.hex()[2:] + self.dm_replies = defaultdict(asyncio.Future) # type: Dict[bytes, asyncio.Future] + self.relay_manager = aionostr.Manager(self.relays, private_key=self.nostr_private_key) + self.taskgroup = OldTaskGroup() + self.is_connected = asyncio.Event() + self.server_relays = None + + def __enter__(self): + asyncio.run_coroutine_threadsafe(self.main_loop(), self.network.asyncio_loop) + return self + + def __exit__(self, ex_type, ex, tb): + fut = asyncio.run_coroutine_threadsafe(self.stop(), self.network.asyncio_loop) + fut.result(timeout=5) + + @log_exceptions + async def main_loop(self): + self.logger.info(f'starting nostr transport with pubkey: {self.nostr_pubkey}') + self.logger.info(f'nostr relays: {self.relays}') + await self.relay_manager.connect() + connected_relays = self.relay_manager.relays + self.logger.info(f'connected relays: {[relay.url for relay in connected_relays]}') + if connected_relays: + self.is_connected.set() + if self.sm.is_server: + tasks = [ + self.check_direct_messages(), + ] + else: + tasks = [ + self.check_direct_messages(), + self.receive_offers(), + self.get_pairs(), + ] + try: + async with self.taskgroup as group: + for task in tasks: + await group.spawn(task) + except Exception as e: + self.logger.exception("taskgroup died.") + finally: + self.logger.info("taskgroup stopped.") + + @log_exceptions + async def stop(self): + self.logger.info("shutting down nostr transport") + self.sm.is_initialized.clear() + await self.taskgroup.cancel_remaining() + await self.relay_manager.close() + + @property + def relays(self): + return self.network.config.NOSTR_RELAYS.split(',') + + def get_offer(self, pubkey): + offer = self.offers.get(pubkey) + return self._parse_offer(offer) + + def _parse_offer(self, offer): + return SwapFees( + percentage = offer['percentage_fee'], + normal_fee = offer['normal_mining_fee'], + lockup_fee = offer['reverse_mining_fee'], + claim_fee = offer['claim_mining_fee'], + min_amount = offer['min_amount'], + max_amount = offer['max_amount'], + ) + + @log_exceptions + async def publish_offer(self, sm): + assert self.sm.is_server + offer = { + "type": "electrum-swap", + "version": self.NOSTR_EVENT_VERSION, + 'network': constants.net.NET_NAME, + 'percentage_fee': sm.percentage, + 'normal_mining_fee': sm.normal_fee, + 'reverse_mining_fee': sm.lockup_fee, + 'claim_mining_fee': sm.claim_fee, + 'min_amount': sm._min_amount, + 'max_amount': sm._max_amount, + 'relays': sm.config.NOSTR_RELAYS, + } + self.logger.info(f'publishing swap offer..') + event_id = await aionostr._add_event( + self.relay_manager, + kind=self.NOSTR_SWAP_OFFER, + content=json.dumps(offer), + private_key=self.nostr_private_key) + + async def send_direct_message(self, pubkey: str, relays, content: str) -> str: + event_id = await aionostr._add_event( + self.relay_manager, + kind=self.NOSTR_DM, + content=content, + private_key=self.nostr_private_key, + direct_message=pubkey) + return event_id + + @log_exceptions + async def send_request_to_server(self, method: str, request: dict) -> dict: + request['method'] = method + request['relays'] = self.config.NOSTR_RELAYS + server_pubkey = self.config.SWAPSERVER_NPUB + event_id = await self.send_direct_message(server_pubkey, self.server_relays, json.dumps(request)) + response = await self.dm_replies[event_id] + return response + + async def receive_offers(self): + await self.is_connected.wait() + query = {"kinds": [self.NOSTR_SWAP_OFFER], "limit":10} + async for event in self.relay_manager.get_events(query, single_event=False, only_stored=False): + try: + content = json.loads(event.content) + except Exception as e: + continue + if content.get('version') != self.NOSTR_EVENT_VERSION: + continue + if content.get('network') != constants.net.NET_NAME: + continue + # check if this is the most recent event for this pubkey + pubkey = event.pubkey + ts = self.offers.get(pubkey, {}).get('timestamp', 0) + if event.created_at <= ts: + #print('skipping old event', pubkey[0:10], event.id) + continue + content['pubkey'] = pubkey + content['timestamp'] = event.created_at + self.offers[pubkey] = content + # mirror event to other relays + #await man.add_event(event, check_response=False) + + async def get_pairs(self): + if self.config.SWAPSERVER_NPUB is None: + return + query = {"kinds": [self.NOSTR_SWAP_OFFER], "authors": [self.config.SWAPSERVER_NPUB], "limit":1} + async for event in self.relay_manager.get_events(query, single_event=True, only_stored=False): + try: + content = json.loads(event.content) + except Exception as e: + continue + if content.get('version') != self.NOSTR_EVENT_VERSION: + continue + if content.get('network') != constants.net.NET_NAME: + continue + # check if this is the most recent event for this pubkey + pubkey = event.pubkey + content['pubkey'] = pubkey + content['timestamp'] = event.created_at + self.logger.info(f'received offer from {age(event.created_at)}') + pairs = self._parse_offer(content) + self.sm.update_pairs(pairs) + self.server_relays = content['relays'].split(',') + + @log_exceptions + async def check_direct_messages(self): + privkey = aionostr.key.PrivateKey(self.private_key) + query = {"kinds": [self.NOSTR_DM], "limit":0, "#p": [self.nostr_pubkey]} + async for event in self.relay_manager.get_events(query, single_event=False, only_stored=False): + try: + content = privkey.decrypt_message(event.content, event.pubkey) + content = json.loads(content) + except Exception: + continue + content['event_id'] = event.id + content['event_pubkey'] = event.pubkey + if 'reply_to' in content: + self.dm_replies[content['reply_to']].set_result(content) + elif self.sm.is_server and 'method' in content: + await self.handle_request(content) + else: + self.logger.info(f'unknown message {content}') + + @log_exceptions + async def handle_request(self, request): + assert self.sm.is_server + # todo: remember event_id of already processed requests + method = request.pop('method') + event_id = request.pop('event_id') + event_pubkey = request.pop('event_pubkey') + print(f'handle_request: id={event_id} {method} {request}') + relays = request.pop('relays').split(',') + if method == 'addswapinvoice': + r = self.sm.server_add_swap_invoice(request) + elif method == 'createswap': + r = self.sm.server_create_swap(request) + elif method == 'createnormalswap': + r = self.sm.server_create_normal_swap(request) + else: + raise Exception(method) + r['reply_to'] = event_id + self.logger.info(f'sending response id={event_id}') + await self.send_direct_message(event_pubkey, relays, json.dumps(r)) diff --git a/electrum/wallet.py b/electrum/wallet.py index ed146b7b2..ae7577386 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -1906,8 +1906,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): # do not use multiple change addresses if len(change) == 1: amount = change[0].value - ln_amount = self.lnworker.swap_manager.get_recv_amount(amount, is_reverse=False) - if ln_amount and ln_amount <= self.lnworker.num_sats_can_receive(): + if amount <= self.lnworker.num_sats_can_receive(): tx.replace_output_address(change[0].address, DummyAddress.SWAP) else: # "spend max" branch diff --git a/run_electrum b/run_electrum index ef42864ee..cc582f2c0 100755 --- a/run_electrum +++ b/run_electrum @@ -326,6 +326,7 @@ def main(): 'cmd': 'gui', SimpleConfig.GUI_NAME.key(): 'qml', SimpleConfig.WALLET_USE_SINGLE_PASSWORD.key(): True, + SimpleConfig.SWAPSERVER_URL: 'https://swaps.electrum.org/api', } if util.get_android_package_name() == "org.electrum.testnet.electrum": # ~hack for easier testnet builds. pkgname subject to change. diff --git a/tests/regtest.py b/tests/regtest.py index 410313cca..44cddbc70 100644 --- a/tests/regtest.py +++ b/tests/regtest.py @@ -79,10 +79,14 @@ class TestLightningSwapserver(TestLightning): agents = { 'alice': { 'use_gossip': 'false', + 'swapserver_url': 'http://localhost:5455', + 'nostr_relays': "''", }, 'bob': { 'lightning_listen': 'localhost:9735', 'enable_plugin_swapserver': 'true', + 'swapserver_port': '5455', + 'nostr_relays': "''", } } diff --git a/tests/test_simple_config.py b/tests/test_simple_config.py index 03b1a84dc..2b1da5b87 100644 --- a/tests/test_simple_config.py +++ b/tests/test_simple_config.py @@ -148,22 +148,6 @@ class Test_SimpleConfig(ElectrumTestCase): config.NETWORK_MAX_INCOMING_MSG_SIZE = None self.assertEqual(MAX_MSG_SIZE_DEFAULT, config.NETWORK_MAX_INCOMING_MSG_SIZE) - def test_configvars_get_default_value_complex_fn(self): - config = SimpleConfig(self.options) - self.assertEqual("https://swaps.electrum.org/api", config.SWAPSERVER_URL) - - config.SWAPSERVER_URL = "http://localhost:9999" - self.assertEqual("http://localhost:9999", config.SWAPSERVER_URL) - - config.SWAPSERVER_URL = None - self.assertEqual("https://swaps.electrum.org/api", config.SWAPSERVER_URL) - - constants.BitcoinTestnet.set_as_network() - try: - self.assertEqual("https://swaps.electrum.org/testnet", config.SWAPSERVER_URL) - finally: - constants.BitcoinMainnet.set_as_network() - def test_configvars_convert_getter(self): config = SimpleConfig(self.options) self.assertEqual(None, config.NETWORK_PROXY)