Skip to content

Commit 86f575b

Browse files
authored
Merge pull request #83 from jan3dev/66-sideswap-fix
[SIDESWAP] Fix USDt to LBTC atomic swap - dealer's suggested recv_amount
2 parents 7227086 + 58afce3 commit 86f575b

4 files changed

Lines changed: 591 additions & 31 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ classifiers = [
2323
]
2424

2525
dependencies = [
26-
"lwk>=0.8.0",
26+
"lwk>=0.17.2",
2727
"mcp>=1.0.0",
2828
"cryptography>=42.0.0",
2929
"bdkpython>=2.2.0",
@@ -34,6 +34,7 @@ dependencies = [
3434
"pillow>=10.0",
3535
"zxing-cpp>=2.2",
3636
"qrcode[pil]>=7.0",
37+
"wallycore>=1.5.3",
3738
]
3839

3940
[project.optional-dependencies]

src/aqua/sideswap.py

Lines changed: 221 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,12 @@
5454
import json
5555
import logging
5656
import threading
57-
import urllib.error
58-
import urllib.request
59-
from dataclasses import asdict, dataclass, field
57+
from dataclasses import asdict, dataclass
6058
from datetime import UTC, datetime
6159
from typing import Any, Optional
6260

61+
import lwk
62+
import wallycore as wally
6363
import websockets
6464

6565
logger = logging.getLogger(__name__)
@@ -276,9 +276,14 @@ def verify_pset_balances(
276276
277277
Verification rules (any failure raises `PsetVerificationError`):
278278
279-
1. The wallet must gain at least `recv_amount` of `recv_asset`. Strict
280-
equality is required — the server should not deliver a different amount
281-
than what it quoted.
279+
1. The wallet's gain of `recv_asset` must land in
280+
`[recv_amount - fee_tolerance_sats, recv_amount]` when
281+
`recv_asset == fee_asset` (the dealer's quote is gross of the
282+
on-chain network fee, and may also be gross of a service fee on the
283+
same asset, so a small shortfall is legitimate). Otherwise the gain
284+
must be **exactly** `recv_amount`. Receiving MORE than `recv_amount`
285+
is always rejected — never legitimate and would indicate a confused
286+
or hostile server.
282287
2. The wallet must lose **at most** `send_amount + fee_tolerance_sats` of
283288
`send_asset`. We allow a small overage to cover the network fee when it
284289
comes from the same asset (which is typical for L-BTC sends, since the
@@ -313,12 +318,31 @@ def verify_pset_balances(
313318
)
314319
fee_asset = fee_asset or send_asset
315320

316-
# Rule 1: receive amount is exactly what was agreed
321+
# Rule 1: receive amount matches what was agreed, modulo the on-chain
322+
# fee on the recv side. When recv_asset == fee_asset, the dealer's
323+
# quote_amount is gross of the network fee (and may also be gross of
324+
# the dealer's own service fee, which lands on the same asset), so the
325+
# taker actually receives slightly less. We mirror the send-side
326+
# tolerance: recv_delta must land in [recv_amount - fee_tolerance_sats,
327+
# recv_amount]. Receiving MORE than agreed is still rejected — that's
328+
# never legitimate and could indicate a confused server.
317329
recv_delta = balances.get(recv_asset, 0)
318-
if recv_delta != recv_amount:
330+
if recv_asset == fee_asset:
331+
# Clamp at zero — a fee tolerance bigger than the receive amount
332+
# would otherwise let the dealer deliver 0 sats and still pass.
333+
min_recv_delta = max(0, recv_amount - fee_tolerance_sats)
334+
else:
335+
min_recv_delta = recv_amount
336+
if recv_delta < min_recv_delta:
319337
raise PsetVerificationError(
320338
f"PSET delivers {recv_delta} sats of recv_asset {recv_asset[:8]}…, "
321-
f"expected exactly {recv_amount} sats"
339+
f"less than the agreed {recv_amount} "
340+
f"(tolerance {recv_amount - min_recv_delta})"
341+
)
342+
if recv_delta > recv_amount:
343+
raise PsetVerificationError(
344+
f"PSET delivers {recv_delta} sats of recv_asset {recv_asset[:8]}…, "
345+
f"more than the agreed {recv_amount}; refusing to sign"
322346
)
323347

324348
# Rule 2: send amount is within tolerance
@@ -351,6 +375,145 @@ def verify_pset_balances(
351375
)
352376

353377

378+
def unblind_dealer_outputs(
379+
pset_b64: str,
380+
*,
381+
recv_addr: Optional[str],
382+
change_addr: Optional[str],
383+
receive_ephemeral_sk: Optional[str],
384+
change_ephemeral_sk: Optional[str],
385+
addr_family: str = "lq",
386+
) -> None:
387+
"""Cryptographically verify dealer-paid outputs against the on-chain commitments.
388+
389+
SideSwap's `mkt::*` dealer pays the taker via outputs blinded with
390+
ephemeral secrets it ships out-of-band in the `get_quote` response.
391+
Wally rewinds each dealer-paid output's rangeproof and re-derives the
392+
asset generator + Pedersen commitment from the unblinded
393+
(asset, abf, value, vbf) — if the commitments don't match the on-chain
394+
ones, a malicious dealer is trying to deliver a different (asset, value)
395+
than what the PSET's explicit fields claim. `PsetVerificationError` is
396+
raised on any mismatch.
397+
398+
This function exists purely for that security side-effect — its caller
399+
relies on `wollet.pset_details(pset).balance()` (after `add_details`) as
400+
the source of truth for the balances dict. Wally's job is to ensure
401+
LWK's explicit-value reading reflects the cryptographic reality.
402+
403+
Implementation mirrors `sideswap_common::pset::swap_amount::get_swap_amount`
404+
in Rust: for each PSET output whose scriptPubKey matches our recv/change
405+
address, derive the CT shared secret via ECDH(ephemeral_sk,
406+
address_blinding_pk), then rewind the rangeproof and verify the
407+
recomputed commitments.
408+
409+
Done in pure Python via `wallycore` (libwally). LWK's Python `TxOut.unblind`
410+
expects the receiver's blinding privkey and runs `ECDH(sk, output_nonce_pk)`;
411+
SideSwap's protocol needs `ECDH(dealer_ephemeral_sk, recv_addr_blinding_pk)`,
412+
which only libwally exposes at this layer.
413+
414+
No-op when no ephemeral keys are supplied (peg flows / legacy orderbook
415+
flows that don't use ephemeral blinding).
416+
"""
417+
if not receive_ephemeral_sk and not change_ephemeral_sk:
418+
return
419+
420+
# Decode addresses up front so we can match by scriptPubKey bytes.
421+
def _addr_spk_and_bpk(addr: Optional[str]) -> tuple[Optional[bytes], Optional[bytes]]:
422+
if not addr:
423+
return None, None
424+
import lwk
425+
spk_hex = str(lwk.Address(addr).script_pubkey())
426+
spk = bytes.fromhex(spk_hex)
427+
bpk = wally.confidential_addr_segwit_to_ec_public_key(addr, addr_family)
428+
return spk, bpk
429+
430+
recv_spk, recv_bpk = _addr_spk_and_bpk(recv_addr)
431+
change_spk, change_bpk = _addr_spk_and_bpk(change_addr)
432+
433+
psbt = wally.psbt_from_base64(pset_b64)
434+
num_outputs = wally.psbt_get_num_outputs(psbt)
435+
436+
for i in range(num_outputs):
437+
try:
438+
spk = wally.psbt_get_output_script(psbt, i)
439+
except Exception:
440+
spk = b""
441+
442+
# Pick the right (ephemeral_sk, blinding_pk) pair for this output.
443+
# recv_addr and change_addr may collapse to the same string (the
444+
# wollet.address(None) gap-limit cursor doesn't advance between
445+
# calls) — that's harmless because we just try receive first.
446+
if recv_spk is not None and spk == recv_spk and receive_ephemeral_sk:
447+
sk_hex, bpk = receive_ephemeral_sk, recv_bpk
448+
elif change_spk is not None and spk == change_spk and change_ephemeral_sk:
449+
sk_hex, bpk = change_ephemeral_sk, change_bpk
450+
else:
451+
continue
452+
453+
ephemeral_sk = bytes.fromhex(sk_hex)
454+
try:
455+
value_commit = wally.psbt_get_output_value_commitment(psbt, i)
456+
asset_commit = wally.psbt_get_output_asset_commitment(psbt, i)
457+
rangeproof = wally.psbt_get_output_value_rangeproof(psbt, i)
458+
except Exception as e:
459+
raise PsetVerificationError(
460+
f"PSET output {i} missing commitment/rangeproof "
461+
f"(spk={spk.hex()}): {e!r}"
462+
) from e
463+
464+
try:
465+
# ECDH shared secret: SHA256 of the ECDH point. Liquid CT uses
466+
# this 32-byte hash directly as the rangeproof nonce.
467+
nonce_hash = wally.ecdh_nonce_hash(bpk, ephemeral_sk)
468+
# asset_unblind_with_nonce signature (wallycore Python wrapper):
469+
# (nonce_hash, proof, commitment, extra, generator)
470+
# -> (value, asset_bytes, abf_bytes, vbf_bytes)
471+
# The Python wrapper allocates the 3 output buffers internally
472+
# and prepends the C return (= the recovered uint64 value) as
473+
# the first tuple element. extra commitment data is the
474+
# scriptPubKey (matches the reference:
475+
# `output.script_pubkey.as_bytes()`).
476+
value_out, asset_buf, abf_buf, vbf_buf = wally.asset_unblind_with_nonce(
477+
nonce_hash, rangeproof, value_commit, spk, asset_commit,
478+
)
479+
# Coerce bytearray outputs to bytes for downstream wally calls
480+
# that are strict about argument types via SWIG.
481+
asset_out = bytes(asset_buf)
482+
abf_out = bytes(abf_buf)
483+
vbf_out = bytes(vbf_buf)
484+
except Exception as e:
485+
raise PsetVerificationError(
486+
f"failed to unblind dealer output {i} at script {spk.hex()}: {e!r}"
487+
) from e
488+
489+
# Recompute the asset generator and value commitment from the
490+
# unblinded (asset, abf, value, vbf) and check they match the
491+
# on-chain commitments. asset_unblind_with_nonce alone proves the
492+
# rangeproof is valid for the *commitment* but does not prove the
493+
# encrypted message (which carries the claimed asset_id + abf)
494+
# matches it. A malicious dealer could put a different asset in
495+
# the message than the one the commitment actually opens to;
496+
# without this check we'd believe the message. Matches the
497+
# `unexpected asset/value commitment` checks in the SideSwap
498+
# reference (sideswap_common::pset::swap_amount).
499+
expected_generator = wally.asset_generator_from_bytes(asset_out, abf_out)
500+
if expected_generator != asset_commit:
501+
raise PsetVerificationError(
502+
f"PSET output {i} asset commitment mismatch: dealer's "
503+
f"rangeproof message decodes to a different asset than the "
504+
f"on-chain asset commitment opens to (script {spk.hex()})"
505+
)
506+
expected_value_commitment = wally.asset_value_commitment(
507+
value_out, vbf_out, expected_generator,
508+
)
509+
if expected_value_commitment != value_commit:
510+
raise PsetVerificationError(
511+
f"PSET output {i} value commitment mismatch: dealer's "
512+
f"rangeproof message decodes to a different value than the "
513+
f"on-chain value commitment opens to (script {spk.hex()})"
514+
)
515+
516+
354517
# ---------------------------------------------------------------------------
355518
# WebSocket JSON-RPC client (async)
356519
# ---------------------------------------------------------------------------
@@ -1438,8 +1601,14 @@ def execute_swap(
14381601
# start_quotes (not as a follow-up call).
14391602
wollet = self.wallet_manager._get_wollet(wallet_name)
14401603
inputs = select_swap_utxos(wollet.utxos(), send_asset, send_amount)
1441-
recv_addr = str(wollet.address(None).address())
1442-
change_addr = str(wollet.address(None).address())
1604+
# `wollet.address(None)` returns the next-unused index but doesn't
1605+
# consume it, so calling it twice returns the same address. The
1606+
# dealer needs a distinct change scriptPubKey on the reverse-direction
1607+
# path (L-BTC → asset has a taker change output), so we pin the
1608+
# change to the index right after the receive.
1609+
recv_result = wollet.address(None)
1610+
recv_addr = str(recv_result.address())
1611+
change_addr = str(wollet.address(recv_result.index() + 1).address())
14431612

14441613
# SideSwap binds quote_id to the WebSocket session that issued
14451614
# start_quotes / get_quote — submitting taker_sign on a fresh
@@ -1477,6 +1646,10 @@ async def _full_swap() -> "SideSwapSwap":
14771646
# Accept the quote and request the half-built PSET on the same
14781647
# session so the server recognises us as the original taker.
14791648
get_quote_resp = await client.mkt_get_quote(int(quote_data["quote_id"]))
1649+
# The dealer ships ephemeral blinding secrets alongside the PSET so
1650+
# the taker can unblind the outputs paid to its addresses.
1651+
receive_ephemeral_sk = get_quote_resp.get("receive_ephemeral_sk")
1652+
change_ephemeral_sk = get_quote_resp.get("change_ephemeral_sk")
14801653
try:
14811654
await client.mkt_stop_quotes()
14821655
except Exception:
@@ -1561,15 +1734,19 @@ async def _full_swap() -> "SideSwapSwap":
15611734
recv_amount=recv_amount,
15621735
fee_tolerance_sats=fee_tolerance_sats,
15631736
fee_asset=policy_asset,
1737+
receive_ephemeral_sk=receive_ephemeral_sk,
1738+
change_ephemeral_sk=change_ephemeral_sk,
1739+
recv_addr=recv_addr,
1740+
change_addr=change_addr,
15641741
)
15651742
swap.status = "verified"
15661743
self.storage.save_sideswap_swap(swap)
15671744

15681745
signer = self.wallet_manager._signers[wallet_name]
1569-
import lwk
1570-
15711746
pset = lwk.Pset(pset_b64)
1747+
pset = wollet.add_details(pset)
15721748
signed = signer.sign(pset)
1749+
signed = wollet.finalize(signed)
15731750
signed_b64 = str(signed)
15741751
swap.status = "signed"
15751752
self.storage.save_sideswap_swap(swap)
@@ -1610,15 +1787,46 @@ def _verify_pset(
16101787
recv_amount: int,
16111788
fee_tolerance_sats: int,
16121789
fee_asset: Optional[str] = None,
1790+
receive_ephemeral_sk: Optional[str] = None,
1791+
change_ephemeral_sk: Optional[str] = None,
1792+
recv_addr: Optional[str] = None,
1793+
change_addr: Optional[str] = None,
16131794
) -> None:
16141795
"""Run the PSET balance check via LWK and raise on mismatch."""
16151796
import lwk
16161797

16171798
pset = lwk.Pset(pset_b64)
1799+
# Enrich the PSET with wallet UTXO/descriptor details so pset_details()
1800+
# can attribute the inputs we're spending to our wallet. SideSwap's
1801+
# PSETs ship sparse input descriptors; without this LWK sees neither
1802+
# the inputs nor the outputs as ours and returns an empty balances
1803+
# dict, forcing us to reconstruct it manually. add_details() is the
1804+
# supported way to backfill that context.
1805+
pset = wollet.add_details(pset)
16181806
details = wollet.pset_details(pset)
16191807
balances_dict_raw = details.balance().balances()
16201808
# LWK returns AssetId objects; normalise to hex strings keyed by asset id.
16211809
balances: dict[str, int] = {str(asset): int(amount) for asset, amount in balances_dict_raw.items()}
1810+
1811+
# Cryptographically verify each dealer-paid output: the wally helper
1812+
# rewinds the rangeproof using `receive_ephemeral_sk`, then recomputes
1813+
# the asset generator + Pedersen commitment from the unblinded
1814+
# (asset, abf, value, vbf) and asserts they match the on-chain
1815+
# commitments. This is the defense against a malicious dealer who
1816+
# puts honest explicit values in the PSET fields but commits to
1817+
# different values on-chain. LWK's `pset_details` (above) reads the
1818+
# explicit fields without verifying them — we rely on this wally
1819+
# check to ensure those fields are truthful. The helper raises
1820+
# PsetVerificationError on any mismatch; tests stub it out via
1821+
# `_patch_swap_layers`.
1822+
unblind_dealer_outputs(
1823+
pset_b64,
1824+
recv_addr=recv_addr,
1825+
change_addr=change_addr,
1826+
receive_ephemeral_sk=receive_ephemeral_sk,
1827+
change_ephemeral_sk=change_ephemeral_sk,
1828+
)
1829+
16221830
verify_pset_balances(
16231831
balances,
16241832
send_asset=send_asset,

0 commit comments

Comments
 (0)