Skip to content
PQ Stealth Addresses

A SPHINCS- C13 spend authorized by a zero-knowledge proof, over frame transactions

2026-09-03. Companion artifacts: noir/sphincs-c13-verify/ (circuit + generate_prover.py), js-client/contracts/src/frames/\{Stealth8141ZkAccount, Stealth8141ZkFactory\}.sol + frames/zk/SphincsC13HonkVerifier.sol (bb-generated), js-client/contracts/test/ZkAccount.t.sol, ui/scripts/\{deploy-frames-zk.mjs, e2e-frames-zk.ts\}. Follows D-022 (prefix-deploy-native-keys.md) and D-018's open item "remove spend-time linkability by binding the SPHINCS- key through a ZK proof".

1. Question and the user's framing

Given that the direct ML-DSA verify costs 13.7–14.9 M gas and precompiles are not the way: prove the post-quantum verification inside a circuit and spend with the proof. The pairing-based backend (UltraHonk on BN254/KZG) is not quantum-sound, but the statement is fixed, so the backend can be swapped for a hash-based one later. The user's choice for the spike: SPHINCS- C13 verification in-circuit, pairing now / STARK later with the swap designed in, measured on the frames testnet.

2. What the circuit proves

Public inputs: message (the frame tx sig_hash, TXPARAM 0x08) and the D-018 commitment = keccak256("pq-stealth/sphincs-c13/commit/v0" ‖ pk ‖ opener), each as two 128-bit field elements (4 public inputs). Private: pkSeed, pkRoot, opener, the 3,688-B signature, and a chain-order hint. The circuit opens the commitment and runs the exact SPHINCs-C13Asm.sol verification (Verity-refined Yul, D-018): keccak-only tweakable hash over seed ‖ ADRS ‖ payload, FIPS 205 uncompressed ADRS, FORS+C forced-zero and WOTS+C constant-sum as assertions.

Because the key is a witness, a spend reveals nothing about which recipient key signed it: the spend-time linkability of the commit signer (every spent address of a recipient becomes linkable once the commitment is opened on chain) is gone. The account address is CREATE2(factory, 0, initcode(commitment, verifier, frameCtx)), sender-computable from the meta-address key and the KEM shared secret exactly as before.

Circuit-shape decision. WOTS+C makes the total chain length fixed (L·(w−1) − target_sum = 43·7 − 208 = 93 steps per layer) while each chain's length is data-dependent. A naive circuit hashes 7 steps for every chain (301 per layer, 2.3× the work). Instead the prover supplies chain_hint[layer][93], the chain each step belongs to; the circuit hashes the steps in that order with a per-chain running position and asserts every chain received exactly 7 − digit steps. The digit-sum assertion guarantees Σ(7 − digit) = 93, so all 93 steps must land in real chains. Result: the same 335 keccak calls as the Solidity verifier.

3. Measured

Toolchain: nargo 1.0.0-beta.19, bb 4.2.0 (aztec bundle), noir-lang/keccak256 v0.1.2, forge 1.4.1, Apple silicon laptop.

itemvalue
keccak256 in UltraHonk, 64-B input, one call (incl. I/O)40,626 gates
eight chained calls142,162 gates → ≈ 14.5 k gates per keccak-f
C13 verify circuit (335 keccak calls, 358 permutations)6,382,753 gates (89,111 ACIR opcodes; 2^23 circuit)
proving key18 s, 6.7 GB
proof (EVM target, ZK)11,456 B, 4 public inputs; prove 50–56 s, peak 8.4–10.9 GB (§3b)
Stealth8141ZkAccount.executeFrame with proof, forge3,526,632 gas; live 4,174,008 (§3b)
bb Solidity verifier, 2^16 test circuit, 32 public inputs, ZK flavour2,989,292 gas, runtime code 26,317 B
same, no-ZK flavourproof 7,776 B (verifier not measured: the generator emits the ZK contract regardless)
Python re-implementation of the verifier on the three fixture vectorsvalid; 93 + 93 hint steps

3b. Live run (frames testnet, chain 81410, 2026-09-03)

Deployed (ui/scripts/deploy-frames-zk.mjs, ui/public/frames-zk-deployment.json): ZKTranscriptLib 0x48b90e15…2b361 (6,577 B, 10.4 M gas — bb's verifier calls it as an external library, so it is a second contract to link), HonkVerifier 0xF01ecC1d…5BBfb (26,318 B, 40.9 M gas — accepted: the frames testnet does not enforce EIP-170's 24,576 B), Stealth8141ZkFactory 0x303CB317…9826.

ui/scripts/e2e-frames-zk.ts, fixture recipient, fresh ML-KEM-768 encapsulation:

stepresult
receive: pay + announce, one 0x06 tx0xe1df05ee…b6cdb, 295,489 gas
scan: decapsulate, re-derive commitment → accountmatch, 0x9cF91490…ac4F7
C13 sign over the tx's sig_hash (Rust signer-c13)3.3 s
witness (nargo execute)0.5 s
UltraHonk prove, 2^23, EVM target (proving key 17 s of it)50.4 s, peak 10.9 GB, proof 11,456 B
spend: [VERIFY(sponsor), DEFAULT(factory.createAccount), DEFAULT(account.executeFrame)], 11,908 B raw0x0fef8acd…2bc1e, status 1, block 85365
frame receiptssponsor VERIFY 100 · account deploy 37,001 · proof verify + transfer 4,174,008
total gas billed (execution + state)9,249,124

Two lessons from the first attempt (0x946ad48e…c0afd, status 0): a 4 M execution-gas spend frame died at 3,938,060 = exactly 63/64 of its limit, i.e. out of gas inside the verifier staticcall — forge's 3.53 M undercounts what this chain charges for copying the 11 KB proof (SIGDATACOPY + ABI return), two cold contracts, and the EIP-8037 schedule; 9 M is comfortable and the total stays far under the 16.78 M cap. And the account deploy fits inside the spend transaction (3,099 B runtime, 37 k execution gas), unlike the ML-DSA route's 43 M type-2 deploy — the whole first spend is one frame transaction again.

Two consequences stand out before any testnet number:

  • The proof is cheaper than ML-DSA and dearer than SPHINCS- itself. ~3 M for the Honk verify versus 13.7 M for ML-DSA on the EVM, but the direct C13 verify is 188 k. For SPHINCS- the ZK route is a privacy purchase (unlinkability), not a cost cut; for ML-DSA it would be a cost cut, but that is the MLWE-ownership statement of D-008, not this circuit.
  • The bb verifier does not fit EIP-170. 26.3 kB runtime against the 24,576-B limit. Mainnet needs EIP-7907 (removed from Fusaka, candidate for Glamsterdam, not final). The frames testnet runs Glamsterdam+Bogota; whether it enforces 170 is exactly what the deploy step tests.

4. Backend question: pairing now, STARK later

The account holds the verifier as a rotatable pointer (setVerifier, callable only by the account itself, i.e. through an authorized executeFrame covered by sig_hash). The statement and its public-input layout are the contract; the backend is replaceable. What that does and does not buy:

  • Soundness of KZG/BN254 rests on discrete log. A CRQC forges proofs, so a Honk account is exactly as quantum-safe at rest as an ECDSA account: not at all. The swap must happen before a CRQC; after one, the attacker can authorize the swap too. Same posture as EIP-7702 accounts today, stated plainly.
  • A hash-based backend (FRI/STARK, or a binary-field system) is plausibly PQ-sound and transparent, but its EVM verifier is the cost: hash-heavy, typically 1–5 M+ gas with keccak Merkle paths and proofs of tens to hundreds of kB; with D-015's standard-hash policy there is no Poseidon shortcut. The verifier contract must also fit 170, which today means a wrapped/recursive proof.
  • Wrapping does not transfer post-quantum soundness. A PQ inner proof folded into a pairing-based outer proof (the SP1/Groth16 "EVM wrapper" pattern) is only as PQ as the outer proof. The on-chain verifier is the trust root.

Flock (succinctlabs/flock, asked about 2026-09-03): "a prover and verifier for R1CS-over-GF(2) statements, built on a zerocheck + lincheck PIOP with a multilinear PCS (Ligerito) over the binary field F₂₁₂₈" — hash-based, transparent, plausibly PQ-sound, aimed at "batched hash statements (BLAKE3 and SHA-256 compressions)" with "a recursion tower that folds proofs into proofs"; 305 k SHA-256/s on a 32-core Threadripper; Rust API only; research stage; no keccak encoder and no EVM verifier. For this statement it would need a Keccak-f R1CS-over-GF(2) encoder (Keccak is Boolean-native: 38,400 ANDs per permutation, 358 permutations ≈ 14 M AND constraints, well inside its throughput) and, for on-chain use, either an EVM Ligerito verifier (none exists; proofs are large) or a wrapper — which brings back the paragraph above. Verdict: the right shape of prover for a hash-based migration target, not something that can be plugged into the account today; watch it alongside EIP-8288 (protocol-level STARK aggregation).

5. Where this leaves the routes

routeper-spend on-chainlinkabilityPQ-sound at reststatus
direct C13 verify (D-018)188 kkey revealed on spendyes (hash-based)live
C13 in-circuit, UltraHonk (this)≈ 3 M + callnoneno (until backend swap)spike
C13 in-circuit, hash-based backend1–5 M+, verifier TBDnoneyesno EVM verifier yet
ML-DSA direct (D-022)13.7 Mkey revealedyes (lattice)live
EIP-8164 native key (D-022)50 kkey in code at receiveyesDraft, no client

5b. Follow-up: prove the secret instead, in the browser (D-025)

The C13 statement is too large for browser proving (bb.js is wasm32, 4 GB cap; the circuit needs 8–11 GB). The user's redirection for the demo — prove the spending secret, not a signature — makes the statement two keccak calls: noir/preimage-ownership, 40,626 gates, same public-input layout, same account. Measured: witness ~50 ms, UltraHonk prove 0.65–1.3 s with bb.js in WASM, verifier 26,317 B, executeFrame 2,942,272 gas in forge; live on the frames testnet 0xa598ec41…1fe65 with frames [100, 37,001, 3,544,107]. The demo UI proves in a web worker with no server component. Everything in §4 about the backend still applies; the statement shrank, the trust root did not move.

6. Open

  • EIP-170 on the frames testnet and on mainnet (EIP-7907).
  • Prover on a phone/browser: 6.4 M gates, 2^23, ~7 GB proving key — laptop-class today. The no-ZK flavour and a recursion step are the obvious levers.
  • Grindable R (upstream's own review note) is unchanged by proving; the circuit inherits the C13 security posture as is.
  • Anonymity set: every ZK account shares one verifier and one commitment domain, so the set is "all ZK SPHINCS- stealth accounts", the same shape as D-022's "all 8164 accounts" remark.