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.
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
Three proofs sit in the bundle:
π_attest(once, at wallet setup) — proves the wallet knowssk, thatpk = H(sk), and thatC_skcommits to the sameskwith wallet-only randomnessr.π_sk(per spend) — proves the wallet knows the sameskcommitted inC_sk, that the canonical zswap nullifier was derived from thatskand the coin, and that the coin-binding tag was derived from the coin andpk.π_spend(per spend, server) — proves the coin commitment was built from the coin andpkand matches the Merkle path leaf, that the declared nullifier is inserted, that the value commitment and spend rules are valid, and re-discloses the samecoinBindingTag.
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.
The wallet generates pi_attest once per key. It proves:
- the wallet knows
sk; pkwas derived from thatsk;C_skcommits to the sameskusing wallet-only randomnessr.
This moves the expensive pk = H(sk) relation out of the per-spend client proof.
The wallet generates pi_sk for each spend. It proves:
- the wallet knows the same
skcommitted inC_sk; - the canonical zswap nullifier was derived from that
skand 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.
The proof server generates pi_spend. It proves:
- the coin commitment was built from the coin and
pkand matches the Merkle path leaf; - the coin commitment is in the Merkle tree;
- the declared nullifier is inserted;
- the same
coinBindingTagthe 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.
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:
pkandC_skacrosspi_attestandpi_sk;pk,nullifier, andcoinBindingTagacrosspi_skandpi_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.
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.
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.
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.
It avoids recomputing pk = H(sk) inside every per-spend client proof while still binding the wallet key to the per-spend proof.
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.
It was much cheaper in Compact than the Pedersen commitment option while still giving the binding needed for this PoC. See spike-results.md.
No. The server receives public values and proofs, but not the secret key or attestation blinding value.
Stock nodes do not know how to decode or verify the split proof bundle, wallet attestation proof, or client derivation proof.
No. The current approach verifies multiple proofs directly in ledger admission logic instead of recursively aggregating them into one proof.
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.
- A two-proof split where the proof server only verified
clientDerivationProofoff-chain before producingspend-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 toSPEND_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
coinCommitmentas a wallet/client-proved public output. This was moved into the serverspendSplitUsercircuit so the server proof computes canonicalH(coin, pk)and checks it against the Merkle path leaf. - Using
skCommitmentas 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 laterC_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
skback to the canonicalpk = H(sk)without paying that hash every spend. The v3 bundle addsattestation_proof. - A Pedersen-style
C_skcommitment. 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 PoseidonC_sk. - Treating
skas 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.