Write Your First Circuit with Noir & Barretenberg

From theory to syntax

The previous lesson built a complete ZK app from a single-constraint preimage circuit. This lesson goes deeper into Noir's language features — types, control flow, functions, and standard library primitives — by building progressively more complex circuits that demonstrate patterns you will use in real applications.

Each circuit in this lesson introduces one new concept. Read the code, understand what constraint it creates, then look at how nargo and Barretenberg process it.

The Field type and arithmetic

Every value in a Noir circuit is ultimately a field element. The Field type represents an element of the scalar field of the BN254 elliptic curve, a prime field with order approximately 2²⁵⁴. All arithmetic in a ZK circuit happens modulo this prime.

``rust fn main(a: Field, b: Field, result: pub Field) { assert(a + b == result); } ``

This circuit proves that you know two values a and b that sum to the public result. The + operator maps directly to field addition. One constraint is generated. No trusted setup required beyond the universal Barretenberg SRS.

Multiplication is equally direct:

``rust fn main(a: Field, b: Field, product: pub Field) { assert(a * b == product); } ``

One multiplication gate, one R1CS-equivalent constraint in the underlying UltraHonk constraint system.

What you cannot do directly in field arithmetic is comparison. Field has no ordering. A Field value of 5 is not less than 7 in any meaningful circuit sense without additional constraints. For comparisons, you need integer types.

Integer types

Noir provides sized integer types: u8, u16, u32, u64, and i8 through i64. These types carry range constraints automatically. Declaring a value as u8 constrains it to the range 0 to 255 without any additional assertions.

``rust fn main(age: u8, threshold: pub u8) { assert(age >= threshold); } ``

This circuit proves that age is at least threshold without revealing age. The compiler generates the bit decomposition constraints needed to implement the comparison in field arithmetic. With u8, this costs 8 range check constraints. The public input is the threshold, not the age.

The Prover.toml for this circuit:

``toml age = "25" threshold = "18" ``

Run:

``bash nargo execute bb prove -b ./target/age_check.json -w ./target/age_check.gz -o ./target/proof bb verify -k ./target/vk -p ./target/proof ``

Arrays

Arrays in Noir are fixed-size and their size must be known at compile time. This is a constraint-system requirement: the circuit structure is fixed, so every array operation must be unrolled into a fixed number of constraints.

``rust fn main(values: [Field; 5], expected_sum: pub Field) { let mut sum: Field = 0; for i in 0..5 { sum += values[i]; } assert(sum == expected_sum); } ``

This circuit proves that the sum of five private values equals a public total. The loop over 5 elements is unrolled by the compiler into 5 addition operations and 5 constraints.

The Prover.toml:

``toml values = ["10", "20", "30", "15", "25"] expected_sum = "100" ``

Array index access inside circuits is bounded. If you access values[i] where i is a witness rather than a constant, Noir generates a range check on i and uses a mux-style circuit to select the correct element. This is more expensive than constant-index access.

Structs

Structs let you group related values and pass them around the circuit as a unit:

```rust struct Credential { id: Field, expiry: u32, level: u8, }

fn main( cred: Credential, current_time: pub u32, min_level: pub u8, ) { assert(cred.expiry > current_time); assert(cred.level >= min_level); } ```

This circuit proves that a private credential is not expired and meets a minimum access level. The credential fields are private. The current time and minimum level are public. A verifier can confirm the credential is valid without learning its ID, exact expiry, or exact level.

Structs in Prover.toml are expressed as nested keys:

```toml current_time = "1700000000" min_level = "2"

[cred] id = "0x1234" expiry = "1800000000" level = "3" ```

Functions as circuit components

Noir functions work the way you expect from Rust: they take arguments, return values, and compose. When the compiler processes a Noir program, it inlines all function calls into a single flat constraint system. Functions are a code organization tool. They do not create separate circuits.

```rust fn is_valid_bit(b: u1) -> bool { (b == 0) | (b == 1) }

fn sum_bits(bits: [u1; 8]) -> u32 { let mut total: u32 = 0; for i in 0..8 { total += bits[i] as u32; } total }

fn main(bits: [u1; 8], expected_count: pub u32) { for i in 0..8 { assert(is_valid_bit(bits[i])); } assert(sum_bits(bits) == expected_count); } ```

This circuit proves that an array of 8 bits contains exactly expected_count ones. is_valid_bit enforces that each element is 0 or 1. sum_bits counts them. Both functions are inlined by the compiler into the flat constraint system.

The standard library

Noir's standard library provides the cryptographic primitives you need for ZK applications without requiring external dependencies for common operations.

Poseidon2 hash — the ZK-optimized hash function:

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

fn main(preimage: [Field; 2], hash: pub Field) { let computed = poseidon2::Poseidon2::hash(preimage, 2); assert(computed == hash); } ```

Poseidon2 is designed for arithmetic circuits. It uses field operations rather than bitwise operations. This makes it approximately 100 times cheaper in constraints than SHA256 for the same preimage size.

Pedersen commitment — for hiding values with a blinding factor:

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

fn main( value: Field, blinding: Field, commitment: pub [Field; 2], ) { let computed = pedersen_commitment::pedersen_commitment([value, blinding]); assert(computed.x == commitment[0]); assert(computed.y == commitment[1]); } ```

A Pedersen commitment hides a value while binding the prover to it. The blinding factor ensures the commitment does not reveal the value even if the verifier knows the value space.

Merkle membership — proving inclusion in a tree:

```rust use dep::std::merkle::compute_merkle_root;

fn main( leaf: Field, index: Field, hash_path: [Field; 3], root: pub Field, ) { let computed_root = compute_merkle_root(leaf, index, hash_path); assert(computed_root == root); } ```

compute_merkle_root takes a leaf value, its position in the tree, and the sibling hashes along the path to the root. It recomputes the root and asserts it matches the public root. The leaf value and path remain private. The root is public.

This is the foundational circuit for anonymous credential systems, private voting, and mixers. Anyone who knows a leaf and path can prove membership without revealing which leaf they hold.

Inspecting circuit constraints

Use nargo info to see the compiled circuit size:

``bash nargo info ``

Output:

`` +-------------+----------------------+--------------+----------------------+ | Package | Expression Width | ACIR Opcodes | Backend Circuit Size | +-------------+----------------------+--------------+----------------------+ | my_circuit | Bounded { width: 4 } | 12 | 47 | +-------------+----------------------+--------------+----------------------+ ``

The ACIR Opcodes count represents operations at the ACIR level before the Barretenberg backend converts them to its internal UltraHonk gate representation. The Backend Circuit Size is the actual gate count after this translation.

To inspect the raw ACIR opcodes:

``bash nargo info --print-acir ``

This shows the exact operations the constraint system contains. It is useful when optimizing circuits and identifying which operations generate the most constraints.

Common patterns and what to avoid

Prefer Poseidon2 over SHA256 and Keccak. SHA256 requires approximately 30,000 constraints in Noir. Poseidon2 requires approximately 300. Use SHA256 only when you need Ethereum-compatible hashing for specific on-chain interoperability reasons.

Use typed integers for values that have range semantics. A u32 timestamp, a u8 access level, or a u64 balance carries its range constraint for free. Using Field for these values requires you to add explicit range constraints manually or risk under-constraining the circuit.

Loop bounds must be constants. You cannot loop a variable number of times based on a witness value. The circuit structure is fixed at compile time. If you need variable-length processing, pad to a fixed maximum length and add a length parameter as a witness.

Assert your intermediate values. Unlike Circom's <-- pattern, Noir automatically constrains values computed with let. But logic errors where you compute the right thing for the wrong reason are still possible. Add explicit assert statements to verify invariants at critical points in your circuit logic, not just at the output.

Checking constraint inspection

For any circuit you build, run this sequence before moving to proof generation:

```bash # Check the circuit compiles and is valid nargo check

# Run tests to verify logic nargo test

# Inspect the circuit size nargo info

# Execute with your test inputs to catch witness errors nargo execute ```

These four commands catch errors at the circuit level before you involve the proving backend. Barretenberg proof generation is slower than witness execution. Fail fast at the circuit level before reaching the proving step.

The compiled artifact

After nargo compile, the target directory contains circuit_name.json. This is the ACIR bytecode that Barretenberg consumes. The structure includes the circuit's constraint system encoded as ACIR opcodes, the public input count, and metadata about the circuit.

You will reference this file in every subsequent Barretenberg command with the -b flag. It is the permanent record of your compiled circuit and should be committed to version control alongside your source code.

Answer the quiz correctly to continue →

Video · Quiz1 / 3

What is Barretenberg's role when working with Noir circuits?