|
54 | 54 | import json |
55 | 55 | import logging |
56 | 56 | import threading |
57 | | -import urllib.error |
58 | | -import urllib.request |
59 | | -from dataclasses import asdict, dataclass, field |
| 57 | +from dataclasses import asdict, dataclass |
60 | 58 | from datetime import UTC, datetime |
61 | 59 | from typing import Any, Optional |
62 | 60 |
|
| 61 | +import lwk |
| 62 | +import wallycore as wally |
63 | 63 | import websockets |
64 | 64 |
|
65 | 65 | logger = logging.getLogger(__name__) |
@@ -276,9 +276,14 @@ def verify_pset_balances( |
276 | 276 |
|
277 | 277 | Verification rules (any failure raises `PsetVerificationError`): |
278 | 278 |
|
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. |
282 | 287 | 2. The wallet must lose **at most** `send_amount + fee_tolerance_sats` of |
283 | 288 | `send_asset`. We allow a small overage to cover the network fee when it |
284 | 289 | comes from the same asset (which is typical for L-BTC sends, since the |
@@ -313,12 +318,31 @@ def verify_pset_balances( |
313 | 318 | ) |
314 | 319 | fee_asset = fee_asset or send_asset |
315 | 320 |
|
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. |
317 | 329 | 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: |
319 | 337 | raise PsetVerificationError( |
320 | 338 | 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" |
322 | 346 | ) |
323 | 347 |
|
324 | 348 | # Rule 2: send amount is within tolerance |
@@ -351,6 +375,145 @@ def verify_pset_balances( |
351 | 375 | ) |
352 | 376 |
|
353 | 377 |
|
| 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 | + |
354 | 517 | # --------------------------------------------------------------------------- |
355 | 518 | # WebSocket JSON-RPC client (async) |
356 | 519 | # --------------------------------------------------------------------------- |
@@ -1438,8 +1601,14 @@ def execute_swap( |
1438 | 1601 | # start_quotes (not as a follow-up call). |
1439 | 1602 | wollet = self.wallet_manager._get_wollet(wallet_name) |
1440 | 1603 | 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()) |
1443 | 1612 |
|
1444 | 1613 | # SideSwap binds quote_id to the WebSocket session that issued |
1445 | 1614 | # start_quotes / get_quote — submitting taker_sign on a fresh |
@@ -1477,6 +1646,10 @@ async def _full_swap() -> "SideSwapSwap": |
1477 | 1646 | # Accept the quote and request the half-built PSET on the same |
1478 | 1647 | # session so the server recognises us as the original taker. |
1479 | 1648 | 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") |
1480 | 1653 | try: |
1481 | 1654 | await client.mkt_stop_quotes() |
1482 | 1655 | except Exception: |
@@ -1561,15 +1734,19 @@ async def _full_swap() -> "SideSwapSwap": |
1561 | 1734 | recv_amount=recv_amount, |
1562 | 1735 | fee_tolerance_sats=fee_tolerance_sats, |
1563 | 1736 | 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, |
1564 | 1741 | ) |
1565 | 1742 | swap.status = "verified" |
1566 | 1743 | self.storage.save_sideswap_swap(swap) |
1567 | 1744 |
|
1568 | 1745 | signer = self.wallet_manager._signers[wallet_name] |
1569 | | - import lwk |
1570 | | - |
1571 | 1746 | pset = lwk.Pset(pset_b64) |
| 1747 | + pset = wollet.add_details(pset) |
1572 | 1748 | signed = signer.sign(pset) |
| 1749 | + signed = wollet.finalize(signed) |
1573 | 1750 | signed_b64 = str(signed) |
1574 | 1751 | swap.status = "signed" |
1575 | 1752 | self.storage.save_sideswap_swap(swap) |
@@ -1610,15 +1787,46 @@ def _verify_pset( |
1610 | 1787 | recv_amount: int, |
1611 | 1788 | fee_tolerance_sats: int, |
1612 | 1789 | 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, |
1613 | 1794 | ) -> None: |
1614 | 1795 | """Run the PSET balance check via LWK and raise on mismatch.""" |
1615 | 1796 | import lwk |
1616 | 1797 |
|
1617 | 1798 | 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) |
1618 | 1806 | details = wollet.pset_details(pset) |
1619 | 1807 | balances_dict_raw = details.balance().balances() |
1620 | 1808 | # LWK returns AssetId objects; normalise to hex strings keyed by asset id. |
1621 | 1809 | 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 | + |
1622 | 1830 | verify_pset_balances( |
1623 | 1831 | balances, |
1624 | 1832 | send_asset=send_asset, |
|
0 commit comments