2026-04-05·14 min read

Fuzzing ZK circuits: testing Noir and Circom with property-based approaches

Fuzzing ZK Circuits: Testing Noir and Circom with Property-Based Approaches

By deivitto — April 2026

ZK circuits aren't smart contracts. You can't just throw a Foundry fuzzer at them and hope for the best. The execution model is different, the bug classes are different, and the tooling is still catching up.

But the core idea of property-based testing — define what should always be true, then generate random inputs to check , applies just as well to circuits as it does to Solidity. You just need different techniques.

I've spent the last year pushing property-based approaches into ZK circuit testing for Noir and Circom. Here's what works, where the tooling is, and what bugs you'll actually catch.

Why ZK circuit testing is different

In a smart contract, a bug means wrong state or lost funds. In a ZK circuit, bugs are more subtle and arguably more dangerous.

Under-constrained circuits are the classic ZK bug. The circuit accepts proofs it shouldn't. A prover can generate a valid proof for a statement that's false. This is catastrophic, it silently breaks the entire security guarantee.

Over-constrained circuits are less dangerous but still problematic. The circuit rejects valid inputs. Users can't prove true statements. The system doesn't work, but at least it doesn't accept lies.

Witness generation mismatches happen when the witness generation code (the part that computes intermediate values) disagrees with the constraints. The circuit might be correctly constrained but the solver can't find a satisfying witness for valid inputs.

Soundness issues are the deepest class. The arithmetic relationship expressed by the constraints doesn't match the intended mathematical relationship. This requires understanding both the math and the circuit encoding.

None of these map cleanly to "run the function with random inputs and check if it reverts." You need properties that specifically target ZK failure modes.

Noir: property-Based testing with nargo

Noir has the most ergonomic testing story in the ZK space right now. nargo test supports basic testing out of the box, and with some work, you can build property-based testing on top.

Basic Noir test structure

// A simple Noir circuit: prove you know the preimage of a hash
fn main(preimage: [u8; 32], expected_hash: pub [u8; 32]) {
    let computed = std::hash::sha256(preimage);
    assert(computed == expected_hash);
}

#[test]
fn test_valid_preimage() {
    let preimage = [1; 32]; // known preimage
    let hash = std::hash::sha256(preimage);
    main(preimage, hash); // should pass
}

This is a unit test. It checks one input. For property-based testing, we want to check all inputs (or at least a lot of random ones).

Building property-Based tests in Noir

Noir doesn't have a built-in fuzzing framework yet, but you can build one using nargo's test infrastructure and a wrapper script.

// property_tests.nr

// Property: for ANY valid preimage, the circuit should accept
// the correctly computed hash
#[test]
fn property_correct_hash_always_accepted() {
    // We can't easily generate random bytes in Noir's test framework
    // So we test with deterministic but varied inputs
    let inputs: [[u8; 32]; 5] = [
        [0; 32],
        [1; 32],
        [255; 32],
        [0x42; 32],
        [0xDE; 32],
    ];

    for input in inputs {
        let hash = std::hash::sha256(input);
        main(input, hash); // must not fail
    }
}

// Property: a WRONG hash should always be rejected
#[test(should_fail)]
fn property_wrong_hash_always_rejected() {
    let preimage = [1; 32];
    let hash = std::hash::sha256(preimage);
    // Flip one bit in the hash
    let mut bad_hash = hash;
    bad_hash[0] = hash[0] ^ 1;
    main(preimage, bad_hash); // must fail
}

For real fuzzing, we drive input generation from outside Noir:

#!/bin/bash
# fuzz_noir_circuit.sh -- generate random inputs, run nargo

for i in $(seq 1 1000); do
    # Generate random 32-byte preimage
    PREIMAGE=$(python3 -c "import os; print(list(os.urandom(32)))")

    # Compute expected hash
    HASH=$(python3 -c "
import hashlib, os, sys
preimage = os.urandom(32)
h = hashlib.sha256(preimage).digest()
print(f'preimage = {list(preimage)}')
print(f'expected_hash = {list(h)}')
")

    # Write Prover.toml
    echo "$HASH" > Prover.toml

    # Run the circuit
    if ! nargo execute 2>/dev/null; then
        echo "FAILURE on iteration $i"
        echo "Input: $PREIMAGE"
        exit 1
    fi
done
echo "All 1000 iterations passed"

This is crude but effective. Each iteration generates random inputs, computes the expected output, and checks that the circuit accepts.

Testing for under-Constrained circuits in Noir

The real power of property-based testing for ZK is catching under-constrained circuits. The property is: "for the same public inputs, there should be at most one valid witness (or a known equivalence class)."

// If your circuit computes a deterministic function,
// there should be exactly one valid witness per public input.

// This Noir circuit is UNDER-CONSTRAINED:
fn bad_range_check(x: Field, max: pub Field) {
    // Intended: prove x < max
    // Bug: only constrains x * x < max * max
    // A negative field element could satisfy this
    assert(x * x as u64 < max * max as u64);
}

// Property test: can we find two different x values
// that both satisfy the circuit for the same max?
#[test]
fn property_unique_witness() {
    let max: Field = 100;
    // If x=5 satisfies the circuit...
    bad_range_check(5, max);
    // ...and x=p-5 (where p is the field modulus) also satisfies it,
    // the circuit is under-constrained
}

In practice, detecting under-constrained circuits requires more sophisticated techniques, often involving running the circuit with carefully crafted adversarial inputs.

Noir's nargo fuzz (Experimental)

As of early 2026, Noir has experimental fuzzing support. It's not fully mature, but it's getting there:

// With nargo's experimental fuzz support:
#[fuzz]
fn fuzz_transfer_circuit(
    sender_balance: u64,
    amount: u64,
    receiver_balance: u64
) {
    // Skip invalid inputs
    if amount > sender_balance {
        return;
    }

    // The transfer circuit should preserve total supply
    let total_before = sender_balance + receiver_balance;

    // Simulate the circuit logic
    let new_sender = sender_balance - amount;
    let new_receiver = receiver_balance + amount;
    let total_after = new_sender + new_receiver;

    assert(total_before == total_after);
}

Circom: property testing with circom-tester

Circom's testing story revolves around JavaScript/TypeScript testing with circom_tester. You write circuits in Circom and test them in JS.

Basic Circom test setup

// test/transfer.test.js
const { wasm } = require("circom_tester");
const path = require("path");

describe("Transfer Circuit", () => {
    let circuit;

    before(async () => {
        circuit = await wasm(
            path.join(__dirname, "../circuits/transfer.circom")
        );
    });

    it("should accept valid transfer", async () => {
        const input = {
            senderBalance: 100,
            amount: 30,
            receiverBalance: 50,
        };
        const witness = await circuit.calculateWitness(input, true);
        await circuit.checkConstraints(witness);
    });
});

Property-Based testing with fast-check

Combine circom_tester with a property testing library like fast-check:

const fc = require("fast-check");
const { wasm } = require("circom_tester");

describe("Transfer Circuit Properties", () => {
    let circuit;

    before(async () => {
        circuit = await wasm("circuits/transfer.circom");
    });

    it("should preserve total supply for any valid transfer", async () => {
        await fc.assert(
            fc.asyncProperty(
                fc.integer({ min: 0, max: 2 ** 32 }),  // senderBalance
                fc.integer({ min: 0, max: 2 ** 32 }),  // receiverBalance
                fc.integer({ min: 0, max: 2 ** 32 }),  // amount
                async (senderBalance, receiverBalance, amount) => {
                    // Precondition: valid transfer
                    fc.pre(amount <= senderBalance);
                    fc.pre(
                        receiverBalance + amount < 2 ** 64
                    ); // no overflow

                    const input = { senderBalance, amount, receiverBalance };

                    const witness = await circuit.calculateWitness(
                        input,
                        true
                    );
                    await circuit.checkConstraints(witness);

                    // Check: total supply preserved
                    const newSender = BigInt(
                        witness[circuit.getSignalIdx("main.newSenderBalance")]
                    );
                    const newReceiver = BigInt(
                        witness[
                            circuit.getSignalIdx("main.newReceiverBalance")
                        ]
                    );
                    const totalBefore =
                        BigInt(senderBalance) + BigInt(receiverBalance);
                    const totalAfter = newSender + newReceiver;

                    return totalBefore === totalAfter;
                }
            ),
            { numRuns: 1000 }
        );
    });

    it("should reject transfers exceeding balance", async () => {
        await fc.assert(
            fc.asyncProperty(
                fc.integer({ min: 1, max: 2 ** 32 }),
                fc.integer({ min: 0, max: 2 ** 32 }),
                async (senderBalance, extraAmount) => {
                    const amount = senderBalance + extraAmount + 1;
                    const input = {
                        senderBalance,
                        amount,
                        receiverBalance: 0,
                    };

                    try {
                        await circuit.calculateWitness(input, true);
                        // If we get here, the circuit accepted an
                        // invalid transfer -- that's a bug
                        return false;
                    } catch (e) {
                        // Circuit correctly rejected
                        return true;
                    }
                }
            ),
            { numRuns: 500 }
        );
    });
});

Testing for under-Constrained Circom circuits

Here's a concrete Circom circuit with an under-constrained bug and how to catch it:

// circuits/range_check.circom -- BUGGY
pragma circom 2.1.0;

template RangeCheck(N) {
    signal input value;
    signal input max_value;
    signal output in_range;

    // BUG: this doesn't actually constrain value < max_value
    // It only computes the comparison but doesn't assert it
    signal diff;
    diff <-- max_value - value;

    // Missing: constraint that diff is positive (range check on diff)
    // A prover could set diff to anything and still satisfy constraints

    in_range <-- (value < max_value) ? 1 : 0;
    // This is witness generation only -- no constraint!
}

component main = RangeCheck(64);

Property-based test that catches it:

it("should reject value >= max_value", async () => {
    await fc.assert(
        fc.asyncProperty(
            fc.bigInt({ min: 100n, max: 2n  64n }),
            fc.bigInt({ min: 0n, max: 99n }),
            async (value, max_value) => {
                fc.pre(value >= max_value); // Only test invalid inputs

                const input = {
                    value: value.toString(),
                    max_value: max_value.toString(),
                };

                try {
                    const witness = await circuit.calculateWitness(
                        input,
                        true
                    );
                    await circuit.checkConstraints(witness);

                    // If constraints pass for value >= max_value,
                    // the circuit is under-constrained!
                    const inRange = witness[
                        circuit.getSignalIdx("main.in_range")
                    ];

                    // The circuit should either:
                    // 1. Fail constraint check, OR
                    // 2. Output in_range = 0
                    // If in_range = 1 for invalid input,
                    // that's definitely broken
                    return inRange === 0n;
                } catch (e) {
                    // Constraint failure is acceptable -- means
                    // circuit correctly rejects
                    return true;
                }
            }
        ),
        { numRuns: 1000 }
    );
});

Common ZK bugs that property testing catches

1. missing range checks

The most common ZK bug. Field arithmetic wraps around the prime modulus, so values that "look" small might actually be large negative numbers in disguise.

Property: "For all outputs, the output value should be less than 2^N when the circuit claims a range check to N bits."

2. unchecked witness values

When you use <-- in Circom (assignment without constraint), the prover is free to set the value to anything. If there's no corresponding === constraint, the circuit is under-constrained.

Property: "For any given public input, changing the private witness values should either produce the same public output or fail constraint checking."

3. hash collision acceptance

Circuits that verify hash preimages should reject all inputs except the correct one.

Property: "For random preimage p, the circuit with expected_hash = H(p) should reject any preimage q where q != p." (With high probability for random inputs.)

4. arithmetic overflow in field operations

Field elements wrap around at the prime. p - 1 + 2 = 1 in the field. If your circuit assumes normal integer arithmetic, you'll have bugs.

Property: "For inputs near the field boundary (p-1, p-2, etc.), the circuit should either handle wraparound correctly or reject the input."

it("should handle field boundary correctly", async () => {
    const p = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;

    const boundaryInputs = [
        p - 1n,
        p - 2n,
        0n,
        1n,
        p / 2n,
    ];

    for (const input of boundaryInputs) {
        const circuitInput = { value: input.toString() };
        try {
            const witness = await circuit.calculateWitness(
                circuitInput, true
            );
            await circuit.checkConstraints(witness);
            // Verify output makes sense for this input
        } catch (e) {
            // Rejection is fine -- just ensure no panic/undefined behavior
        }
    }
});

5. nullifier uniqueness violations

In protocols that use nullifiers (like mixers or private transactions), each commitment should produce exactly one nullifier. If two different commitments produce the same nullifier, you've got a collision bug. If one commitment can produce two different nullifiers, you've got a double-spend.

Property: "For all commitments c1 != c2, nullifier(c1) != nullifier(c2)." Property: "For any commitment c, the nullifier is deterministic, computing it twice yields the same result."

Integrating with CI/CD

Property-based circuit tests should run on every commit. Here's a GitHub Actions setup:

# .github/workflows/zk-tests.yml
name: ZK Circuit Tests
on: [push, pull_request]

jobs:
  noir-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Noir
        run: |
          curl -L https://raw.githubusercontent.com/noir-lang/noirup/main/install | bash
          noirup
      - name: Run Noir tests
        run: nargo test

  circom-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - name: Install dependencies
        run: npm ci
      - name: Install Circom
        run: |
          cargo install --git https://github.com/iden3/circom.git
      - name: Run property tests
        run: npx mocha test//*.test.js --timeout 120000

How this differs from smart contract testing

A few key differences:

No state. Circuits are pure functions. There's no storage, no msg.sender, no block.timestamp. Every invocation is independent. This actually makes property testing easier, you don't need stateful fuzzing, just input/output properties.

Constraint satisfaction vs execution. In Solidity, you run the code and check the output. In ZK, you're checking two things: does the witness satisfy the constraints, and does the witness generation produce correct values? These can disagree.

Field arithmetic. Everything operates over a prime field. Your intuitions about integer arithmetic don't transfer directly. a - b when b > a doesn't revert, it wraps around the field modulus.

Proof generation vs constraint checking. Testing the constraints is fast. Generating actual proofs is slow. For property testing, you almost always want to test at the constraint level, not the proof level.

For broader context on fuzzing and how it applies across different domains, see our learning section. And if you're working on ZK circuit security more broadly, our guide on ZK circuit security audit patterns covers the audit perspective.

What's next for ZK circuit testing

The tooling is evolving fast. Here's what I'm watching:

  • Noir's native fuzzing. The experimental support will mature. When it does, property-based testing in Noir will be as easy as it is in Rust.
  • Formal verification for circuits. Tools that can mathematically prove circuit correctness, not just test it. This is the endgame for under-constrained detection.
  • Cross-framework testing. Testing the same logic implemented in both Noir and Circom to catch framework-specific bugs, a form of differential testing.
  • Adversarial witness generators. Tools that specifically try to find witnesses that satisfy constraints but violate the intended semantics. Think of it as a fuzzer that's trying to cheat the prover.

ZK security is where smart contract security was five years ago. The bugs are there, the stakes are high, and the testing practices are still forming. Property-based testing is the fastest way to close that gap.

Get a ZK circuit security review

Related Posts

Related Glossary Terms

Get your ZK circuits tested