1
0

Merge pull request #10169 from SomberNight/202508_swaps_more_cleanup

swaps: more cleanup
This commit is contained in:
ghost43
2025-08-23 12:54:02 +00:00
committed by GitHub
3 changed files with 147 additions and 54 deletions

View File

@@ -910,7 +910,8 @@ 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._payment_bundles_pkey_to_canon = {} # type: Dict[bytes, bytes] # TODO: persist
self._payment_bundles_canon_to_pkeylist = {} # type: Dict[bytes, Sequence[bytes]] # TODO: persist
self.nostr_keypair = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.NOSTR_KEY)
self.swap_manager = SwapManager(wallet=self.wallet, lnworker=self)
@@ -2308,22 +2309,42 @@ class LNWallet(LNWorker):
self.wallet.save_db()
return payment_hash
def bundle_payments(self, hash_list):
def bundle_payments(self, hash_list: Sequence[bytes]) -> None:
"""Bundle together a list of payment_hashes, for atomicity, so that either
- all gets fulfilled, or
- none of them gets fulfilled.
(we are the recipient of this payment)
"""
payment_keys = [self._get_payment_key(x) for x in hash_list]
self.payment_bundles.append(payment_keys)
with self.lock:
# We maintain two maps.
# map1: payment_key -> bundle_key=canon_pkey (canonically smallest among pkeys)
# map2: bundle_key -> list of pkeys in bundle
# assumption: bundles are immutable, so no adding extra pkeys after-the-fact
canon_pkey = min(payment_keys)
for pkey in payment_keys:
assert pkey not in self._payment_bundles_pkey_to_canon
for pkey in payment_keys:
self._payment_bundles_pkey_to_canon[pkey] = canon_pkey
self._payment_bundles_canon_to_pkeylist[canon_pkey] = tuple(payment_keys)
def get_payment_bundle(self, payment_key: bytes) -> Sequence[bytes]:
for key_list in self.payment_bundles:
if payment_key in key_list:
return key_list
return []
with self.lock:
canon_pkey = self._payment_bundles_pkey_to_canon.get(payment_key)
if canon_pkey is None:
return []
return self._payment_bundles_canon_to_pkeylist[canon_pkey]
def delete_payment_bundle(self, payment_hash: bytes) -> None:
payment_key = self._get_payment_key(payment_hash)
for key_list in self.payment_bundles:
if payment_key in key_list:
self.payment_bundles.remove(key_list)
with self.lock:
canon_pkey = self._payment_bundles_pkey_to_canon.get(payment_key)
if canon_pkey is None: # is it ok for bundle to be missing??
return
pkey_list = self._payment_bundles_canon_to_pkeylist[canon_pkey]
for pkey in pkey_list:
del self._payment_bundles_pkey_to_canon[pkey]
del self._payment_bundles_canon_to_pkeylist[canon_pkey]
def save_preimage(self, payment_hash: bytes, preimage: bytes, *, write_to_disk: bool = True):
if sha256(preimage) != payment_hash:
@@ -2370,9 +2391,15 @@ class LNWallet(LNWorker):
self.hold_invoice_callbacks.pop(payment_hash)
def save_payment_info(self, info: PaymentInfo, *, write_to_disk: bool = True) -> None:
key = info.payment_hash.hex()
assert info.status in SAVED_PR_STATUS
with self.lock:
if old_info := self.get_payment_info(payment_hash=info.payment_hash):
if info == old_info:
return # already saved
if info != old_info._replace(status=info.status):
# differs more than in status. let's fail
raise Exception("payment_hash already in use")
key = info.payment_hash.hex()
self.payment_info[key] = info.amount_msat, info.direction, info.status
if write_to_disk:
self.wallet.save_db()

View File

@@ -77,48 +77,76 @@ assert MAX_LOCKTIME_DELTA < MIN_FINAL_CLTV_DELTA_FOR_CLIENT
# different length which would still allow for claiming the onchain
# coins but the invoice couldn't be settled
WITNESS_TEMPLATE_REVERSE_SWAP = [
# Unified witness-script for all swaps. Historically with Boltz-backend, this was the reverse-swap script.
WITNESS_TEMPLATE_SWAP = [
opcodes.OP_SIZE,
OPPushDataGeneric(None),
OPPushDataGeneric(None), # idx 1. length of preimage
opcodes.OP_EQUAL,
opcodes.OP_IF,
opcodes.OP_HASH160,
OPPushDataGeneric(lambda x: x == 20),
OPPushDataGeneric(lambda x: x == 20), # idx 5. payment_hash
opcodes.OP_EQUALVERIFY,
OPPushDataPubkey,
OPPushDataPubkey, # idx 7. claim_pubkey
opcodes.OP_ELSE,
opcodes.OP_DROP,
OPPushDataGeneric(None),
OPPushDataGeneric(None), # idx 10. locktime
opcodes.OP_CHECKLOCKTIMEVERIFY,
opcodes.OP_DROP,
OPPushDataPubkey,
OPPushDataPubkey, # idx 13. refund_pubkey
opcodes.OP_ENDIF,
opcodes.OP_CHECKSIG
]
def check_reverse_redeem_script(
def _check_swap_scriptcode(
*,
redeem_script: bytes,
lockup_address: str,
payment_hash: bytes,
locktime: int,
refund_pubkey: bytes = None,
claim_pubkey: bytes = None,
refund_pubkey: Optional[bytes], # note: We don't need to check the counterparty's key.
claim_pubkey: Optional[bytes], # Can use None in that case.
) -> None:
assert (refund_pubkey is not None) or (claim_pubkey is not None), "at least one pubkey must be set"
parsed_script = [x for x in script_GetOp(redeem_script)]
if not match_script_against_template(redeem_script, WITNESS_TEMPLATE_REVERSE_SWAP):
if not match_script_against_template(redeem_script, WITNESS_TEMPLATE_SWAP):
raise Exception("rswap check failed: scriptcode does not match template")
if script_to_p2wsh(redeem_script) != lockup_address:
raise Exception("rswap check failed: inconsistent scriptcode and address")
if ripemd(payment_hash) != parsed_script[5][1]:
raise Exception("rswap check failed: our preimage not in script")
if claim_pubkey and claim_pubkey != parsed_script[7][1]:
claim_pubkey = claim_pubkey or parsed_script[7][1]
if claim_pubkey != parsed_script[7][1]:
raise Exception("rswap check failed: our pubkey not in script")
if refund_pubkey and refund_pubkey != parsed_script[13][1]:
refund_pubkey = refund_pubkey or parsed_script[13][1]
if refund_pubkey != parsed_script[13][1]:
raise Exception("rswap check failed: our pubkey not in script")
if locktime != int.from_bytes(parsed_script[10][1], byteorder='little'):
raise Exception("rswap check failed: inconsistent locktime and script")
# let's just rebuild the full script from scratch...
if redeem_script != _construct_swap_scriptcode(
payment_hash=payment_hash,
locktime=locktime,
refund_pubkey=refund_pubkey,
claim_pubkey=claim_pubkey,
):
raise Exception("failed to rebuild swap script from scratch")
def _construct_swap_scriptcode(
payment_hash: bytes,
locktime: int,
refund_pubkey: bytes,
claim_pubkey: bytes,
) -> bytes:
assert isinstance(payment_hash, bytes) and len(payment_hash) == 32
assert isinstance(locktime, int) and (0 <= locktime <= bitcoin.NLOCKTIME_BLOCKHEIGHT_MAX)
assert isinstance(refund_pubkey, bytes) and len(refund_pubkey) == 33
assert isinstance(claim_pubkey, bytes) and len(claim_pubkey) == 33
return construct_script(
WITNESS_TEMPLATE_SWAP,
values={1: 32, 5: ripemd(payment_hash), 7: claim_pubkey, 10: locktime, 13: refund_pubkey}
)
class SwapServerError(Exception):
@@ -226,7 +254,7 @@ class SwapManager(Logger):
for payment_hash_hex, swap in self._swaps.items():
payment_hash = bytes.fromhex(payment_hash_hex)
swap._payment_hash = payment_hash
self._add_or_reindex_swap(swap)
self._add_or_reindex_swap(swap, is_new=False)
if not swap.is_reverse and not swap.is_redeemed:
self.lnworker.register_hold_invoice(payment_hash, self.hold_invoice_callback)
@@ -427,7 +455,7 @@ class SwapManager(Logger):
# note: swap.funding_txid can change due to RBF, it will get updated here:
swap.funding_txid = txin.prevout.txid.hex()
swap._funding_prevout = txin.prevout
self._add_or_reindex_swap(swap) # to update _swaps_by_funding_outpoint
self._add_or_reindex_swap(swap, is_new=False) # to update _swaps_by_funding_outpoint
funding_height = self.lnwatcher.adb.get_tx_height(txin.prevout.txid.hex())
spent_height = txin.spent_height
# set spending_txid (even if tx is local), for GUI grouping
@@ -565,15 +593,21 @@ class SwapManager(Logger):
def create_normal_swap(self, *, lightning_amount_sat: int, payment_hash: bytes, their_pubkey: bytes = None):
""" server method """
assert lightning_amount_sat
if payment_hash.hex() in self._swaps:
raise Exception("payment_hash already in use")
locktime = self.network.get_local_height() + LOCKTIME_DELTA_REFUND
if self.network.blockchain().is_tip_stale():
raise Exception("our blockchain tip is stale")
our_privkey = os.urandom(32)
our_pubkey = ECPrivkey(our_privkey).get_public_key_bytes(compressed=True)
onchain_amount_sat = self._get_recv_amount(lightning_amount_sat, is_reverse=True) # what the client is going to receive
if not onchain_amount_sat:
raise Exception("no onchain amount")
redeem_script = construct_script(
WITNESS_TEMPLATE_REVERSE_SWAP,
values={1:32, 5:ripemd(payment_hash), 7:their_pubkey, 10:locktime, 13:our_pubkey}
redeem_script = _construct_swap_scriptcode(
payment_hash=payment_hash,
locktime=locktime,
refund_pubkey=our_pubkey,
claim_pubkey=their_pubkey,
)
swap, invoice, prepay_invoice = self.add_normal_swap(
redeem_script=redeem_script,
@@ -600,6 +634,8 @@ class SwapManager(Logger):
min_final_cltv_expiry_delta: Optional[int] = None,
) -> Tuple[SwapData, str, Optional[str]]:
"""creates a hold invoice"""
if payment_hash.hex() in self._swaps:
raise Exception("payment_hash already in use")
if prepay:
# server requests 2 * the mining fee as instantly settled prepayment so that the mining
# fees of the funding tx and potential timeout refund tx are always covered
@@ -655,7 +691,7 @@ class SwapManager(Logger):
spending_txid=None,
)
swap._payment_hash = payment_hash
self._add_or_reindex_swap(swap)
self._add_or_reindex_swap(swap, is_new=True)
self.add_lnwatcher_callback(swap)
return swap, invoice, prepay_invoice
@@ -663,6 +699,8 @@ class SwapManager(Logger):
""" server method. """
assert lightning_amount_sat is not None
locktime = self.network.get_local_height() + LOCKTIME_DELTA_REFUND
if self.network.blockchain().is_tip_stale():
raise Exception("our blockchain tip is stale")
privkey = os.urandom(32)
our_pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True)
onchain_amount_sat = self._get_send_amount(lightning_amount_sat, is_reverse=False)
@@ -670,9 +708,11 @@ class SwapManager(Logger):
raise Exception("no onchain amount")
preimage = os.urandom(32)
payment_hash = sha256(preimage)
redeem_script = construct_script(
WITNESS_TEMPLATE_REVERSE_SWAP,
values={1:32, 5:ripemd(payment_hash), 7:our_pubkey, 10:locktime, 13:their_pubkey}
redeem_script = _construct_swap_scriptcode(
payment_hash=payment_hash,
locktime=locktime,
refund_pubkey=their_pubkey,
claim_pubkey=our_pubkey,
)
swap = self.add_reverse_swap(
redeem_script=redeem_script,
@@ -697,6 +737,9 @@ class SwapManager(Logger):
payment_hash: bytes,
prepay_hash: Optional[bytes] = None,
) -> SwapData:
if payment_hash.hex() in self._swaps:
raise Exception("payment_hash already in use")
assert sha256(preimage) == payment_hash
lockup_address = script_to_p2wsh(redeem_script)
receive_address = self.wallet.get_receiving_address()
swap = SwapData(
@@ -715,26 +758,41 @@ class SwapManager(Logger):
spending_txid=None,
)
if prepay_hash:
if prepay_hash in self._prepayments:
raise Exception("prepay_hash already in use")
self._prepayments[prepay_hash] = payment_hash
swap._payment_hash = payment_hash
self._add_or_reindex_swap(swap)
self._add_or_reindex_swap(swap, is_new=True)
self.add_lnwatcher_callback(swap)
return swap
def server_add_swap_invoice(self, request):
def server_add_swap_invoice(self, request: dict) -> dict:
""" server method.
(client-forward-swap phase2)
"""
invoice = request['invoice']
invoice = Invoice.from_bech32(invoice)
key = invoice.rhash
payment_hash = bytes.fromhex(key)
their_pubkey = bytes.fromhex(request['refundPublicKey'])
with self.swaps_lock:
assert key in self._swaps
swap = self._swaps[key]
assert swap.lightning_amount == int(invoice.get_amount_sat())
self.wallet.save_invoice(invoice)
# check that we have the preimage
assert sha256(swap.preimage) == payment_hash
assert swap.spending_txid is None
self.invoices_to_pay[key] = 0
assert swap.lightning_amount == int(invoice.get_amount_sat())
assert swap.is_reverse is True
# check that we have the preimage
assert sha256(swap.preimage) == payment_hash
assert swap.spending_txid is None
# check their_pubkey by recalculating redeem_script
our_pubkey = ECPrivkey(swap.privkey).get_public_key_bytes(compressed=True)
redeem_script = _construct_swap_scriptcode(
payment_hash=payment_hash, locktime=swap.locktime, refund_pubkey=their_pubkey, claim_pubkey=our_pubkey,
)
assert swap.redeem_script == redeem_script
assert key not in self.invoices_to_pay
self.invoices_to_pay[key] = 0
assert self.wallet.get_invoice(invoice.get_id()) is None
self.wallet.save_invoice(invoice)
return {}
async def normal_swap(
@@ -757,9 +815,9 @@ class SwapManager(Logger):
cltv safety requirement: (onchain_locktime > LN_locktime), otherwise server is vulnerable
New flow:
- User requests swap
- User requests swap (RPC 'createnormalswap')
- Server creates preimage, sends RHASH to user
- User creates hold invoice, sends it to server
- User creates hold invoice, sends it to server (RPC 'addswapinvoice')
- Server sends HTLC, user holds it
- User creates on-chain output locked to RHASH
- Server spends the on-chain output using preimage (revealing the preimage)
@@ -812,12 +870,13 @@ class SwapManager(Logger):
raise SwapServerError("failed to parse response from swapserver for createnormalswap") from e
del data # parsing done
# verify redeem_script is built with our pubkey and preimage
check_reverse_redeem_script(
_check_swap_scriptcode(
redeem_script=redeem_script,
lockup_address=lockup_address,
payment_hash=payment_hash,
locktime=locktime,
refund_pubkey=refund_pubkey,
claim_pubkey=None,
)
# check that onchain_amount is not more than what we estimated
@@ -827,6 +886,8 @@ class SwapManager(Logger):
# verify that they are not locking up funds for too long
if locktime - self.network.get_local_height() > MAX_LOCKTIME_DELTA:
raise Exception("fswap check failed: locktime too far in future")
if self.network.blockchain().is_tip_stale():
raise Exception("our blockchain tip is stale")
swap, invoice, _ = self.add_normal_swap(
redeem_script=redeem_script,
@@ -866,8 +927,10 @@ class SwapManager(Logger):
self.lnworker.register_hold_invoice(payment_hash, callback)
# send invoice to server and wait for htlcs
# note: server will link this RPC to our previous 'createnormalswap' RPC
# - using the RHASH from invoice, and using refundPublicKey
# - FIXME it would be safer to use a proper session-secret?!
request_data = {
"preimageHash": payment_hash.hex(),
"invoice": invoice,
"refundPublicKey": refund_pubkey.hex(),
}
@@ -941,7 +1004,7 @@ class SwapManager(Logger):
) -> Optional[str]:
"""send on Lightning, receive on-chain
- User generates preimage, RHASH. Sends RHASH to server.
- User generates preimage, RHASH. Sends RHASH to server. (RPC 'createswap')
- Server creates an LN invoice for RHASH.
- User pays LN invoice - except server needs to hold the HTLC as preimage is unknown.
- if the server requested a fee prepayment (using 'minerFeeInvoice'),
@@ -968,10 +1031,9 @@ class SwapManager(Logger):
request_data = {
"type": "reversesubmarine",
"pairId": "BTC/BTC",
"orderSide": "buy",
"invoiceAmount": lightning_amount_sat,
"preimageHash": payment_hash.hex(),
"claimPublicKey": our_pubkey.hex()
"claimPublicKey": our_pubkey.hex(),
}
self.logger.debug(f'rswap: sending request for {lightning_amount_sat}')
data = await transport.send_request_to_server('createswap', request_data)
@@ -995,7 +1057,7 @@ class SwapManager(Logger):
del data # parsing done
self.logger.debug(f'rswap: {response_id=}')
# verify redeem_script is built with our pubkey and preimage
check_reverse_redeem_script(
_check_swap_scriptcode(
redeem_script=redeem_script,
lockup_address=lockup_address,
payment_hash=payment_hash,
@@ -1010,6 +1072,8 @@ class SwapManager(Logger):
# verify that we will have enough time to get our tx confirmed
if locktime - self.network.get_local_height() <= MIN_LOCKTIME_DELTA:
raise Exception("rswap check failed: locktime too close")
if self.network.blockchain().is_tip_stale():
raise Exception("our blockchain tip is stale")
# verify invoice payment_hash
lnaddr = self.lnworker._check_bolt11_invoice(invoice)
invoice_amount = int(lnaddr.get_amount_sat())
@@ -1052,8 +1116,9 @@ class SwapManager(Logger):
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
return swap.funding_txid
def _add_or_reindex_swap(self, swap: SwapData) -> None:
def _add_or_reindex_swap(self, swap: SwapData, *, is_new: bool) -> None:
with self.swaps_lock:
assert is_new == (swap.payment_hash.hex() not in self._swaps), is_new
if swap.payment_hash.hex() not in self._swaps:
self._swaps[swap.payment_hash.hex()] = swap
if swap._funding_prevout:
@@ -1837,11 +1902,11 @@ class NostrTransport(SwapServerTransport):
try:
method = request.pop('method')
self.logger.info(f'handle_request: id={event_id} {method} {request}')
if method == 'addswapinvoice':
if method == 'addswapinvoice': # client-forward-swap phase2
r = self.sm.server_add_swap_invoice(request)
elif method == 'createswap':
elif method == 'createswap': # client-reverse-swap
r = self.sm.server_create_swap(request)
elif method == 'createnormalswap':
elif method == 'createnormalswap': # client-forward-swap phase1
r = self.sm.server_create_normal_swap(request)
else:
raise Exception(method)

View File

@@ -10,7 +10,7 @@ import logging
import concurrent
from concurrent import futures
from unittest import mock
from typing import Iterable, NamedTuple, Tuple, List, Dict
from typing import Iterable, NamedTuple, Tuple, List, Dict, Sequence
from aiorpcx import timeout_after, TaskTimeout
from electrum_ecc import ECPrivkey
@@ -215,7 +215,8 @@ class MockLNWallet(Logger, EventListener, NetworkRetryManager[LNPeerAddr]):
self.downstream_to_upstream_htlc = {}
self.dont_settle_htlcs = {}
self.hold_invoice_callbacks = {}
self.payment_bundles = [] # lists of hashes. todo:persist
self._payment_bundles_pkey_to_canon = {} # type: Dict[bytes, bytes]
self._payment_bundles_canon_to_pkeylist = {} # type: Dict[bytes, Sequence[bytes]]
self.config.INITIAL_TRAMPOLINE_FEE_LEVEL = 0
self.logger.info(f"created LNWallet[{name}] with nodeID={local_keypair.pubkey.hex()}")