Skip to content

Commit eefcedd

Browse files
committed
Fix Sideswap USDt to LBTC atomic swap - dealer's suggested recv_amount
1 parent 6d5bf8f commit eefcedd

4 files changed

Lines changed: 325 additions & 16 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",
@@ -33,6 +33,7 @@ dependencies = [
3333
"websockets>=12.0",
3434
"pillow>=10.0",
3535
"zxing-cpp>=2.2",
36+
"wallycore>=1.5.3",
3637
]
3738

3839
[project.optional-dependencies]

src/aqua/sideswap.py

Lines changed: 268 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -313,12 +313,29 @@ def verify_pset_balances(
313313
)
314314
fee_asset = fee_asset or send_asset
315315

316-
# Rule 1: receive amount is exactly what was agreed
316+
# Rule 1: receive amount matches what was agreed, modulo the on-chain
317+
# fee on the recv side. When recv_asset == fee_asset, the dealer's
318+
# quote_amount is gross of the network fee (and may also be gross of
319+
# the dealer's own service fee, which lands on the same asset), so the
320+
# taker actually receives slightly less. We mirror the send-side
321+
# tolerance: recv_delta must land in [recv_amount - fee_tolerance_sats,
322+
# recv_amount]. Receiving MORE than agreed is still rejected — that's
323+
# never legitimate and could indicate a confused server.
317324
recv_delta = balances.get(recv_asset, 0)
318-
if recv_delta != recv_amount:
325+
if recv_asset == fee_asset:
326+
min_recv_delta = recv_amount - fee_tolerance_sats
327+
else:
328+
min_recv_delta = recv_amount
329+
if recv_delta < min_recv_delta:
330+
raise PsetVerificationError(
331+
f"PSET delivers {recv_delta} sats of recv_asset {recv_asset[:8]}…, "
332+
f"less than the agreed {recv_amount} "
333+
f"(tolerance {recv_amount - min_recv_delta})"
334+
)
335+
if recv_delta > recv_amount:
319336
raise PsetVerificationError(
320337
f"PSET delivers {recv_delta} sats of recv_asset {recv_asset[:8]}…, "
321-
f"expected exactly {recv_amount} sats"
338+
f"more than the agreed {recv_amount}; refusing to sign"
322339
)
323340

324341
# Rule 2: send amount is within tolerance
@@ -351,6 +368,152 @@ def verify_pset_balances(
351368
)
352369

353370

371+
def unblind_dealer_outputs(
372+
pset_b64: str,
373+
*,
374+
recv_addr: Optional[str],
375+
change_addr: Optional[str],
376+
receive_ephemeral_sk: Optional[str],
377+
change_ephemeral_sk: Optional[str],
378+
addr_family: str = "lq",
379+
) -> dict[str, int]:
380+
"""Decode dealer-blinded outputs paid to the taker, via wallycore.
381+
382+
SideSwap's `mkt::*` dealer pays the taker via outputs blinded with
383+
ephemeral secrets it ships out-of-band in the `get_quote` response.
384+
The taker's wallet can't natively see these outputs because the
385+
blinding pubkey is the dealer's ephemeral, not the wallet's
386+
SLIP-77-derived one — `wollet.pset_details()` returns an empty
387+
balances dict for this PSET.
388+
389+
Implementation mirrors `sideswap_common::pset::swap_amount::get_swap_amount`
390+
in Rust: for each PSET output whose scriptPubKey matches our recv/change
391+
address, derive the CT shared secret via ECDH(ephemeral_sk,
392+
address_blinding_pk), then rewind the rangeproof to recover the
393+
explicit (asset, value).
394+
395+
Done in pure Python via `wallycore` (libwally). LWK's Python `TxOut.unblind`
396+
expects the receiver's blinding privkey and runs `ECDH(sk, output_nonce_pk)`;
397+
SideSwap's protocol needs `ECDH(dealer_ephemeral_sk, recv_addr_blinding_pk)`,
398+
which only libwally exposes at this layer.
399+
400+
Returns {asset_hex -> positive_sats} to be merged into the LWK
401+
balances dict before `verify_pset_balances` runs.
402+
403+
Returns empty when no ephemeral keys are supplied (peg flows / legacy
404+
orderbook flows that don't use ephemeral blinding).
405+
"""
406+
if not receive_ephemeral_sk and not change_ephemeral_sk:
407+
return {}
408+
409+
import wallycore as wally
410+
411+
# Decode addresses up front so we can match by scriptPubKey bytes.
412+
def _addr_spk_and_bpk(addr: Optional[str]) -> tuple[Optional[bytes], Optional[bytes]]:
413+
if not addr:
414+
return None, None
415+
import lwk
416+
spk_hex = str(lwk.Address(addr).script_pubkey())
417+
spk = bytes.fromhex(spk_hex)
418+
bpk = wally.confidential_addr_segwit_to_ec_public_key(addr, addr_family)
419+
return spk, bpk
420+
421+
recv_spk, recv_bpk = _addr_spk_and_bpk(recv_addr)
422+
change_spk, change_bpk = _addr_spk_and_bpk(change_addr)
423+
424+
psbt = wally.psbt_from_base64(pset_b64)
425+
num_outputs = wally.psbt_get_num_outputs(psbt)
426+
427+
extras: dict[str, int] = {}
428+
for i in range(num_outputs):
429+
try:
430+
spk = wally.psbt_get_output_script(psbt, i)
431+
except Exception:
432+
spk = b""
433+
434+
# Pick the right (ephemeral_sk, blinding_pk) pair for this output.
435+
# recv_addr and change_addr may collapse to the same string (the
436+
# wollet.address(None) gap-limit cursor doesn't advance between
437+
# calls) — that's harmless because we just try receive first.
438+
if recv_spk is not None and spk == recv_spk and receive_ephemeral_sk:
439+
sk_hex, bpk = receive_ephemeral_sk, recv_bpk
440+
elif change_spk is not None and spk == change_spk and change_ephemeral_sk:
441+
sk_hex, bpk = change_ephemeral_sk, change_bpk
442+
else:
443+
continue
444+
445+
ephemeral_sk = bytes.fromhex(sk_hex)
446+
try:
447+
value_commit = wally.psbt_get_output_value_commitment(psbt, i)
448+
asset_commit = wally.psbt_get_output_asset_commitment(psbt, i)
449+
rangeproof = wally.psbt_get_output_value_rangeproof(psbt, i)
450+
except Exception as e:
451+
raise PsetVerificationError(
452+
f"PSET output {i} missing commitment/rangeproof "
453+
f"(spk={spk.hex()}): {e!r}"
454+
) from e
455+
456+
try:
457+
# ECDH shared secret: SHA256 of the ECDH point. Liquid CT uses
458+
# this 32-byte hash directly as the rangeproof nonce.
459+
nonce_hash = wally.ecdh_nonce_hash(bpk, ephemeral_sk)
460+
# asset_unblind_with_nonce signature (wallycore Python wrapper):
461+
# (nonce_hash, proof, commitment, extra, generator)
462+
# -> (value, asset_bytes, abf_bytes, vbf_bytes)
463+
# The Python wrapper allocates the 3 output buffers internally
464+
# and prepends the C return (= the recovered uint64 value) as
465+
# the first tuple element. extra commitment data is the
466+
# scriptPubKey (matches the reference:
467+
# `output.script_pubkey.as_bytes()`).
468+
value_out, asset_buf, abf_buf, vbf_buf = wally.asset_unblind_with_nonce(
469+
nonce_hash, rangeproof, value_commit, spk, asset_commit,
470+
)
471+
# Coerce bytearray outputs to bytes for downstream wally calls
472+
# that are strict about argument types via SWIG.
473+
asset_out = bytes(asset_buf)
474+
abf_out = bytes(abf_buf)
475+
vbf_out = bytes(vbf_buf)
476+
except Exception as e:
477+
raise PsetVerificationError(
478+
f"failed to unblind dealer output {i} at script {spk.hex()}: {e!r}"
479+
) from e
480+
481+
# Recompute the asset generator and value commitment from the
482+
# unblinded (asset, abf, value, vbf) and check they match the
483+
# on-chain commitments. asset_unblind_with_nonce alone proves the
484+
# rangeproof is valid for the *commitment* but does not prove the
485+
# encrypted message (which carries the claimed asset_id + abf)
486+
# matches it. A malicious dealer could put a different asset in
487+
# the message than the one the commitment actually opens to;
488+
# without this check we'd believe the message. Matches the
489+
# `unexpected asset/value commitment` checks in the SideSwap
490+
# reference (sideswap_common::pset::swap_amount).
491+
expected_generator = wally.asset_generator_from_bytes(asset_out, abf_out)
492+
if expected_generator != asset_commit:
493+
raise PsetVerificationError(
494+
f"PSET output {i} asset commitment mismatch: dealer's "
495+
f"rangeproof message decodes to a different asset than the "
496+
f"on-chain asset commitment opens to (script {spk.hex()})"
497+
)
498+
expected_value_commitment = wally.asset_value_commitment(
499+
value_out, vbf_out, expected_generator,
500+
)
501+
if expected_value_commitment != value_commit:
502+
raise PsetVerificationError(
503+
f"PSET output {i} value commitment mismatch: dealer's "
504+
f"rangeproof message decodes to a different value than the "
505+
f"on-chain value commitment opens to (script {spk.hex()})"
506+
)
507+
508+
# libwally returns asset_id in **reversed** byte order vs the
509+
# display/hex form used everywhere else in this codebase.
510+
asset_hex = asset_out[::-1].hex()
511+
value = int(value_out)
512+
extras[asset_hex] = extras.get(asset_hex, 0) + value
513+
514+
return extras
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,6 +1734,10 @@ 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)
@@ -1569,13 +1746,57 @@ async def _full_swap() -> "SideSwapSwap":
15691746
import lwk
15701747

15711748
pset = lwk.Pset(pset_b64)
1749+
# Enrich with wallet UTXO/descriptor details so the signer
1750+
# recognises our input(s) and actually attaches signatures.
1751+
# SideSwap PSETs ship sparse input descriptors; without this
1752+
# the signer no-ops and the server rejects with "missing
1753+
# signature for <outpoint>". Same enrichment as in
1754+
# _verify_pset above. Test shims may not implement
1755+
# add_details — harmless fallback.
1756+
pset_pre_details_b64 = str(pset)
1757+
try:
1758+
pset = wollet.add_details(pset)
1759+
except Exception:
1760+
pass
1761+
pset_post_details_b64 = str(pset)
15721762
signed = signer.sign(pset)
1763+
signed_b64_pre_finalize = str(signed)
1764+
# LWK's Signer.sign adds partial signatures; SideSwap's
1765+
# server requires the taker's input(s) to be FULLY
1766+
# finalized (final_script_witness populated). Run the
1767+
# wallet finalizer to convert partial sigs into final
1768+
# witness data. The dealer's inputs won't have sigs yet
1769+
# (server signs them later) — finalize should partially-
1770+
# finalize: ours done, dealer's left alone.
1771+
try:
1772+
signed = wollet.finalize(signed)
1773+
except Exception as fin_err:
1774+
logger.warning("[SIDESWAP-FINALIZE] %s", fin_err)
15731775
signed_b64 = str(signed)
1776+
# Diagnostic: did anything change at each stage?
1777+
diag = (
1778+
f"len(pset_pre_details)={len(pset_pre_details_b64)} "
1779+
f"len(pset_post_details)={len(pset_post_details_b64)} "
1780+
f"len(signed)={len(signed_b64_pre_finalize)} "
1781+
f"len(finalized)={len(signed_b64)} "
1782+
f"details_changed={pset_pre_details_b64 != pset_post_details_b64} "
1783+
f"sign_changed={pset_post_details_b64 != signed_b64_pre_finalize} "
1784+
f"finalize_changed={signed_b64_pre_finalize != signed_b64}"
1785+
)
1786+
logger.warning("[SIDESWAP-SIGN-DIAG] %s", diag)
15741787
swap.status = "signed"
15751788
self.storage.save_sideswap_swap(swap)
15761789

15771790
# ---- Phase 4: submit on the SAME WS --------------------
1578-
sign_payload = await client.mkt_taker_sign(quote_id, signed_b64)
1791+
try:
1792+
sign_payload = await client.mkt_taker_sign(quote_id, signed_b64)
1793+
except Exception as submit_err:
1794+
# Surface the sign-step diagnostic so we can tell
1795+
# whether the server rejected because sign() was a
1796+
# no-op or because the signature was malformed.
1797+
raise SideSwapWSError(
1798+
f"{submit_err}\n[SIDESWAP-SIGN-DIAG] {diag}"
1799+
) from submit_err
15791800
txid = sign_payload.get("txid")
15801801
if not txid:
15811802
raise SideSwapWSError(
@@ -1610,15 +1831,56 @@ def _verify_pset(
16101831
recv_amount: int,
16111832
fee_tolerance_sats: int,
16121833
fee_asset: Optional[str] = None,
1834+
receive_ephemeral_sk: Optional[str] = None,
1835+
change_ephemeral_sk: Optional[str] = None,
1836+
recv_addr: Optional[str] = None,
1837+
change_addr: Optional[str] = None,
16131838
) -> None:
16141839
"""Run the PSET balance check via LWK and raise on mismatch."""
16151840
import lwk
16161841

16171842
pset = lwk.Pset(pset_b64)
1843+
# Enrich the PSET with wallet UTXO/descriptor details so pset_details()
1844+
# can attribute the inputs we're spending to our wallet. SideSwap's
1845+
# PSETs ship sparse input descriptors; without this LWK sees neither
1846+
# the inputs nor the outputs as ours and returns an empty balances
1847+
# dict, forcing us to reconstruct it manually. add_details() is the
1848+
# supported way to backfill that context.
1849+
try:
1850+
pset = wollet.add_details(pset)
1851+
except Exception:
1852+
# Older LWK or test shim may not support this; harmless fallback.
1853+
pass
16181854
details = wollet.pset_details(pset)
16191855
balances_dict_raw = details.balance().balances()
16201856
# LWK returns AssetId objects; normalise to hex strings keyed by asset id.
16211857
balances: dict[str, int] = {str(asset): int(amount) for asset, amount in balances_dict_raw.items()}
1858+
1859+
# Cryptographically verify each dealer-paid output: the wally helper
1860+
# rewinds the rangeproof using `receive_ephemeral_sk`, then recomputes
1861+
# the asset generator + Pedersen commitment from the unblinded
1862+
# (asset, abf, value, vbf) and asserts they match the on-chain
1863+
# commitments. This is the defense against a malicious dealer who
1864+
# puts honest explicit values in the PSET fields but commits to
1865+
# different values on-chain. LWK's `pset_details` (above) reads the
1866+
# explicit fields without verifying them — we rely on this wally
1867+
# check to ensure those fields are truthful. The returned dict is
1868+
# discarded: LWK is the source of truth for balances, wally is the
1869+
# source of truth for whether to trust LWK.
1870+
try:
1871+
unblind_dealer_outputs(
1872+
pset_b64,
1873+
recv_addr=recv_addr,
1874+
change_addr=change_addr,
1875+
receive_ephemeral_sk=receive_ephemeral_sk,
1876+
change_ephemeral_sk=change_ephemeral_sk,
1877+
)
1878+
except ImportError:
1879+
# wallycore not installed (e.g. minimal test image). Tests use a
1880+
# fake wollet with canned balances and don't need the crypto
1881+
# check. In production wallycore is a hard dependency.
1882+
pass
1883+
16221884
verify_pset_balances(
16231885
balances,
16241886
send_asset=send_asset,

0 commit comments

Comments
 (0)