Building privacy-preserving EVM apps with Noir — toolchain, pattern selection, commitment-nullifier flows, Solidity verifiers, tree state, and NoirJS. Use when building a Noir-based privacy app on EVM.
"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.
Beyond the corrections above:
HonkVerifier.sol, pass its address to your app contract constructorpub params, proof.publicInputs, and Solidity verify() call must be in the same orderCheck 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.
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
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.
Treat the generated files as interfaces between subsystems:
target/my_circuit.json — circuit artifact consumed by NoirJStarget/vk — verification key used by bb verify and 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.
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).
act() logic.pollId. One withdrawal per deposit → global nullifier. Unlimited access checks → no nullifier needed.Get these answers before choosing a pattern or writing a circuit. The answers determine tree depth, nullifier design, contract structure, and wallet flow.
A working privacy app is not "just a circuit." The model must wire five pieces together correctly:
nullifier, secret) and membership in the commitment treebb write_solidity_verifier; its ABI is the source of truthleafIndex, siblings, and the root used for provingIf any one of these layers uses different hashes, input ordering, tree depth, or serialization, the app breaks even if the circuit compiles.
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.
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" }
At commitment time, generate two random private fields:
nullifiersecretThen 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:
nullifier and secret.commitment.leafIndex plus the new root.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.
// 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
}
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");
}
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;
}
}
For a real app, the contract needs more than merkleRoot + usedNullifiers:
knownRoots or currentRoot-onlyGood 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.
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
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).
The packages are @noir-lang/noir_js + @aztec/bb.js. NOT @noir-lang/backend_barretenberg (old, deprecated). The class is UltraHonkBackend, NOT UltraPlonkBackend (old).
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.
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.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());
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.proof is Uint8Array — serialize it to 0x... before sending over RPCproof.publicInputs are strings — normalize them to 32-byte hex before comparing or passing to Solidity--oracle_hash keccak on CLI commands (see Build Pipeline above) AND { keccak: true } in generateProof() — both must be set or you get serialization mismatchesbb.destroy() when doneBuffer in browser — convert Uint8Array to hex directlyMost zk app failures happen here:
pub parameter orderproof.publicInputs orderThese 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.
If your circuit uses poseidon::poseidon::bn254::hash_2, then every other layer must use the same algorithm and input ordering:
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.
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 |
external_nullifier / pollId / recipient / action idhash(0) and hash(1) are trivially brutable — add a salt)u1 type (enforces binary at compile time) — if using Field, manually constrain to 0/1nullifier, secret, commitment, chain/contract metadata, leafIndex)leafIndex and root so the client can rebuild the treeMockVerifier, even locally — deploy scripts and dev/testnet wiring use the real HonkVerifier; MockVerifier is only for narrow unit testsDo not stop at "the circuit compiles." A working zk app needs tests at every boundary:
act(), and assert success.MockVerifier is only for narrow unit tests. Deploy scripts, local dev wiring, and integration tests use the real generated HonkVerifier.
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.
Use app-pattern repos for end-to-end architecture. Use primitives for narrow cryptographic building blocks.