How to Build Your First ZK App with Noir

What you are building

This lesson walks through building a complete ZK application using Noir from scratch. The application proves that a user knows a value whose hash equals a publicly known commitment, without revealing the value itself. It is the simplest meaningful ZK application: proof of preimage knowledge.

By the end you will have a working circuit, a generated proof, and a Solidity verifier contract that can verify the proof on-chain.

The toolchain

Noir development requires two tools:

nargo is Noir's CLI and package manager. It handles project creation, compilation, witness generation, testing, and proof generation. Install it using noirup:

``bash curl -L https://raw.githubusercontent.com/noir-lang/noirup/refs/heads/main/install | bash noirup ``

Verify the installation:

``bash nargo --version ``

bb is the Barretenberg proving backend developed by Aztec. It takes compiled circuits and witnesses and generates UltraHonk proofs. It also generates Solidity verifier contracts. Install it using bbup:

``bash curl -L https://raw.githubusercontent.com/AztecProtocol/aztec-packages/refs/heads/master/barretenberg/bbup/install | bash bbup ``

Verify:

``bash bb --version ``

bbup automatically installs a version of Barretenberg compatible with your nargo installation. You do not need to manage version compatibility manually.

Creating the project

``bash nargo new zk_preimage cd zk_preimage ``

This creates the following structure:

`` zk_preimage/ Nargo.toml ← project manifest src/ main.nr ← circuit entry point ``

The Nargo.toml file identifies the package:

```toml [package] name = "zk_preimage" type = "bin" authors = [""] compiler_version = ">=1.0.0"

[dependencies] ```

Writing the circuit

Open src/main.nr and replace the contents:

```rust use dep::std::hash::poseidon2;

fn main( secret: Field, // private — the preimage expected_hash: pub Field, // public — the commitment ) { let computed_hash = poseidon2::Poseidon2::hash([secret], 1); assert(computed_hash == expected_hash); } ```

Three things to understand about this circuit:

secret is a private input. It is the value the prover knows. It never appears in the proof or the public inputs. The verifier learns nothing about it.

expected_hash is marked pub. It is a public input. The verifier receives this value and checks the proof against it. If someone submits a proof for a different hash, verification fails.

poseidon2::Poseidon2::hash is Noir's standard library Poseidon2 hash. Poseidon2 is designed for ZK circuits. It is significantly cheaper in constraints than SHA256 or Keccak. The function takes an array of field elements and a message size parameter.

assert creates a constraint that must be satisfied. If computed_hash != expected_hash, the constraint fails and no valid proof can be generated.

Writing a test

Noir supports inline circuit tests using the #[test] attribute:

```rust #[test] fn test_valid_preimage() { let secret = 42; let hash = dep::std::hash::poseidon2::Poseidon2::hash([42], 1); main(secret, hash); }

#[test(should_fail)] fn test_wrong_hash() { main(42, 999); } ```

Run the tests:

``bash nargo test ``

Tests run the circuit without generating a proof. They are fast and useful for verifying circuit logic during development. The should_fail attribute asserts that the circuit fails to satisfy constraints for the given inputs, which is useful for verifying that invalid witnesses are rejected.

Compiling the circuit

``bash nargo compile ``

This produces target/zk_preimage.json, a JSON representation of the ACIR (Abstract Circuit Intermediate Representation). ACIR is the backend-agnostic intermediate format that Barretenberg consumes. The terminal output includes the circuit size:

`` Scheme is: ultra_honk Finalized circuit size: 312 ``

The circuit size reflects the number of gates in the constraint system. Poseidon2 is efficient: 312 gates is small. SHA256 for the same preimage proof would require roughly 30,000 gates.

Generating the witness

The witness is the full assignment of all signals in the circuit. Create Prover.toml in the project root with your private and public inputs:

``toml secret = "42" expected_hash = "0x..." # replace with actual hash ``

To compute the actual Poseidon2 hash of your secret, run a small test that prints it:

``rust #[test] fn print_hash() { let hash = dep::std::hash::poseidon2::Poseidon2::hash([42], 1); println(f"hash: {hash}"); } ``

``bash nargo test print_hash -- --show-output ``

Copy the printed hash into Prover.toml as the expected_hash value. Then execute the circuit to generate the witness:

``bash nargo execute ``

This produces target/zk_preimage.gz, the witness file. If your inputs do not satisfy the circuit constraints, this step fails with a constraint violation error. Errors here mean incorrect inputs, not a circuit bug.

Generating the proof

With the compiled circuit and witness, Barretenberg generates the cryptographic proof:

``bash bb prove \ -b ./target/zk_preimage.json \ -w ./target/zk_preimage.gz \ -o ./target/proof ``

This produces target/proof, a binary file containing the UltraHonk proof.

Verifying the proof locally

Generate the verification key first:

``bash bb write_vk \ -b ./target/zk_preimage.json \ -o ./target/vk ``

Then verify the proof against the verification key:

``bash bb verify \ -k ./target/vk \ -p ./target/proof ``

A successful verification prints confirmation. A failed verification means the proof is invalid, the public inputs were tampered with, or the verification key does not match the circuit.

Generating a Solidity verifier

For on-chain verification, Barretenberg generates a Solidity verifier contract directly from the verification key:

``bash bb write_solidity_verifier \ -k ./target/vk \ -o ./target/Verifier.sol ``

This produces a Verifier.sol contract with a verify function:

``solidity function verify( bytes calldata _proof, bytes32[] calldata _publicInputs ) external view returns (bool) ``

Deploy this contract and call verify with the proof bytes and public inputs. The contract returns true if the proof is valid, false otherwise.

Public inputs are passed as a bytes32 array in the order they appear in the Noir circuit. For this circuit, expected_hash is the only public input, so _publicInputs contains one element.

The public inputs passed to the Solidity verifier must exactly match those used when generating the proof. Order, count, and encoding must be identical. A mismatch produces a failed verification even if the underlying proof is valid.

The complete project structure

After completing all steps:

`` zk_preimage/ Nargo.toml Prover.toml ← private + public inputs src/ main.nr ← circuit target/ zk_preimage.json ← compiled ACIR zk_preimage.gz ← witness proof ← UltraHonk proof vk ← verification key Verifier.sol ← Solidity verifier contract ``

What you have built

The circuit proves that you know a value whose Poseidon2 hash equals a specific public commitment. The proof is a valid UltraHonk proof. The Solidity contract verifies that proof on-chain without learning anything about the secret value.

This is the foundational pattern for:

Private identity — prove you hold a credential whose hash is on a public registry without revealing the credential.

Anonymous voting — prove your vote was cast by a registered voter without revealing which voter.

ZK mixers — prove you deposited funds whose nullifier is unspent without revealing which deposit is yours.

Every more complex Noir application is this pattern extended. The circuit grows, the public inputs change, and the constraint count increases. The toolchain, the flow, and the structure remain identical.

Next steps

The next lesson moves from this single-circuit application to a more realistic architecture: a Merkle tree membership proof. That circuit introduces circomlib-equivalent standard library usage in Noir, multi-level constraint composition, and the nullifier pattern that underlies most production privacy applications.

Answer the quiz correctly to continue →

Video · Quiz1 / 3

In the Noir preimage proof circuit, why is Poseidon2 used instead of SHA256?