Merge pull request #9260 from spesmilo/swaps_over_nostr

Swaps over nostr
This commit is contained in:
ThomasV
2024-11-12 09:48:46 +01:00
committed by GitHub
17 changed files with 569 additions and 210 deletions
+1
View File
@@ -8,6 +8,7 @@ certifi
attrs>=20.1.0
jsonpatch
electrum_ecc
electrum_aionostr>=0.0.6
# Note that we also need the dnspython[DNSSEC] extra which pulls in cryptography,
# but as that is not pure-python it cannot be listed in this file!
+39 -36
View File
@@ -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),
+102 -13
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
+1 -1
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):
+23 -27
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
+8
View File
@@ -194,6 +194,13 @@ class SettingsDialog(QDialog, QtEventListener):
self.set_alias_color()
self.alias_e.editingFinished.connect(self.on_alias_edit)
nostr_relays_label = HelpLabel.from_configvar(self.config.cv.NOSTR_RELAYS)
nostr_relays = self.config.NOSTR_RELAYS
self.nostr_relays_e = QLineEdit(nostr_relays)
def on_nostr_edit():
self.config.NOSTR_RELAYS = str(self.nostr_relays_e.text())
self.nostr_relays_e.editingFinished.connect(on_nostr_edit)
msat_cb = checkbox_from_configvar(self.config.cv.BTC_AMOUNTS_PREC_POST_SAT)
msat_cb.setChecked(self.config.BTC_AMOUNTS_PREC_POST_SAT > 0)
def on_msat_checked(_x):
@@ -392,6 +399,7 @@ class SettingsDialog(QDialog, QtEventListener):
misc_widgets = []
misc_widgets.append((updatecheck_cb, None))
misc_widgets.append((filelogging_cb, None))
misc_widgets.append((nostr_relays_label, self.nostr_relays_e))
misc_widgets.append((alias_label, self.alias_e))
misc_widgets.append((qr_label, qr_combo))
+18 -15
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:
+6
View File
@@ -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
+5 -4
View File
@@ -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):
+2 -2
View File
@@ -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": [],
+3 -9
View File
@@ -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)
+15 -11
View File
@@ -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,11 +1192,24 @@ 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://nos.lol,wss://relay.damus.io,wss://brb.io,wss://nostr.mom',
type_=str,
short_desc=lambda: _("Nostr relays"),
long_desc=lambda: ' '.join([
_('Nostr relays are used to send and receive submarine swap offers'),
_('If this list is empty, Electrum will use http instead'),
]),
)
# zeroconf channels
ACCEPT_ZEROCONF_CHANNELS = ConfigVar('accept_zeroconf_channels', default=False, type_=bool)
+340 -74
View File
@@ -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))
+1 -2
View File
@@ -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
+1
View File
@@ -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.
+4
View File
@@ -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': "''",
}
}
-16
View File
@@ -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)