Noir (ZK Privacy)
What You Probably Got Wrong
"Use nargo prove and nargo verify." Those commands were removed. Nargo only compiles and executes. Proving and verification use bb (Barretenberg CLI) directly. If you generate nargo prove commands, they will fail.
"I can use SHA256 for hashing in my circuit." SHA256 costs ~30,000 gates in a circuit. Poseidon costs ~600. For in-circuit hashing, always use Poseidon. Poseidon was removed from the Noir standard library — you must add it as an external dependency. The correct import is use poseidon::poseidon::bn254::hash_2 after adding the noir-lang/poseidon dependency to Nargo.toml. Not std::hash::poseidon::bn254::hash_2 (removed from stdlib), not Poseidon2::hash, not pedersen_hash.
"pub goes before the parameter name." Noir 1.0 changed public input syntax: pub merkle_root: Field → merkle_root: pub Field. The old syntax gives "Expected a pattern but found 'pub'".
"Set compiler_version = ">=1.0.0-beta.3" in Nargo.toml." compiler_version rejects beta strings — >=1.0.0-beta.3 fails. Use >=0.36.0 or omit compiler_version entirely.
"I built a commitment-nullifier circuit so my app is private." The ZK proof hides the link between commitment and nullifier, but msg.sender is public. If the same wallet deposits a commitment and later calls act() to withdraw/vote, anyone can link the two transactions onchain. The whole pattern is pointless unless the acting wallet is different from the committing wallet. Use a fresh burner wallet + a relayer or ERC-4337 paymaster to pay gas without revealing the link.
"The generated HonkVerifier.sol works with any Solidity version." The verifier generated by bb write_solidity_verifier requires pragma solidity >=0.8.21 and EVM version cancun. If your Foundry project uses a lower version, add solc_version = '0.8.27' and evm_version = 'cancun' to foundry.toml.
Quick Reference
Beyond the corrections above:
- Solidity verifier = separate deploy — deploy the generated
HonkVerifier.sol, pass its address to your app contract constructor - Input order must match everywhere — circuit
pubparams,proof.publicInputs, and Solidityverify()call must be in the same order - Poseidon ≠ Poseidon2 — different algorithms, different outputs. Don't mix them across circuit, offchain tree, and contract
Toolchain (Current as of March 2026)
Install
Check if nargo and bb are already installed before running the installers:
nargo --version && bb --version
If both commands return versions, you're set — skip the install. If either is missing:
# 1. Install nargo (Noir compiler) — always install nargo first
curl -L https://raw.githubusercontent.com/noir-lang/noirup/refs/heads/main/install | bash
noirup
# 2. Install bb (Barretenberg proving backend) — bbup reads your nargo version
# and installs the compatible bb automatically
curl -L https://raw.githubusercontent.com/AztecProtocol/aztec-packages/refs/heads/master/barretenberg/bbup/install | bash
bbup
Order matters: install nargo first, then run bbup — it auto-detects your nargo version and installs the compatible bb.
Project Structure
my-circuit/
Nargo.toml # Project manifest (name, type, dependencies, external libs)
src/
main.nr # Circuit entry point
Prover.toml # Witness inputs (private + public values)
Create a new project:
nargo new my_circuit
cd my_circuit
Build Pipeline
The production build pipeline stops at circuit artifact, VK, and Solidity verifier. If asked for the minimal production build, say explicitly that bb prove / bb verify below are optional local smoke tests only.
# 1. Compile circuit to ACIR
nargo compile
# 2. Execute with witness inputs (reads Prover.toml, writes target/*.gz)
nargo execute
# 3. Generate verification key — --oracle_hash keccak is required for EVM compatibility
bb write_vk --oracle_hash keccak -b target/my_circuit.json -o target/
# 4. Generate Solidity verifier from the VK
bb write_solidity_verifier -k target/vk -o target/Verifier.sol
Local-only proof smoke test — useful before wiring up the frontend. All commands must use --oracle_hash keccak consistently, or you get serialization mismatches.
bb prove --oracle_hash keccak -b target/my_circuit.json -w target/my_circuit.gz -o target/
bb verify --oracle_hash keccak -p target/proof -k target/vk -i target/public_inputs
The command is bb write_solidity_verifier — not bb contract, not nargo codegen-verifier.
Contract size warning: Call this failure by name: the generated HonkVerifier.sol can exceed the 24KB EIP-170 contract size limit. For real deployments, enable the Solidity optimizer first:
# foundry.toml
[profile.default]
optimizer = true
optimizer_runs = 200
If you still hit the limit locally, run anvil --code-size-limit 40960 and forge script ... --code-size-limit 40960. That flag is for local testing only — mainnet and major L2s still enforce the 24KB limit.
Build Artifacts and Hand-off
Treat the generated files as interfaces between subsystems:
target/my_circuit.json— circuit artifact consumed by NoirJStarget/vk— verification key used bybb verifyand Solidity verifier generationtarget/Verifier.sol— generated verifier source; this is the source of truth for the verifier ABI. This is a standalone contract that must be deployed separately. Your app contract receives the verifier's deployed address in its constructor. Do not just import it — deploy it first, then pass the address.
Pick a stable layout and keep it consistent. A good default is:
circuits/my_circuit/target/my_circuit.json
contracts/src/verifiers/HonkVerifier.sol
frontend/public/circuits/my_circuit.json
Do not hand-copy artifacts ad hoc in prompts or scripts. Models drift unless you make the hand-off explicit.
Choosing the Right Pattern
Not every privacy app needs a Merkle tree. Pick the simplest approach that fits:
Simple private proof — prove a fact about private data without revealing it. No Merkle tree, no nullifier, no anonymity set. Just a circuit with private inputs, a public output, and a Solidity verifier. Examples: prove you're over 18 without revealing your age, prove your balance exceeds a threshold, prove a sealed bid is within range. The toolchain, Poseidon, NoirJS, and verifier sections above all apply — you just write a simpler circuit.
Commitment-nullifier pattern — needed when multiple participants must act anonymously from a shared set. Participants commit secret hashes into a Merkle tree, then later prove membership and act from a different wallet. The Merkle tree is the anonymity set. The nullifier prevents double-action. Required for: anonymous voting, private withdrawals (Tornado Cash), anonymous airdrops, whistleblowing. This is harder to get right — see below.
If you're unsure: start with a simple private proof. Only reach for the commitment-nullifier pattern when you need unlinkability between a prior action (committing) and a later action (withdrawing/voting).
Before writing any code, ask:
- What needs to stay private? A fact about data (age, balance, credential) → simple proof. Which participant performed an action → commitment-nullifier.
- What happens after proof verification? Withdraw funds, cast a vote, claim an airdrop, unlock access — this determines the contract's
act()logic. - Can the same participant act more than once? One vote per poll → nullifier scoped to
pollId. One withdrawal per deposit → global nullifier. Unlimited access checks → no nullifier needed. - Does the caller's identity need to be hidden? If yes, the user must act from a fresh wallet via a relayer or ERC-4337 paymaster. If no (e.g., private credential check), the same wallet is fine.
- Which chain? Check the compatibility table below. zkSync ERA works but has higher gas for BN254 precompiles.
- What frontend? Vite, Next.js / Scaffold-ETH 2, or backend-only — each has different WASM configuration (see NoirJS section).
Get these answers before choosing a pattern or writing a circuit. The answers determine tree depth, nullifier design, contract structure, and wallet flow.
App Architecture (Commitment-Nullifier Apps)
A working privacy app is not "just a circuit." The model must wire five pieces together correctly:
- Circuit — proves knowledge of a note (
nullifier,secret) and membership in the commitment tree - Onchain app contract — accepts commitment inserts, tracks accepted roots, blocks reused nullifiers, and executes the action after proof verification
- Generated verifier — created by
bb write_solidity_verifier; its ABI is the source of truth - Offchain tree mirror — rebuilds the Merkle tree from insert events and produces
leafIndex, siblings, and the root used for proving - Frontend prover — creates/saves notes, loads the circuit artifact, executes Noir, generates the proof, serializes calldata, and submits the action transaction
If any one of these layers uses different hashes, input ordering, tree depth, or serialization, the app breaks even if the circuit compiles.
The Commitment-Nullifier Pattern
The foundational primitive for privacy on Ethereum (Tornado Cash, Semaphore, MACI, Zupass).
How it works: Many participants each commit a secret hash into a shared Merkle tree onchain. Later, any participant can prove "I am one of the people who committed" without revealing which one — by submitting a ZK proof from a different wallet. The Merkle tree is the anonymity set: the more people who commit, the larger the crowd you hide in, and the stronger the privacy. A nullifier hash prevents double-spending/double-voting without revealing identity.
This is why scale matters — a commitment tree with 3 entries gives weak privacy (1-in-3), while a tree with 10,000 entries makes identifying the actor practically impossible.
Nargo.toml
Poseidon is no longer in the Noir standard library — add it as an external dependency:
[package]
name = "my_circuit"
type = "bin"
[dependencies]
poseidon = { git = "https://github.com/noir-lang/poseidon", tag = "v0.2.6" }
Note Lifecycle (What the User Must Save)
At commitment time, generate two random private fields:
nullifiersecret
Then compute the commitment and persist a note locally. If the note is lost, the user cannot later prove membership or spend/vote.
type PrivacyNote = {
nullifier: string;
secret: string;
commitment: string;
chainId: number;
contract: `0x${string}`;
treeDepth: number;
leafIndex?: number;
};
Default flow:
- Generate
nullifierandsecret. - Compute
commitment. - Submit the commitment insertion transaction.
- Read the insert event and persist
leafIndexplus the new root. - Later, rebuild the tree from events, derive siblings for
leafIndex, and prove against an accepted root.
The app must make this lifecycle explicit. A model that only writes the circuit usually forgets note persistence, leafIndex, or event replay.
Circuit Implementation
// src/main.nr
use poseidon::poseidon::bn254::hash_1;
use poseidon::poseidon::bn254::hash_2;
fn main(
// Private inputs (known only to prover)
nullifier: Field,
secret: Field,
merkle_path: [Field; 20], // Sibling hashes (tree depth 20)
merkle_indices: [u1; 20], // 0 = left child, 1 = right child (u1 enforces binary)
// Public inputs (visible to verifier/contract)
merkle_root: pub Field,
nullifier_hash: pub Field,
) {
// 1. Recompute the commitment from private inputs
let commitment = hash_2([nullifier, secret]);
// 2. Verify the commitment exists in the Merkle tree
let computed_root = compute_merkle_root(commitment, merkle_path, merkle_indices);
assert(computed_root == merkle_root, "Merkle proof invalid");
// 3. Verify the nullifier hash matches
let computed_nullifier_hash = hash_1([nullifier]);
assert(computed_nullifier_hash == nullifier_hash, "Nullifier hash mismatch");
}
fn compute_merkle_root(
leaf: Field,
path: [Field; 20],
indices: [u1; 20],
) -> Field {
let mut current = leaf;
for i in 0..20 {
// u1 type enforces binary at compile time, no manual assert needed
let (left, right) = if indices[i] == 0 {
(current, path[i])
} else {
(path[i], current)
};
current = hash_2([left, right]);
}
current
}
Production Hardening: Domain Separation and Action Binding
The circuit above is the minimal pattern. Production apps should domain-separate hashes and bind the proof to a specific action. Commitments and nullifiers must use different domains.
For action-scoped apps such as voting, bind nullifier usage to an external_nullifier (for example pollId):
use poseidon::poseidon::bn254::hash_2;
fn main(
nullifier: Field,
secret: Field,
merkle_path: [Field; 20],
merkle_indices: [u1; 20],
merkle_root: pub Field,
external_nullifier: pub Field,
nullifier_hash: pub Field,
) {
let note_secret = hash_2([nullifier, secret]);
let commitment = hash_2([1, note_secret]); // 1 = commitment domain
let computed_root = compute_merkle_root(commitment, merkle_path, merkle_indices);
assert(computed_root == merkle_root, "Merkle proof invalid");
// 2 = nullifier domain; external_nullifier scopes usage to a poll/action
let nullifier_domain = hash_2([2, external_nullifier]);
let computed_nullifier_hash = hash_2([nullifier_domain, nullifier]);
assert(computed_nullifier_hash == nullifier_hash, "Nullifier hash mismatch");
}
Solidity Contract Integration
The generated verifier contract is the source of truth. If you wrap it behind an interface like the one below, inspect the generated verifier ABI first and mirror it exactly, or add a dedicated adapter contract with a stable app-facing interface.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IVerifier {
function verify(bytes calldata proof, bytes32[] calldata publicInputs)
external view returns (bool);
}
contract PrivacyPool {
IVerifier public immutable verifier;
bytes32 public merkleRoot;
mapping(bytes32 => bool) public usedNullifiers;
constructor(address _verifier) {
verifier = IVerifier(_verifier);
}
// msg.sender is public — see "same wallet" warning above.
// Only mutate state after verify() succeeds.
function act(bytes calldata _proof, bytes32 _merkleRoot, bytes32 _nullifierHash) external {
require(!usedNullifiers[_nullifierHash], "Already acted");
require(_merkleRoot == merkleRoot, "Invalid root");
// Public inputs order MUST match the circuit's pub parameter order
bytes32[] memory publicInputs = new bytes32[](2);
publicInputs[0] = _merkleRoot; // pub merkle_root
publicInputs[1] = _nullifierHash; // pub nullifier_hash
require(verifier.verify(_proof, publicInputs), "Invalid proof");
// Only mutate state after proof verification succeeds
usedNullifiers[_nullifierHash] = true;
}
}
Contract State Model
For a real app, the contract needs more than merkleRoot + usedNullifiers:
- Emit an insert event with the commitment, leaf index, and resulting root
- Store used nullifiers
- Make the root-acceptance policy explicit: recent
knownRootsorcurrentRoot-only - Verify proofs before success events, transfers/votes, or nullifier writes
Good default:
event CommitmentInserted(bytes32 indexed commitment, uint256 indexed leafIndex, bytes32 root);
mapping(bytes32 => bool) public usedNullifiers;
mapping(bytes32 => bool) public knownRoots; // keep recent roots by default
bytes32 public currentRoot;
Root policy: default to recent knownRoots. If you intentionally accept only currentRoot, say so explicitly and require clients to prove against the latest root.
Clients derive siblings by replaying CommitmentInserted into the offchain tree mirror; the contract never returns witness paths.
Onchain Commitment Storage (LeanIMT)
Most Noir ZK apps store commitments in an onchain Merkle tree. If asked how commitments are stored onchain, name all three pieces: onchain @zk-kit/lean-imt.sol + deployed PoseidonT3; offchain @zk-kit/lean-imt; witness path from tree.generateProof(leafIndex).
Solidity:
npm install @zk-kit/lean-imt.sol
import {LeanIMT, LeanIMTData} from "@zk-kit/lean-imt.sol/LeanIMT.sol";
Deploy PoseidonT3 alongside; the tree contract uses it internally for hashing. The contract maintains the Merkle root automatically — users call insert(commitment).
JavaScript (client-side tree mirror):
npm install @zk-kit/lean-imt
import { LeanIMT } from "@zk-kit/lean-imt";
const { siblings, pathIndices } = tree.generateProof(leafIndex); // after replaying CommitmentInserted events
Merkle Proof with zk-kit
For production Merkle trees, use the @zk-kit.noir library:
# Nargo.toml
[dependencies]
binary_merkle_root = { tag = "main", git = "https://github.com/privacy-scaling-explorations/zk-kit.noir", directory = "packages/binary-merkle-root" }
use binary_merkle_root::binary_merkle_root;
use poseidon::poseidon::bn254::hash_2;
global TREE_DEPTH: u32 = 20;
fn main(
leaf: Field,
indices: [u1; TREE_DEPTH], // u1 enforces binary, no manual assert needed
siblings: [Field; TREE_DEPTH],
root: pub Field,
) {
let computed = binary_merkle_root(hash_2, leaf, TREE_DEPTH, indices, siblings);
assert(computed == root, "Invalid Merkle proof");
}
The function takes 5 args: hasher, leaf, depth, indices, siblings. The indices type is [u1; MAX_DEPTH] — the u1 type constrains values to 0 or 1 at the type level, so you don't need manual binary assertions like assert(indices[i] * (indices[i] - 1) == 0).
Frontend Proof Generation (NoirJS)
The packages are @noir-lang/noir_js + @aztec/bb.js. NOT @noir-lang/backend_barretenberg (old, deprecated). The class is UltraHonkBackend, NOT UltraPlonkBackend (old).
Package Setup
npm install @noir-lang/noir_js "@aztec/bb.js@$(bb --version)"
# ⚠ The @aztec/bb.js version must exactly match your bb CLI version (check with `bb --version`). A mismatch produces different proof serialization, causing onchain verification to fail.
Vite Configuration
NoirJS uses WASM and requires top-level await:
// vite.config.ts
import { defineConfig } from "vite";
import { nodePolyfills } from "vite-plugin-node-polyfills";
export default defineConfig({
plugins: [nodePolyfills()],
optimizeDeps: {
esbuildOptions: { target: "esnext" },
},
build: {
target: "esnext",
},
});
Next.js / Scaffold-ETH 2 Configuration
// next.config.js
const nextConfig = {
webpack: (config) => {
config.experiments = { ...config.experiments, asyncWebAssembly: true };
return config;
},
};
module.exports = nextConfig;
// components/ProofGenerator.tsx
"use client";
import dynamic from "next/dynamic";
// All Noir components must be client-only — WASM doesn't run in SSR
const NoirProver = dynamic(() => import("./NoirProver"), { ssr: false });
Circuit artifact (my_circuit.json) must be copied to public/ and loaded via fetch() — cross-package JSON imports don't work in Next.js:
const circuit = await fetch("/my_circuit.json").then(r => r.json());
Generating Proofs in the Browser
import { Noir } from "@noir-lang/noir_js";
import { Barretenberg, UltraHonkBackend } from "@aztec/bb.js";
import circuit from "../circuit/target/my_circuit.json";
// 1. Initialize Barretenberg instance, then backend and Noir
const bb = await Barretenberg.new();
const backend = new UltraHonkBackend(circuit.bytecode, bb);
const noir = new Noir(circuit);
// 2. Execute circuit (generates witness)
const inputs = {
nullifier: "0x1234...",
secret: "0xabcd...",
merkle_path: ["0x...", "0x...", ...],
merkle_indices: [0, 1, 0, ...],
merkle_root: "0x...",
nullifier_hash: "0x...",
};
const { witness } = await noir.execute(inputs);
// 3. Generate proof — { keccak: true } matches --oracle_hash keccak used for the verifier
const proof = await backend.generateProof(witness, { keccak: true });
// proof.proof is Uint8Array — the raw proof bytes
// proof.publicInputs is string[] — the public inputs
const bytesToHex = (bytes: Uint8Array) =>
`0x${Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("")}`;
const toBytes32 = (field: string) =>
`0x${field.replace(/^0x/, "").padStart(64, "0")}` as `0x${string}`;
const proofHex = bytesToHex(proof.proof);
const publicInputs = proof.publicInputs.map(toBytes32);
// 4. Send to contract
const tx = await contract.act(
proofHex, // bytes calldata
publicInputs[0], // merkle_root
publicInputs[1], // nullifier_hash
);
proof.proofisUint8Array— serialize it to0x...before sending over RPCproof.publicInputsare strings — normalize them to 32-byte hex before comparing or passing to Solidity- EVM compatibility requires
--oracle_hash keccakon CLI commands (see Build Pipeline above) AND{ keccak: true }ingenerateProof()— both must be set or you get serialization mismatches - Proof generation takes 5-30 seconds in browser depending on circuit size
- Cleanup: call
bb.destroy()when done - The generated verifier ABI is the source of truth; if your app uses an adapter, make the adapter match that ABI, not a guessed interface
- No
Bufferin browser — convertUint8Arrayto hex directly
Serialization Boundary
Most zk app failures happen here:
- Circuit
pubparameter order - NoirJS
proof.publicInputsorder - Solidity verifier input order
- App contract wrapper/adaptor order
These four must match exactly.
Hard rule: inspect the generated verifier ABI and mirror it exactly. Do not assume every verifier exposes a generic verify(bytes, bytes32[]) signature just because one example does.
Hash Parity Across Circuit, Tree, and Contract
If your circuit uses poseidon::poseidon::bn254::hash_2, then every other layer must use the same algorithm and input ordering:
- commitment creation
- Merkle parent hashing
- offchain tree mirror
- onchain tree contract
Do not mix Poseidon, Poseidon2, and Keccak. poseidon2Hash is not a substitute for poseidon::poseidon::bn254::hash_2.
Before building the full app, test one leaf hash and one parent hash with known inputs across every layer and assert that the outputs match exactly.
Chain Compatibility
Noir/Barretenberg proofs verify on any EVM chain with BN254 precompiles (ecAdd, ecMul, ecPairing at addresses 0x06-0x08).
| Chain | Compatible | Notes |
|---|---|---|
| Ethereum mainnet | Yes | |
| Optimism | Yes | |
| Arbitrum | Yes | |
| Base | Yes | |
| Scroll | Yes | |
| Polygon PoS | Yes | |
| zkSync ERA | Yes | BN254 precompiles at standard addresses; implemented as smart contracts (higher gas) |
| Polygon zkEVM | No | Being shut down — do not build on it |
Circuit Security Checklist
- All private inputs are constrained (no unconstrained witness values that could be manipulated)
- Public inputs minimized — only what the verifier contract needs
- Domain separation in hashes — different prefixes for commitments vs nullifiers (prevents cross-protocol replay)
- Action-scoped apps bind nullifier usage to an
external_nullifier/pollId/ recipient / action id - Nullifier prevents double-action (contract checks and stores used nullifier hashes)
- Small-domain values not directly hashed as public outputs (if vote is 0 or 1,
hash(0)andhash(1)are trivially brutable — add a salt) - Merkle tree depth matches between circuit (fixed at compile time) and contract
- Merkle indices use
u1type (enforces binary at compile time) — if usingField, manually constrain to 0/1 - Poseidon hash compatibility verified between Noir circuit, offchain tree mirror, and any onchain Poseidon library
- The app persists notes (
nullifier,secret,commitment, chain/contract metadata,leafIndex) - The contract emits insert events with
leafIndexand root so the client can rebuild the tree - The accepted-root policy is explicit (recent root history vs current-root-only)
- The generated verifier ABI was inspected and mirrored exactly
- Never deploy
MockVerifier, even locally — deploy scripts and dev/testnet wiring use the realHonkVerifier;MockVerifieris only for narrow unit tests
Testing the App Core
Do not stop at "the circuit compiles." A working zk app needs tests at every boundary:
- Circuit witness test — fixed inputs should produce the expected public inputs and root checks.
- Hash parity test — the same leaf and parent inputs must hash identically in the circuit, offchain tree mirror, and onchain tree library.
- Real-verifier integration test — deploy the generated verifier and verify one real proof against it.
- End-to-end app test — insert a commitment, rebuild the tree from events, generate a browser/backend proof, submit
act(), and assert success. - Failure-path tests — reused nullifier, wrong root, wrong sibling ordering, stale root, and mismatched public input order must all fail.
MockVerifier is only for narrow unit tests. Deploy scripts, local dev wiring, and integration tests use the real generated HonkVerifier.
AI Agent Tooling
This skill corrects common mistakes. For live, searchable access to Noir documentation, stdlib, and example circuits, agents can use the noir-mcp-server:
claude mcp add noir-mcp -- npx @critesjosh/noir-mcp-server@latest
After adding the server, run /reload-plugins so the new tools become available in the current session.
Indexes the Noir compiler repo, standard library, examples, and community libraries (bignum, zk-kit.noir, etc.). Useful for looking up function signatures and browsing code beyond what this skill covers. If the npm package is unavailable, clone the repo and run directly.