- Solana has the primitives to verify Groth16 zero-knowledge proofs on-chain — the
alt_bn128syscalls handle BN254 pairing, andsol_poseidonhandles the hashing. Verification typically costs 170K–500K compute units depending on circuit complexity and public-input count; budget explicitly. - Noir is the cleanest way to write a circuit in 2026, but its default backend (Barretenberg/UltraHonk) does not produce Groth16 proofs — you must use Sunspot to compile Noir ACIR to Gnark-Groth16 and generate a Solana verifier program.
- The Poseidon in Noir’s stdlib is not byte-compatible with the
sol_poseidonsyscall. Use the noir-lang/poseidon library instead — it matches circomlib, which is what the syscall implements. - This guide walks two circuits end to end: a trivial
x != yassertion to exercise the pipeline, and a sparse-Merkle-tree blacklist exclusion proof that demonstrates the real-world pattern of a Solana program doing a CPI to the verifier. - All code is verified against solana-foundation/noir-examples (the canonical reference) and reilabs/sunspot as of April 2026.
Why verify zero-knowledge proofs on Solana
A zero-knowledge proof lets a prover convince a verifier that some computation was performed correctly, without revealing the private inputs. On Solana, this unlocks patterns that are structurally impossible with plain accounts:- Private membership — prove you hold a credential or belong to a set without revealing which one.
- Off-chain computation with on-chain settlement — execute heavy logic off-chain, post a succinct proof, pay verification cost in constant time.
- Confidential state transitions — prove a transition is valid without publishing the full pre-state.
- Signature aggregation and threshold schemes — verify complex cryptography (BLS, ECDSA, Schnorr) inside a circuit and produce a single Groth16 proof.
SetComputeUnitLimit. Either way, verification cost is bounded and predictable, which is what makes ZK on Solana usable as a primitive rather than a novelty.
Production deployments already rely on this path. Light Protocol’s ZK Compression proves the validity of compressed-account state transitions using the same alt_bn128 syscalls, on mainnet, every slot.
This guide assumes basic Solana program development experience. If you are new to Solana programs, start with Solana: Anchor development and Solana: Program derived addresses and cross-program invocations.
What this guide is not
Three distinct things share the “ZK on Solana” label. This guide covers one of them:| Topic | What it is | Covered here |
|---|---|---|
| Custom Groth16 verification | Deploy your own on-chain verifier program, write circuits in Noir, prove Groth16, verify via alt_bn128 syscalls | ✅ This guide |
| ZK Compression | Light Protocol’s mainnet state-compression layer. Uses Groth16 under the hood but exposed as a managed service, not a circuit-writing flow | ❌ See ZK Compression docs |
| Token-2022 Confidential Transfers / ZK ElGamal Proof Program | Native Solana proof program for encrypted token balances, using ElGamal and Bulletproofs, not Groth16 | ❌ See Agave ZK ElGamal docs |
The stack
There are five moving parts. Understanding what each one does and where the boundaries are will save you a lot of debugging.| Piece | Role | Where it runs |
|---|---|---|
| Noir | Write the circuit in a Rust-like DSL. Compiles to ACIR (an arithmetic-circuit intermediate representation). | Off-chain, in your editor |
| Sunspot | Translate ACIR to Gnark’s constraint system (CCS), run Groth16 setup, generate proofs, and build a Solana verifier program with the verifying key baked in. | Off-chain, CLI tool |
| Gnark | The Groth16 proving backend Sunspot uses under the hood. | Off-chain, library |
alt_bn128 syscalls | BN254 pairing check and elliptic-curve arithmetic. What the on-chain verifier calls to validate the proof. | On-chain, Agave runtime |
sol_poseidon syscall | Poseidon hash over BN254. Used when your Solana program needs to recompute a hash that was also computed inside the circuit. | On-chain, Agave runtime |
Why Noir’s default backend is not enough
Noir compiles to ACIR. The default Noir backend — Barretenberg’sbb CLI, distributed with nargo — produces UltraHonk proofs. UltraHonk and Groth16 both use the BN254 curve, but UltraHonk is a KZG-based proving system and its verifier needs polynomial-commitment operations that Solana does not expose as syscalls. The alt_bn128 syscalls cover G1/G2 arithmetic and pairing — enough for Groth16 — but not the multi-point KZG openings UltraHonk relies on. That gap, not the curve, is why you must route Noir through Sunspot to target Solana today.
Sunspot fills this gap. It reads the ACIR bytecode Noir emits, translates it to Gnark’s constraint system, runs a Groth16 setup over BN254, and generates a Solana program that uses alt_bn128 pairing to verify the resulting proofs. This is the only production path from Noir to Solana today.
Prerequisites
- A Chainstack Solana node endpoint — devnet is fine for this guide; mainnet if you want to test production paths.
- Solana CLI 3.x or later.
- Rust 1.94.0 or later (via rustup, not Homebrew — the Homebrew Rust is often too old).
- Node.js 18+ and pnpm.
- Go 1.24+ (required by Sunspot).
- just (task runner used by the reference repo).
Pin a coherent toolchain
Noir, Sunspot, and the examples repo all move fast. Versions drift. The combination known to work end-to-end as of April 2026 is:- Noir (
nargo) —1.0.0-beta.18(Sunspot’s hard requirement). - Sunspot — latest
mainbranch. - Solana CLI — 3.1.x or later.
- Gnark — pulled automatically by Sunspot’s Go module.
Install Sunspot
Clone the examples repo
All code in this guide lives in the Solana Foundation’s noir-examples repo. Clone it and follow along.Tutorial 1: the one circuit — prove x != y
The simplest possible circuit: given a secret x and a public y, prove that x != y. The circuit leaks nothing about x beyond the fact that it is not equal to y.
The circuit
circuits/one/src/main.nr:
x: u64— a private input. Its value is part of the witness but never revealed.y: pub u64— a public input. Its value is passed to the verifier alongside the proof and is checked against what the circuit committed to.
assert becomes a constraint in the arithmetic circuit. When you generate a proof, that constraint must be satisfied by the witness, or proof generation fails.
The input file
circuits/one/Prover.toml:
Prover.toml to populate the witness when you run nargo execute.
Compile, execute, prove
Fromcircuits/one/:
target/one.proof— 388 bytes for a simple circuit, the Groth16 proof itself.target/one.pw— the public witness (public inputs, serialized).target/one.vk— the verifying key, which gets compiled into the on-chain verifier program.
Build and deploy the verifier
proof || public_witness, validate the Groth16 proof against the baked-in verifying key. It returns success or an error — no accounts are written, no state is modified.
Verify on-chain from a TypeScript client
The reference client incircuits/one/client/ uses @solana/kit. The core of it is straightforward:
proof || witness. The verifier program handles deserialization and pairing check internally.
Run the reference client:
x and y in Prover.toml to the same value and re-run nargo execute — witness generation fails immediately because the assert(x != y) constraint is violated, so no .gz witness file is produced and sunspot prove cannot even run.
Tutorial 2: sparse-Merkle-tree exclusion proof
Theone circuit exercises the pipeline but doesn’t do anything useful. The smt_exclusion circuit is the real-world pattern: prove that your public key is not in a blacklist, without revealing anything else.
The pattern generalizes. Any time you have an allow-list or deny-list and you want users to prove their status without leaking which user they are, this is the shape of the solution.
How the exclusion proof works
The set of “blacklisted” public keys is stored as a sparse Merkle tree of depth 254 (one bit per position in the BN254 scalar field). Each leaf position corresponds to a Poseidon hash of a public key. An empty leaf has value0; an occupied leaf has a non-zero value.
The root of this tree is committed on-chain. To prove your public key is not blacklisted, you:
- Hash your public key with Poseidon to get the leaf position.
- Provide the sibling hashes along the Merkle path from that position to the root.
- Prove inside the circuit that the leaf value at that position is
0and that the path reconstructs to the committed root.
The Poseidon coordinate trap
This is the part that will bite you if you are not careful. Noir’s standard library ships a Poseidon implementation atstd::hash::poseidon. Circomlib — the canonical circuit library in the ZK ecosystem — ships a different Poseidon with the same BN254 parameters but a different output convention. Specifically, the sponge permutation produces a 3-element state, and the two implementations disagree on which coordinate is the “hash output”:
- Noir’s stdlib
poseidonfollows the Poseidon paper — output isstate[1]. - Circomlib’s
poseidon— output isstate[0].
sol_poseidon syscall is implemented by light-poseidon, which is circomlib-compatible. So sol_poseidon returns state[0].
If you use Noir’s stdlib Poseidon inside your circuit and then try to recompute the hash on-chain with sol_poseidon, the values will differ. The proof verifies correctly (the circuit’s internal hash checks out), but any on-chain logic that depends on matching the hash to something your program computes separately will fail.
In Nargo.toml:
0x115cc0f5... is the circomlibjs hash of [1, 2]. If a future Noir or library update drifts the convention, this test catches it immediately. Every ZK circuit you write that needs on-chain hash matching should pin a value like this.
The circuit
Abbreviated, thesmt_exclusion circuit (circuits/smt_exclusion/src/main.nr):
smt_root, pubkey_hash) and three private inputs (pubkey, siblings, leaf_value). The proof attests that the prover knows a pubkey whose Poseidon hash matches the public pubkey_hash, and that this key’s position in the tree rooted at the public smt_root holds an empty leaf.
Run the tests and generate a proof:
The on-chain verifier — same as before
Runningsunspot deploy target/smt_exclusion.vk produces a Solana program that does one thing: verify a Groth16 proof against the baked-in verifying key. Deploy it the same way as before.
This is what you would call directly from a client if the only thing your application needs is “prove the user is not blacklisted”. But in most real systems the ZK check is one gate among several — for example, the user is not blacklisted and they are transferring no more than N lamports. That is where CPI comes in.
A calling program that does CPI to the verifier
The richer pattern incircuits/smt_exclusion/on_chain_program/ is a regular Solana program that:
- Holds an admin-settable SMT root in a PDA state account.
- Accepts a
TRANSFER_SOLinstruction with instruction data[amount || proof || public_witness]. - Verifies the public witness matches the stored SMT root and the signer’s Poseidon hash.
- Does a CPI to the ZK verifier program.
- If verification succeeds, transfers the lamports.
on_chain_program/src/lib.rs):
- The verifier is stateless — the CPI passes an empty accounts list. The verifier program reads only its baked-in verifying key and the instruction data.
- Bind the proof to on-chain state. Your program must recompute the expected public inputs (SMT root, signer hash, whatever else is public) and check they match what the witness claims. Otherwise a user could submit a valid proof for a different tree or different signer.
- Endianness conversion is required. The
sol_poseidonsyscall withEndianness::LittleEndianproduces little-endian bytes; Gnark emits big-endian in the public witness. Reverse the computed bytes before comparing, or you will get spurious mismatches. - Every
sol_poseidoninput must be exactly 32 bytes. SIMD-0359 is active on devnet (since epoch 1014) and testnet (since epoch 897). Before activation, the runtime silently zero-extended short inputs; after activation, anything other than exactly 32 bytes returnsPoseidonSyscallError::Unexpected. The code above zero-pads 16-byte pubkey halves to 32 bytes; match the padding in both the client-side Poseidon computation and anywhere else you call the syscall. - Compute-unit budget. Groth16 verification alone fits in ~200K CU, but the calling program also does hashing, state reads, and the transfer. Measure with
compute-budgetand set the limit explicitly from the client.
The client flow
The TypeScript client (circuits/smt_exclusion/client/) builds the witness by (1) hashing the sender’s pubkey with a circomlib-compatible Poseidon in JavaScript, (2) building the Merkle path against the current SMT state, (3) writing Prover.toml, (4) shelling out to nargo execute and sunspot prove, and (5) submitting the transaction.
Using your Chainstack endpoint
Point the reference client at your own Chainstack node rather than the default public RPC. In any of the client scripts, replace the RPC URL:Production checklist
Before shipping a ZK-gated Solana program to mainnet, work through this list.Run a proper trusted-setup ceremony
sunspot setup is a 1-of-1 ceremony with no toxic-waste mitigation. For any real deployment, replace the verifying key with one produced by a multi-party computation. Gnark supports phase2 contributions; pull .pk/.vk from a ceremony where at least one honest participant destroyed their randomness. Re-run sunspot deploy with the post-ceremony .vk.Lock the verifier program
Once deployed, make the verifier program immutable (
solana program set-upgrade-authority --final). An upgradeable verifier means whoever holds the upgrade authority can swap in a different verifying key and accept forged proofs.Treat feature-gate activations as release blockers
Solana’s ZK-adjacent syscalls and programs have been disabled and re-enabled before — the ZK ElGamal Proof Program was disabled at epoch 805 after a Fiat-Shamir transcript bug, and SIMD-0359 silently changed the input-length contract for
sol_poseidon. Subscribe to the Agave feature gate tracker and re-test your deployed programs on devnet before any feature gate activates on mainnet. A working deploy today can fail silently tomorrow if a feature gate tightens input validation.Bind public inputs to on-chain state
The proof only attests that the circuit’s constraints are satisfied given the public inputs. Your calling program must verify those public inputs match real state — the stored SMT root, the signer’s pubkey, the current epoch, etc. Without this, a valid proof for any tree or any signer is accepted.
Set and measure compute-unit budgets explicitly
The verifier alone consumes ~200K CU. Any calling program adds more. Never rely on the default; set
SetComputeUnitLimit from the client and confirm the transaction actually succeeds under that budget on mainnet, not just localnet.Pin dependency versions in both Rust and Noir
Noir, Sunspot, and the Poseidon library all iterate. A minor-version update can change constraint counts, round constants, or byte layouts. Commit
Nargo.toml, Cargo.toml, and Cargo.lock. Re-run the setup ceremony if any of these change.Unit-test the hash invariants
Include a
test_poseidon_circom_compatible-style test that hashes a known input and asserts equality with a pre-computed expected value. This catches silent drift in the Poseidon library.Common pitfalls
Proof generation fails with “constraint not satisfied”
The witness does not satisfy the circuit’s constraints. Most often this means your off-chain computation produced inputs that disagree with the circuit’sasserts — for example, the Merkle path you built does not reconstruct to the root you claimed, or the leaf value at the claimed position is not what you said. Turn on Sunspot’s verbose output and the specific failing constraint will be reported.
Proof verifies in sunspot verify but fails on-chain
Two likely causes:
- Endianness. Gnark emits public witnesses in big-endian; the
sol_poseidonsyscall withEndianness::LittleEndianreturns little-endian bytes. If your calling program compares them without reversing one side, they will never match. - Public-input binding mismatch. The circuit hashed one set of bytes; your program hashed a different set. Check that you split the pubkey the same way (16 low + 16 high bytes) on both sides and that the byte order of each half matches.
Hash from the circuit does not match sol_poseidon output
You used Noir’s stdlib std::hash::poseidon. Switch to the noir-lang/poseidon library (dep::poseidon::poseidon::bn254::hash_N). See the coordinate-trap section above.
Verifier CU usage exceeds 200K
The baseline is under 200K for a simple verifier, but large public-witness sizes or additional hashing in the caller push it over. Set the limit explicitly from the client withSetComputeUnitLimit. If you need to stay under 200K strictly, keep the number of public inputs small and avoid per-byte hashing in the caller.
Program fails with stack overflow during verification
Groth16 verifying keys and proof structs, if instantiated on the stack inside a Solana program, can exceed the 4 KB SBF stack limit. The Sunspot-generated verifier avoids this internally, but if you are writing a calling program that also does crypto work (hashing, building witness buffers), keep large byte arrays on the heap viaVec or in account data — never stack-allocate a [u8; 4096] or larger. Anchor 0.31 and later emit stack-size warnings for this pattern even when the runtime usage is fine.
Transaction exceeds the 1232-byte size limit
A Sunspot-generated Groth16 proof is 324–388 bytes depending on the circuit; the underlying compressed BN254 proof is 256 bytes, but Sunspot’s wire format adds framing. Public witnesses are small (44 bytes forone, 76 bytes for smt_exclusion), but grow with the number of public inputs. If your calling program passes proof + witness as CPI data on top of an already-large instruction, you will hit Solana’s ~1232-byte transaction limit. Solutions: use address lookup tables to shave signer/account overhead, split large public-witness bytes into a data account written in a separate transaction, or reduce the circuit’s public inputs by hashing them into a single commitment.
convert_endianness is not available on-chain
solana_program::alt_bn128::compression::convert_endianness is gated #[cfg(not(target_os = "solana"))] — it exists only off-chain. If you are converting Gnark big-endian output to alt_bn128 little-endian expectations, do the conversion in the client before submitting. If you need equivalent logic on-chain, copy the byte-reversal into your program (it is a simple per-component reverse, not a syscall).
sunspot deploy produces an empty or broken .so
GNARK_VERIFIER_BIN is not set, or points at the wrong directory. It must point to the gnark-solana/crates/verifier-bin directory inside your Sunspot clone.
Calling program’s sol_poseidon returns Unexpected
SIMD-0359 (active on devnet epoch 1014+, testnet epoch 897+, at time of writing pending on mainnet-beta) requires every input slice to the sol_poseidon syscall to be exactly 32 bytes — the byte length of the BN254 scalar field modulus. Before activation, shorter inputs were silently zero-extended. After activation, they return PoseidonSyscallError::Unexpected.
If your program hashes a pubkey by splitting it into two 16-byte halves, zero-pad each half to 32 bytes before passing them to hashv:
Noir stdlib version mismatch
nargo version must match what Sunspot expects. Sunspot’s README pins beta.18 as of April 2026. If you update Noir without updating Sunspot, ACIR serialization may change and sunspot compile will fail with an opaque parse error. Use noirup -v 1.0.0-beta.18 to stay pinned.
Where this stack is heading
Several in-flight Solana features will change the ZK landscape.- SIMD-0302 — standalone BN254 G2 arithmetic syscalls. Currently G2 is only accessible via the G1+G2 pairing operation. Pending devnet activation in Agave v4.0.0-beta.
- SIMD-0388 — BLS12-381 syscalls. Adds a second pairing-friendly curve alongside BN254, enabling proving systems that target BLS12-381. Does not directly unlock UltraHonk on Solana (UltraHonk uses BN254 but needs KZG polynomial-commitment operations that remain absent), but broadens the curve surface for future ZK work. Pending mainnet.
- ZK Compression V2 — Light Protocol’s production ZK infrastructure, shipping on mainnet as of March 2026. Uses the same Groth16-via-
alt_bn128path described in this guide, at production scale.
alt_bn128 path is the only production route from Noir to Solana.
When to reach for a zkVM instead
Noir is a constraint-writing DSL: you describe a small arithmetic circuit and prove statements about it. This is the right tool for compact, repeat-use circuits — Merkle membership, signature verification, range proofs. If your proof target is “run this arbitrary Rust (or C, or Python-like) program and prove it executed correctly”, a zkVM is the better fit:- SP1 — write Rust, run in a RISC-V zkVM, generate a STARK that is recursively wrapped in Groth16 for on-chain verification. Good for general-purpose off-chain computation.
- Bonsol — RISC Zero on Solana; similar shape to SP1 but a different proving stack.
Further reading
- solana-foundation/noir-examples — the canonical reference repo; three circuits with pre-deployed devnet verifiers.
- reilabs/sunspot — Noir → Gnark-Groth16 → Solana toolchain.
- Lightprotocol/groth16-solana — the lower-level crate for Groth16 verification on Solana. Use this directly if you are not going through Sunspot (for example, generating proofs with snarkjs).
- Lightprotocol/light-poseidon — the circomlib-compatible Poseidon implementation behind
sol_poseidon. - Noir documentation — circuit syntax and stdlib reference.
- Groth16 paper — the proving system itself.
- Solana ZK primitives — Agave runtime’s ZK documentation (covers ZK ElGamal specifically;
alt_bn128andsol_poseidonare documented in the general syscall reference).
Where to get help
Custom Groth16 verification on Solana is an emerging area — there is no active #zk channel to ping, and Noir-specific Solana discussion is near-zero on Discord, Telegram, and Reddit as of April 2026. When you get stuck, the productive channels are:- groth16-solana GitHub issues — direct line to Light Protocol maintainers for low-level verification questions.
- reilabs/sunspot GitHub issues — for Noir → Gnark → Solana pipeline bugs.
- Noir Discord — circuit-writing and Noir-language questions (Aztec-run, not Solana-specific).
- Solana Stack Exchange — has a small but growing corpus of Groth16 / alt_bn128 / stack-overflow-during-verify questions. Searching for
alt_bn128turns up the most useful prior answers. - Light Protocol Discord — ZK Compression and the broader Groth16-on-Solana community.