Why we shipped SP1 instead of RISC Zero
Vanta's earliest design notes said 'RISC Zero zkVM.' Production ships SP1 + Plonky3. The swap was cheap because the privacy protocol is independent of the prover. Here is why we moved, what stayed the same, and what the FFI verifier looks like.
- FROM
- Dax the Dev <[email protected]>
- SOURCE
- https://blog.skill-issue.dev/blog/vanta_sp1_zkvm_circuits/
- FILED
- 2026-04-15 23:15 UTC
- REVISED
- 2026-04-23 19:31 UTC
- TIME
- 11 min read
- SERIES
- Vanta In Practice
- TAGS
In the original Vanta L1 post I wrote:
The ZK layer is in the
vanta/subtree, written in Rust against RISC Zero’s zkVM, running entirely outside the C++ core.
That sentence was true when I wrote it. It is no longer true. Production Vanta ships SP1 — Succinct Labs’ zkVM — with Plonky3 as the proof backend. RISC Zero was the early prototype. The migration happened before mainnet and has been the production prover for every consensus-critical proof since.
This post is the why of that change, the architectural choice that made the migration cheap, and what the verifier surface inside vantad actually looks like. The design rationale is also documented in papers/17-zkvm-engineering.md, which is the canonical version. This post is the practitioner-flavored version: what I had to change, what I didn’t, and what I’d warn the next person about.
The abstraction that made the swap cheap
The reason RISC Zero → SP1 was a code refactor and not an architectural rewrite is that the ZK code in Vanta is split into four layers and only two of them touch the zkVM SDK at all.
From the engineering paper:
Core logic (
vanta-core): Pure Rust library containing the transfer validation function, domain-separated commitment construction, nullifier derivation, SMT membership proofs, and conservation law checks. This library has no dependency on any zkVM. It compiles to native x86, to ARM, and to RISC-V. It is the same code whether it runs inside a zkVM guest, inside a test harness, or on a developer’s laptop.Guest program (
vanta-circuits/methods/guest/): A thin wrapper that reads private inputs from the zkVM host, callsvalidate_transfer()fromvanta-core, and commits the public outputs (TransferPublicInputs:smt_root,input_commitments,nullifiers,commitments,value_balance) to the journal. The guest program is a few dozen lines of Rust. Its only zkVM-specific code is the I/O calls (sp1_zkvm::io::read()andsp1_zkvm::io::commit()).Host prover (
vanta-circuits/src/prover.rs): The component that sets up the proving environment, feeds private inputs to the guest, and invokes SP1 to generate a compressed Plonky3 proof.FFI verifier (
vanta-verifier-ffi): A Rust static library compiled tolibvanta_verifier.aand linked directly intovantad.
The split means that the cryptographic protocol — commitment scheme, nullifier scheme, conservation law, SMT membership proof verification — lives in vanta-core and has no zkVM dependency. The same Rust source compiles to:
- native x86_64 for unit tests on my laptop
- RISC-V for either zkVM’s guest target
- ARM for the iOS wallet (eventually)
The guest program in vanta-circuits/methods/guest/ is a thin shim. Its only zkVM-specific code is sp1_zkvm::io::read() to pull private inputs and sp1_zkvm::io::commit() to commit public outputs to the proof journal. Swapping to a different zkVM is a few lines of code in a file that is a few dozen lines long. It is not an architectural change.
That split is why I’m comfortable saying we could swap zkVMs again without an architectural rewrite. The chain doesn’t know what proof system it’s running; it knows how to verify a journal.
What the host prover looks like
Here’s the actual prover from vanta-circuits/src/prover.rs:
pub fn prove_transfer(
private_inputs: &TransferPrivateInputs,
smt_root: &Hash,
) -> Result<(SP1ProofWithPublicValues, TransferPublicInputs)> {
let pi = private_inputs.clone();
let root = *smt_root;
let result = std::thread::spawn(move || -> Result<...> {
let mut stdin = SP1Stdin::new();
stdin.write(&pi);
stdin.write(&root);
let client = ProverClient::from_env();
let pk = client.setup(GUEST_ELF.clone())?;
// Use compressed proofs (no Docker dependency).
// Groth16 wrapping requires Docker + Gnark — enable later for production.
let proof = client.prove(&pk, stdin).compressed().run()?;
let mut proof_clone = proof.clone();
let public_inputs: TransferPublicInputs = proof_clone.public_values.read();
Ok((proof, public_inputs))
})
.join()
.map_err(|e| anyhow::anyhow!("proof thread panicked: {:?}", e))??;
Ok(result)
}
A couple of details worth pulling out.
Compressed proofs, not Groth16-wrapped. SP1 supports a Groth16 wrapping step that shrinks the receipt from ~1.27 MB to ~260 bytes. v2.0 ships compressed Plonky3 instead because Groth16 wrapping requires a Docker + Gnark toolchain that I did not want in the consensus-critical path at launch. Smaller proofs are a future release.
Spawn-on-thread because of tokio. SP1’s blocking ProverClient creates its own tokio runtime. If you call it from inside another tokio runtime — which is what happens when the Axum wallet or the Tauri app invokes the prover — you get a “runtime in runtime” panic. Spawning the prove call on a dedicated std::thread and joining it cleanly side-steps that.
This is the kind of footgun that’s invisible at unit-test time and very visible at integration-test time. It cost me an afternoon. Documented now.
include_elf! macro. The guest binary is embedded into the host binary at compile time:
pub static GUEST_ELF: Elf = include_elf!("vanta-guest");
That means the wallet binary (or the Tauri host) carries the guest ELF along with the proving stack. No separate file to ship, no path resolution. This was one of the SP1 ergonomic wins over RISC Zero — the include macro removes a class of “where’s the guest” bugs.
What stayed identical
The cryptographic protocol did not change between RISC Zero and SP1. From the engineering paper:
4.1 The Privacy Model Is Application-Layer, Not Prover-Layer
Vanta’s privacy guarantees come from four cryptographic constructions, all of which are implemented in
vanta-coreand are independent of the proof system:Commitment hiding. A note commitment is computed as:
The hiding property — the fact that an observer cannot determine the committed values from the commitment — comes from the randomness and the preimage resistance of the hash function. This has nothing to do with the proof system. Whether the commitment is computed inside SP1, inside another zkVM, or on a napkin, the hiding property is identical.
This is the load-bearing insight. The proof system is an attestation layer. It says “I correctly executed this Rust program against these private inputs and the public outputs are these.” It does not contribute to the soundness of the commitment scheme, the unlinkability of the nullifier, or the integrity of the SMT membership proof. Those properties live in the application code that runs inside the proof.
Why SP1 won
The full case is in the engineering paper. The short version:
Speed. SP1 generates compressed Plonky3 receipts for Vanta’s transfer workload in 30–60 seconds on a modern multi-core CPU. RISC Zero in our early benchmarks was slower — comparable on simple programs, materially slower once domain-separated SHA-256 was the dominant operation. SP1’s SHA-256 precompile is the difference; it substitutes a hand-optimized circuit for the operation rather than proving SHA-256 instruction-by-instruction through the RISC-V execution trace.
Trusted-setup posture. Plonky3 is a hash-based STARK. It is post-quantum-resilient (under Grover’s algorithm, 256-bit hash → 128-bit effective security is still strong) and it has no trusted setup, no SRS, no powers-of-tau ceremony. Anyone can compile the prover and verifier from source and run them without trusting a third party to have generated setup parameters correctly.
This was a hard requirement for Vanta. The engineering paper is opinionated about it:
a permanent contingent backdoor against a single participant’s operational discipline is not a substitute for transparent cryptography.
That sentence is the reason we don’t ship Groth16, despite Groth16’s ~260-byte proofs. Groth16’s per-circuit trusted setup requires a multi-party-computation ceremony where the security assumption is “at least one participant was honest and destroyed their share.” We didn’t want to carry that assumption.
SDK ergonomics. SP1’s include_elf!, SP1Stdin, ProverClient API, and the cargo-prove tool are well-typed and well-documented. The development cycle is fast and doesn’t require specialized tooling beyond the Rust toolchain plus the SP1 target.
Active development + funding. Succinct (the company) has raised over $55M and ships monthly releases with measurable performance improvements. SP1 is MIT/Apache-2.0, used in production by multiple chains. Lower abandonment risk than smaller projects.
GPU acceleration available. SP1 has CUDA-based GPU proving. Doesn’t matter for the wallet path (we’re not putting GPUs in user laptops) but matters for the proving-network and miner-prover roles.
What the FFI verifier looks like
The FFI lives in vanta/vanta-verifier-ffi/ and compiles to libvanta_verifier.a. It exposes two functions to C++:
bool vanta_verify_and_decode(const uint8_t *proof_bytes, size_t proof_len, VantaJournal *out);
bool vanta_decode_journal(const uint8_t *bytes, size_t len, VantaJournal *out);
The C++ consensus engine in src/script/interpreter.cpp calls vanta_verify_and_decode from the witness-v2 branch. It hands over a byte slice (the SP1 receipt embedded in witness.stack[0]) and receives back a boolean and a populated VantaJournal. The VantaJournal is the 440-byte struct from the engineering paper:
typedef struct {
uint8_t smt_root[32];
uint32_t input_commitment_count;
uint8_t input_commitments[VANTA_MAX_SLOTS][32];
uint32_t nullifier_count;
uint8_t nullifiers[VANTA_MAX_SLOTS][32];
uint32_t commitment_count;
uint8_t commitments[VANTA_MAX_SLOTS][32];
int64_t value_balance;
} VantaJournal;
The C++ doesn’t deserialize the proof. It doesn’t know the proof system. It looks at 32-byte hashes and a signed int64_t, and:
- checks the
input_commitmentsagainst the spent UTXO’sOP_2 PUSH32 <commitment>script - checks the
nullifiersagainst the chainstate nullifier set (the post on this) - checks the
smt_rootagainst the chain’s currently committed state root - checks
value_balancefor sign + balance against any transparent outputs in the transaction
That’s the whole consensus contract. The proof verified bit either is or isn’t set. Everything else is byte arithmetic.
The minimal-FFI design is what makes the audit story tractable. A fuzzer can hammer the boundary and you’ll know if vanta_verify_and_decode ever populates a journal that the proof didn’t actually attest to. The Rust side is the thing that needs the careful audit; the C++ side is reading bytes.
What I learned about zkVMs by switching
A few things I’d tell my past self.
Decouple the cryptographic protocol from the proof system. Hard. It is enormously tempting to put commitment construction in the guest program. Don’t. Put it in vanta-core, call it from the guest, and call it from native unit tests. The native unit tests are how you find off-by-one byte ordering bugs without paying 30s/proof to find them.
The journal is the public contract. Whatever public outputs you commit to the proof journal are your interface. Adding a field is a forking change for the verifier. Removing one is too. Plan the journal layout the way you’d plan a serialised network message: explicit, versioned, ABI-stable.
Compressed proofs are large. ~1.27 MB on Vanta. That meant raising MAX_STANDARD_TX_WEIGHT to MAX_BLOCK_WEIGHT so a single witness-v2 spend fits in one transaction. Plan the chain parameters around the proof size you ship with, and budget for the eventual Groth16-wrapping shrink.
zkVM benchmarks lie about your workload. Generic prover benchmarks measure simple programs. Yours is not simple. Measure your actual circuit against the candidates before deciding. SP1’s SHA-256 precompile dominated our workload; if your hashing is Poseidon over a different field, your numbers will look different.
What’s next
The roadmap from the papers calls out a few things that have not shipped yet:
- Groth16 wrapping to bring receipt size from 1.27 MB to ~260 bytes. Deferred for the Docker dependency reason above.
- Poseidon migration to replace SHA-256 in the commitment scheme with a ZK-friendlier hash. Performance win, no security change.
- GPU proving distribution. SP1 supports CUDA but we haven’t shipped a wallet path that uses it; the lift is mostly UX (how does the user point at a GPU?) and packaging.
The architectural property I want to keep regardless of which of these lands: the chain doesn’t know what proof system it’s running, it knows how to verify a journal. That’s the property that lets us swap zkVMs again. It’s the property that lets the eventual full-Rust node rewrite ship without a forking change. It’s the property that, six years from now, lets us swap the proof backend for whatever has won the 2032 cryptography landscape.
Further reading
vanta/vanta-circuits— host prover + guest programvanta/vanta-verifier-ffi— the static library linked into vantadpapers/17-zkvm-engineering.md— full design rationale- Vanta: a Bitcoin fork with ZK at consensus — the chain the verifier protects
- L1 nullifier sets: enforcing no-double-spend at consensus — what the verifier’s nullifiers feed into
- SP1 docs — the prover we ship
- Plonky3 repo — the proof backend