@@ -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