Skip to content

Latest commit

 

History

History
152 lines (99 loc) · 8.55 KB

File metadata and controls

152 lines (99 loc) · 8.55 KB

Split Proof Design

Split-Prove divides a zswap spend into wallet-local proof work and server proof work. The server can prove the expensive spend circuit without receiving the wallet's raw zswap secret key.

Overview

flowchart LR
    subgraph LOCAL["Local wallet / client"]
        W["sk, r"]
        C["coin + Merkle witness"]
        A["π_attest<br/>public: pk, C_sk"]
        S["π_sk<br/>public: pk, C_sk,<br/>nullifier, coinBindingTag"]
        H["split handoff<br/>coin metadata, pk,<br/>coinCommitment, nullifier,<br/>coinBindingTag, π_attest, π_sk"]

        W --> A
        W --> S
        C --> S
        A --> S
        A --> H
        S --> H
        C --> H
    end

    subgraph SERVER["Proof server"]
        P["π_spend<br/>public: pk, coinCommitment,<br/>nullifier, coinBindingTag"]
    end

    subgraph NODE["Node"]
        V["Verify π_attest, π_sk, π_spend"]
        M["Match shared public inputs"]
        D{"Accept?"}
        OK["Admit"]
        NO["Reject"]

        V --> M --> D
        D -- yes --> OK
        D -- no --> NO
    end

    H -- "public handoff, no sk/r" --> P
    P -- "split proof bundle<br/>(π_attest, π_sk, π_spend, shared inputs)" --> V
Loading

Three proofs sit in the bundle:

  • π_attest (once, at wallet setup) — proves the wallet knows sk, that pk = H(sk), and that C_sk commits to the same sk with wallet-only randomness r.
  • π_sk (per spend) — proves the wallet knows the same sk committed in C_sk, that the canonical zswap nullifier was derived from that sk and the coin, and that the coin-binding tag was derived from the coin and pk.
  • π_spend (per spend, server) — proves the coin commitment was built from the coin and pk and matches the Merkle path leaf, that the declared nullifier is inserted, that the value commitment and spend rules are valid, and re-discloses the same coinBindingTag.

The key optimization: the costly pk = H(sk) relation runs once in the attestation, not on every spend. Per-spend the wallet only opens the cheap Poseidon C_sk commitment and proves the canonical nullifier relation, preserving normal double-spend semantics.

Proof Boundary

Wallet setup

The wallet generates pi_attest once per key. It proves:

  • the wallet knows sk;
  • pk was derived from that sk;
  • C_sk commits to the same sk using wallet-only randomness r.

This moves the expensive pk = H(sk) relation out of the per-spend client proof.

Per spend, client side

The wallet generates pi_sk for each spend. It proves:

  • the wallet knows the same sk committed in C_sk;
  • the canonical zswap nullifier was derived from that sk and the coin;
  • the coin-binding tag was derived from the coin and pk.

The client proof uses the normal zswap nullifier relation, so a stock spend and split spend of the same coin collide in the same nullifier set.

Per spend, proof server side

The proof server generates pi_spend. It proves:

  • the coin commitment was built from the coin and pk and matches the Merkle path leaf;
  • the coin commitment is in the Merkle tree;
  • the declared nullifier is inserted;
  • the same coinBindingTag the client proof emitted is re-disclosed;
  • the value commitment and spend rules are valid.

The server receives public values, coin metadata, Merkle data, and proofs. It does not receive sk or the attestation blinding value r.

Transaction submission

The patched node verifies pi_attest, pi_sk, and pi_spend. It also byte-equates the shared public values across the proofs that emit them:

  • pk and C_sk across pi_attest and pi_sk;
  • pk, nullifier, and coinBindingTag across pi_sk and pi_spend.

coinCommitment is now emitted only by the server proof — the server computes canonical H(coin, pk) and asserts it equals the Merkle path leaf — so it is not a cross-proof shared value. The transaction is accepted only if all proofs verify and all shared values agree.

Why The Wallet Proof Is Smaller

The client does less repeated work because C_sk is opened with a cheap Poseidon/transient-hash relation instead of recomputing the expensive pk = H(sk) SHA-256 relation on every spend.

The expensive pk = H(sk) proof is paid once in the wallet attestation. Each spend only proves that the same committed sk is used to derive the nullifier and coin-binding tag.

The current proof-side design commits to an injective limb encoding of the 32-byte secret key instead of treating sk as one field/scalar. That avoids scalar-reduction ambiguity.

Common Questions

Does split proving preserve normal double-spend semantics?

Yes. The client proof derives the canonical zswap nullifier, so a stock spend and a split spend of the same coin collide in the same nullifier set.

What prevents the server from changing pk, nullifier, or coin-binding data?

The ledger verifies shared public inputs across the proofs. Tampering with pk, nullifier, coinBindingTag, or C_sk breaks the cross-proof byte-equality check and admission fails.

Why add a wallet attestation proof?

It avoids recomputing pk = H(sk) inside every per-spend client proof while still binding the wallet key to the per-spend proof.

Is the wallet attestation reusable?

Yes. It is intended as a one-time-per-wallet or one-time-per-key proof. The per-spend proof opens the same C_sk.

Why use Poseidon for C_sk?

It was much cheaper in Compact than the Pedersen commitment option while still giving the binding needed for this PoC. See spike-results.md.

Does the server ever receive the raw sk?

No. The server receives public values and proofs, but not the secret key or attestation blinding value.

Why does this require a patched ledger?

Stock nodes do not know how to decode or verify the split proof bundle, wallet attestation proof, or client derivation proof.

Is this recursive proof aggregation?

No. The current approach verifies multiple proofs directly in ledger admission logic instead of recursively aggregating them into one proof.

What is the main compatibility tradeoff?

The outer zswap Input/Offer serialization is unchanged, but the opaque proof envelope was bumped to midnight:zswap-split-proof-bundle:v3 and now carries three proofs plus four public-input fields. Nodes and indexers must be rebuilt against the patched ledger so they hold the matching verifier keys (SPEND_SPLIT_VK, CLIENT_DERIVATION_VK, WALLET_ATTESTATION_VK). v2 bundles fail closed against v3 nodes.

Approaches Tried And Discarded

  • A two-proof split where the proof server only verified clientDerivationProof off-chain before producing spend-split. This was not enough because ledger admission still trusted the server gate; someone could bypass the endpoint unless the ledger also verified the client proof.
  • Plain fallback verifier dispatch: try stock SPEND_VK, then fallback to SPEND_SPLIT_VK. This made nodes accept split proofs, but it did not carry or verify the extra client-proof linkage needed for sound split admission.
  • Keeping coinCommitment as a wallet/client-proved public output. This was moved into the server spendSplitUser circuit so the server proof computes canonical H(coin, pk) and checks it against the Merkle path leaf.
  • Using skCommitment as a placeholder witness in the server split circuit. It pinned almost nothing by itself, so the design shifted to explicit shared public inputs: pk, nullifier, coinBindingTag, and later C_sk.
  • Using a split-only Poseidon nullifier. It improved client proof cost, but broke stock-vs-split double-spend semantics because normal wallet spends use the canonical zswap nullifier. The fix was to make split spends prove the canonical wallet nullifier.
  • Recomputing pk = H_persistent(sk) inside every per-spend client proof. This was sound but too expensive; the client proof became almost as heavy as the server proof. It was replaced by a one-time wallet attestation plus a cheaper per-spend opening.
  • A v2 split bundle without wallet attestation. It could verify per-spend derivation, but did not cheaply bind the per-spend sk back to the canonical pk = H(sk) without paying that hash every spend. The v3 bundle adds attestation_proof.
  • A Pedersen-style C_sk commitment. It was considered for the wallet attestation link, but was much more expensive than the Poseidon/transient-hash commitment in Compact, so the PoC moved to Poseidon C_sk.
  • Treating sk as a single field/scalar for commitment. That risks scalar-reduction ambiguity, so the final proof-side design uses an injective limb encoding of the 32-byte secret before committing and checking it.