Merge pull request #9260 from spesmilo/swaps_over_nostr
Swaps over nostr
This commit is contained in:
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
|
||||
+23
-27
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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': "''",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user