Verify Noir Proofs in Aztec Contracts
Overview
In this tutorial, you will build a system that generates zero-knowledge proofs offchain using a Noir circuit and verifies them onchain within an Aztec Protocol smart contract. You will create a simple circuit that proves two values are not equal, generate an UltraHonk proof, deploy an Aztec contract that stores a verification key hash, and submit the proof for onchain verification. This pattern enables trustless computation where anyone can verify that a computation was performed correctly without revealing the private inputs.
This is called "recursive" verification because the proof is verified inside an Aztec private function, which itself gets compiled into a ZK circuit. The result is a proof being verified inside another proof. The Noir circuit you write is not recursive; the recursion happens at the Aztec protocol level when the private function execution (including the verify_honk_proof call) is proven.
The complete code for this tutorial is available at aztec-examples/recursive_verification. Clone it to follow along or use it as a reference.
Prerequisites
Before starting, ensure you have the following installed and configured:
- Node.js (v22 or later)
- yarn package manager
- Aztec CLI (version 3.0.0-devnet.6-patch.1)
- Nargo (version 1.0.0-beta.15)
- 8GB+ RAM (required for proof generation)
- Familiarity with Noir syntax and Aztec contract basics
Install the required tools:
# Install Aztec CLI
bash -i <(curl -s https://install.aztec.network)
aztec-up 3.0.0-devnet.6-patch.1
# Install Nargo via noirup
curl -L https://raw.githubusercontent.com/noir-lang/noirup/refs/heads/main/install | bash
noirup -v 1.0.0-beta.15
Part 1: Understanding the Architecture
The Core Problem
Aztec contracts have inherent limits on function inputs and transaction complexity. These constraints stem from the circuit-based nature of private execution. When your computation requires more inputs than these limits allow, or when the computation itself is too complex to fit in a single function, recursive proof verification provides an escape hatch.
For example, consider a machine learning inference that needs 10,000 input features, or a Merkle tree verification with 1,000 leaves. These cannot fit within a single Aztec function's input constraints. Instead, you can:
- Perform the computation offchain in a vanilla Noir circuit with no input limits
- Generate a proof of correct execution
- Verify only the proof onchain (115 fields for VK + 457-508 fields for proof + N public inputs)
This pattern transforms arbitrarily large computations into fixed-size proof verification.
Data Flow
The recursive verification pattern follows this data flow:
- Circuit Definition: Write a Noir circuit that defines the computation you want to prove
- Compilation: Compile the circuit with
nargo compileto produce bytecode - Proof Generation: Execute the circuit offchain and generate an UltraHonk proof using Barretenberg
- Onchain Verification: Submit the proof to an Aztec contract that verifies it using the stored verification key hash
Why this separation matters: The circuit defines what you're proving. The proof is evidence that you executed the circuit correctly with valid inputs. The onchain verifier checks the evidence without re-running the computation. This is what makes ZK proofs powerful: verification is orders of magnitude cheaper than computation.
Why Verify Proofs in Aztec Contracts?
Proof verification enables several patterns:
-
Bypassing Input Limits: Aztec private functions have strict input constraints. A proof verification call uses ~624 fields (115 VK + 508 proof + 1 public input), but can attest to computations with arbitrarily many inputs. For example, proving membership in a set of 10,000 elements becomes a fixed-size verification.
-
Cross-System Verification: Verify proofs generated by external Noir circuits within your Aztec application. This enables composability: your contract can trust computations performed by other systems without those systems needing to be Aztec-native.
-
Batching Operations: Aggregate multiple operations into a single proof. Instead of making N separate contract calls, prove all N operations were done correctly and verify once.
Why Use Aztec for Proof Verification?
Aztec provides a unique advantage: private function execution. When you verify a proof in an Aztec private function:
- The proof verification happens inside a zero-knowledge circuit
- The inputs to verification (the proof itself) can remain private
- You can compose proof verification with other private operations
This enables patterns impossible on transparent blockchains, like proving you have a valid credential without revealing which credential or when you obtained it.
UX Considerations: Multiple Proof Generation
When using recursive verification in Aztec, users experience two distinct proof generation phases:
-
Noir Proof Generation (application-specific):
- Happens before interacting with the Aztec contract
- Proves the computation (e.g., "I know values x and y where x ≠ y")
- Time depends on circuit complexity (seconds to minutes)
- Produces the proof and verification key that will be verified
-
Aztec Transaction Proof (protocol-level):
- Generated by the PXE when calling the private function
- Proves correct execution of the Aztec contract (including the
verify_honk_proofcall)
With this foundation in mind, let's build a complete example. You'll create a Noir circuit, generate a proof, and verify it inside an Aztec contract.
Part 2: Writing the Noir Circuit
Start by writing a simple circuit that proves two field values are not equal. This minimal example demonstrates the core pattern—you can extend it for more complex computations like Merkle proofs, credential verification, or something else entirely.
Create the Circuit Project
Use nargo new to generate the project structure:
nargo new circuit
This creates the following structure:
circuit/
├── src/
│ └── main.nr # Circuit code
└── Nargo.toml # Circuit configuration
Circuit Code
Replace the contents of circuit/src/main.nr with:
fn main(x: u64, y: pub u64) {
assert(x != y);
}
#[test]
fn test_main() {
main(1, 2);
}
This is intentionally minimal to focus on the verification pattern. In production, you would replace assert(x != y) with meaningful computations like:
- Merkle tree membership proofs
- Hash preimage verification
- Range proofs (proving a value is within bounds)
- Credential verification
- Email verification (proving you received an email from a domain without revealing its contents, like zkEmail)
Understanding Private vs Public Inputs
The circuit has two inputs with different visibility:
-
x: Field- A private input known only to the prover. This value is never revealed onchain or included in the proof data. The verifier cannot determine what value was used—only that some valid value exists. -
y: pub Field- A public input that is visible to the verifier. This value is included in the proof data, but since proof verification happens within a private function, it isn't exposed onchain unless you explicitly reveal it.
Why this distinction matters: The circuit asserts that x != y. The prover demonstrates they know a secret value x that differs from the public value y.
Public inputs don't have to come from the caller. During verification, the Aztec contract can read values from its own storage and use them as public inputs. This pattern ties the proof to contract state—the prover must generate a proof against the current stored value and cannot substitute a different public input.
To make the "public input" truly public, the contract developer can enqueue a public function call from the private function that verifies the proof, passing the public input to a public function to be logged or verified against public state.
For example, you could create a zkpassport proof demonstrating that you are over a certain age. The proof is verified in a private function, then the age (the public input) is passed to a public function where it's compared against a mutable threshold in public storage.
Circuit Configuration
Update circuit/Nargo.toml (see Noir crates and packages for more details):
[package]
name = "hello_circuit"
type = "bin"
authors = ["[YOUR_NAME]"]
[dependencies]
Note: This is a vanilla Noir circuit, not an Aztec contract. It has type = "bin" (binary) and no Aztec dependencies. The circuit is compiled with nargo, not aztec compile. This distinction is important—you can verify proofs from any Noir circuit inside Aztec contracts.
Compile the Circuit
cd circuit
nargo compile
This generates target/hello_circuit.json containing:
- Bytecode: The compiled circuit representation
- ABI (Application Binary Interface): Describes the circuit's inputs and outputs, including which are public
The TypeScript code uses the ABI to correctly format inputs during witness generation.
Test the Circuit
nargo test
Expected output:
[hello_circuit] Running 1 test functions
[hello_circuit] Testing test_main... ok
[hello_circuit] All tests passed
Tip: Circuit tests run without generating proofs, making them fast for development. Use them to verify your circuit logic before the more expensive proof generation step.
Part 3: Writing the Aztec Contract
The Aztec contract stores the verification key hash and verifies proofs submitted by users. When a valid proof is submitted, it increments a counter for the caller.
Why This Contract Design?
The contract demonstrates several important patterns:
-
VK Hash Storage: Instead of storing the full 115-field verification key onchain (expensive), we store only its hash (1 field). The prover submits the full VK with each proof, and the contract verifies it matches the stored hash.
-
Private-to-Public Flow: Proof verification happens in a private function (generating a ZK proof of the verification), but the counter update happens in a public function (visible state change). This separation is fundamental to Aztec's architecture.
-
Self-Only Public Functions: The
_increment_publicfunction can only be called by the contract itself, not external accounts (similar tointernalfunctions in Solidity). This ensures the counter can only be modified after successful proof verification.
Create the Contract Project
Use aztec init to generate the contract project structure:
aztec init --contract contract
This creates:
contract/
├── src/
│ └── main.nr # Contract code
└── Nargo.toml # Contract configuration
Contract Configuration
Update contract/Nargo.toml with the required dependencies:
[package]
name = "ValueNotEqual"
type = "contract"
authors = ["[YOUR_NAME]"]
[dependencies]
aztec = { git = "https://github.com/AztecProtocol/aztec-nr/", tag = "v3.0.0-devnet.6-patch.1", directory = "aztec" }
bb_proof_verification = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v3.0.0-devnet.6-patch.1", directory = "barretenberg/noir/bb_proof_verification" }
Key differences from the circuit's Nargo.toml:
type = "contract"(not"bin")- Depends on
aztecfor Aztec-specific features - Depends on
bb_proof_verificationforverify_honk_proof
Contract Structure
Replace the contents of contract/src/main.nr with:
use aztec::macros::aztec;
#[aztec]
pub contract ValueNotEqual {
use aztec::{
macros::{functions::{external, initializer, internal, only_self, view}, storage::storage},
oracle::debug_log::debug_log_format,
protocol_types::{address::AztecAddress, traits::ToField},
state_vars::{Map, PublicImmutable, PublicMutable},
};
use bb_proof_verification::{UltraHonkVerificationKey, UltraHonkZKProof, verify_honk_proof};
#[storage]
struct Storage<Context> {
counters: Map<AztecAddress, PublicMutable<Field, Context>, Context>,
vk_hash: PublicImmutable<Field, Context>,
}
#[initializer]
#[external("public")]
fn constructor(headstart: Field, owner: AztecAddress, vk_hash: Field) {
self.storage.counters.at(owner).write(headstart);
self.storage.vk_hash.initialize(vk_hash);
}
#[external("private")]
fn increment(
owner: AztecAddress,
verification_key: UltraHonkVerificationKey,
proof: UltraHonkZKProof,
public_inputs: [Field; 1],
) {
debug_log_format("Incrementing counter for owner {0}", [owner.to_field()]);
// Read the stored VK hash - this is readable from private context
// because PublicImmutable values are committed at deployment
let vk_hash = self.storage.vk_hash.read();
// Verify the proof - this is the core operation
// The function checks:
// 1. The VK hashes to the stored vk_hash
// 2. The proof is valid for the given VK and public inputs
verify_honk_proof(verification_key, proof, public_inputs, vk_hash);
// If we reach here, the proof is valid
// Enqueue a public function call to update state
self.enqueue_self._increment_public(owner);
}
#[only_self]
#[external("public")]
fn _increment_public(owner: AztecAddress) {
let current = self.storage.counters.at(owner).read();
self.storage.counters.at(owner).write(current + 1);
}
#[view]
#[external("public")]
fn get_counter(owner: AztecAddress) -> Field {
self.storage.counters.at(owner).read()
}
}
Storage Variables Explained
The contract uses two storage types with different characteristics:
vk_hash: PublicImmutable<Field>
PublicImmutable is perfect for values that:
- Are set once during contract initialization
- Never change after deployment
- Need to be readable from both public and private contexts
The VK hash fits all these criteria. Once you deploy a contract to verify proofs from a specific circuit, the circuit (and thus its VK) shouldn't change.
Why store the hash instead of the full VK?
- Storage costs: 1 field vs 115 fields
- The prover already has the full VK (needed to generate the proof)
- Hash verification is cheap compared to storing/loading 115 fields
counters: Map<AztecAddress, PublicMutable<Field>>
PublicMutable is used for values that:
- Change over time
- Are updated by public functions
- Need to be visible onchain
The counter must be PublicMutable because it's modified by _increment_public, a public function. Private functions cannot directly write to public state; they can only enqueue public function calls.
Function Breakdown
1. constructor (public initializer)
#[initializer]
#[external("public")]
fn constructor(headstart: Field, owner: AztecAddress, vk_hash: Field) {
self.storage.counters.at(owner).write(headstart);
self.storage.vk_hash.initialize(vk_hash);
}
#[initializer]: Marks this as the constructor, called once during deployment#[external("public")]: Executes publicly (visible onchain)- Sets the initial counter value for the owner
- Stores the VK hash using
initialize()(required forPublicImmutable)
2. increment (private function)
#[external("private")]
fn increment(
owner: AztecAddress,
verification_key: UltraHonkVerificationKey,
proof: UltraHonkZKProof,
public_inputs: [Field; 1],
) {
let vk_hash = self.storage.vk_hash.read();
verify_honk_proof(verification_key, proof, public_inputs, vk_hash);
self.enqueue_self._increment_public(owner);
}
#[external("private")]: Executes privately (generates a ZK proof of execution in the PXE)- Reads VK hash from storage (allowed because
PublicImmutableis readable in private context) - Calls
verify_honk_proof()which:- Computes the hash of the provided verification key
- Checks it matches the stored
vk_hash - Verifies the proof against the VK and public inputs
- Fails (reverts) if any check fails
- Uses
enqueue_self._increment_public(owner)to schedule a public function call
Why enqueue_self instead of a direct call?
In Aztec, private functions cannot directly modify public state. Instead, they enqueue public function calls that execute after the private phase completes. This ensures:
- Private execution remains private (no public state reads during private execution)
- State updates are atomic (all enqueued calls execute or none do)
- The execution order is deterministic
3. _increment_public (public, self-only)
#[only_self]
#[external("public")]
fn _increment_public(owner: AztecAddress) {
let current = self.storage.counters.at(owner).read();
self.storage.counters.at(owner).write(current + 1);
}
#[only_self]: Only callable by the contract itself (viaenqueue_self)#[external("public")]: Executes publicly- Reads the current counter and increments it
Why #[only_self]?
Without this modifier, anyone could call _increment_public directly, bypassing proof verification. The #[only_self] modifier ensures the function is only reachable through the private increment function, which requires a valid proof.
4. get_counter (public view)
#[view]
#[external("public")]
fn get_counter(owner: AztecAddress) -> Field {
self.storage.counters.at(owner).read()
}
#[view]: Read-only function, doesn't modify state- Returns the counter value for any address
Part 4: TypeScript Setup and Proof Generation
Before compiling the contract or running any TypeScript scripts, set up the project with the necessary configuration files and dependencies.
Project Setup
Create the following files in your project root directory.
Create package.json:
{
"name": "recursive-verification-tutorial",
"type": "module",
"scripts": {
"ccc": "cd contract && aztec compile && aztec codegen target -o artifacts",
"data": "tsx scripts/generate_data.ts",
"recursion": "tsx scripts/run_recursion.ts"
},
"dependencies": {
"@aztec/accounts": "3.0.0-devnet.6-patch.1",
"@aztec/aztec.js": "3.0.0-devnet.6-patch.1",
"@aztec/bb.js": "3.0.0-devnet.6-patch.1",
"@aztec/kv-store": "3.0.0-devnet.6-patch.1",
"@aztec/noir-contracts.js": "3.0.0-devnet.6-patch.1",
"@aztec/noir-noir_js": "3.0.0-devnet.6-patch.1",
"@aztec/pxe": "3.0.0-devnet.6-patch.1",
"@aztec/test-wallet": "3.0.0-devnet.6-patch.1",
"tsx": "^4.20.6"
},
"devDependencies": {
"@types/node": "^22.0.0"
},
"peerDependencies": {
"typescript": "^5.0.0"
}
}
Create tsconfig.json:
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
}
}
Install dependencies:
yarn install
This installs all the Aztec packages needed for proof generation and contract interaction. The installation may take a few minutes due to the size of the cryptographic libraries.
Compile the Contract
Now compile the Aztec contract and generate TypeScript bindings:
yarn ccc
What this command does (see How to Compile a Contract for details):
aztec compile: Compiles the Noir contract and post-processes it for Aztec (different fromnargo compile)aztec codegen: Generates TypeScript bindings from the contract artifact, enabling type-safe contract interaction
This generates:
contract/target/ValueNotEqual.json- Contract artifact (bytecode, ABI, etc.)contract/artifacts/ValueNotEqual.ts- TypeScript class for deploying and interacting with the contract
Proof Generation Script
The proof generation script executes the circuit offchain and produces the proof data needed for onchain verification.
Create scripts/generate_data.ts:
import { Noir } from "@aztec/noir-noir_js";
import circuitJson from "../circuit/target/hello_circuit.json" with { type: "json" };
import { Barretenberg, UltraHonkBackend, deflattenFields } from "@aztec/bb.js";
import fs from "fs";
import { exit } from "process";
// Step 1: Initialize Barretenberg API (the proving system backend)
// Barretenberg is the C++ library that implements UltraHonk
// threads: 1 uses single-threaded mode (increase for faster proofs on multi-core machines)
const barretenbergAPI = await Barretenberg.new({ threads: 1 });
// Step 2: Create Noir circuit instance from compiled bytecode
// This loads the circuit definition so we can execute it
const helloWorld = new Noir(circuitJson as any);
// Step 3: Execute circuit with inputs to generate witness
// The witness is all intermediate values computed during circuit execution
// x=1 (private), y=2 (public) - proves that 1 != 2
const { witness: mainWitness } = await helloWorld.execute({ x: 1, y: 2 });
// Step 4: Create UltraHonk backend with circuit bytecode
// The backend handles proof generation and verification
const mainBackend = new UltraHonkBackend(circuitJson.bytecode, barretenbergAPI);
// Step 5: Generate proof targeting the noir-recursive verifier
// verifierTarget: 'noir-recursive' creates a proof format suitable for
// verification inside another Noir circuit (which is what Aztec contracts are)
const mainProofData = await mainBackend.generateProof(mainWitness, {
verifierTarget: "noir-recursive",
});
// Step 6: Verify proof locally before saving
// This catches errors early - if verification fails here, it will fail onchain too
const isValid = await mainBackend.verifyProof(mainProofData, {
verifierTarget: "noir-recursive",
});
console.log(`Proof verification: ${isValid ? "SUCCESS" : "FAILED"}`);
// Step 7: Generate recursive artifacts for onchain use
// This converts the proof and VK into field element arrays that can be
// passed to the Aztec contract
const recursiveArtifacts = await mainBackend.generateRecursiveProofArtifacts(
mainProofData.proof,
mainProofData.publicInputs.length,
);
// Step 8: Convert proof to field elements if needed
// Some versions return empty proofAsFields, requiring manual conversion
let proofAsFields = recursiveArtifacts.proofAsFields;
if (proofAsFields.length === 0) {
console.log("Using deflattenFields to convert proof...");
proofAsFields = deflattenFields(mainProofData.proof).map((f) => f.toString());
}
const vkAsFields = recursiveArtifacts.vkAsFields;
console.log(`VK size: ${vkAsFields.length}`); // Should be 115
console.log(`Proof size: ${proofAsFields.length}`); // Should be 508
console.log(`Public inputs: ${mainProofData.publicInputs.length}`); // Should be 1
// Step 9: Save all data to JSON for contract interaction
const data = {
vkAsFields: vkAsFields, // 115 field elements - the verification key
vkHash: recursiveArtifacts.vkHash, // Hash of VK - stored in contract
proofAsFields: proofAsFields, // 508 field elements - the proof
publicInputs: mainProofData.publicInputs.map((p: string) => p.toString()),
};
fs.writeFileSync("data.json", JSON.stringify(data, null, 2));
await barretenbergAPI.destroy();
console.log("Done");
exit();
Understanding the Proof Generation Pipeline
Setup
- Initialize Barretenberg (the cryptographic backend)
- Load the compiled circuit
Witness Generation
const { witness: mainWitness } = await helloWorld.execute({ x: 1, y: 2 });
The witness contains all values computed during circuit execution, not just inputs and outputs, but every intermediate value. The prover needs the witness to construct the proof. The verifier never sees the witness (that's the point of ZK proofs).
Proof Generation
const mainProofData = await mainBackend.generateProof(mainWitness, {
verifierTarget: "noir-recursive",
});
Why verifierTarget: 'noir-recursive'? There are different proof formats optimized for different verifiers:
- Native verifiers (standalone programs)
- Smart contract verifiers (Solidity)
- Recursive verifiers (inside other ZK circuits)
Aztec contracts are compiled to ZK circuits, so verify_honk_proof runs inside a circuit. We need the recursive-friendly proof format.
Local Verification
const isValid = await mainBackend.verifyProof(mainProofData, {
verifierTarget: "noir-recursive",
});
Always verify locally before submitting onchain. Onchain verification costs gas/fees and takes time. Local verification is free and instant.
Field Element Conversion
ZK proofs are arrays of bytes, but Aztec contracts work with field elements. We convert the proof and VK to arrays of 115 and 508 field elements respectively.
let proofAsFields = recursiveArtifacts.proofAsFields;
if (proofAsFields.length === 0) {
console.log("Using deflattenFields to convert proof...");
proofAsFields = deflattenFields(mainProofData.proof).map((f) => f.toString());
}
const vkAsFields = recursiveArtifacts.vkAsFields;
Some versions of the library return an empty proofAsFields array, requiring manual conversion via deflattenFields.
Saving Data for Contract Interaction
const data = {
vkAsFields: vkAsFields,
vkHash: recursiveArtifacts.vkHash,
proofAsFields: proofAsFields,
publicInputs: mainProofData.publicInputs.map((p: string) => p.toString()),
};
fs.writeFileSync("data.json", JSON.stringify(data, null, 2));
await barretenbergAPI.destroy();
The data is saved as JSON so the deployment script can load it. We call barretenbergAPI.destroy() to clean up the WebAssembly resources used by Barretenberg. This is important because Barretenberg allocates significant memory for cryptographic operations, and not destroying it can cause memory leaks in long-running processes.
Run Proof Generation
yarn data
Expected output:
Proof verification: SUCCESS
Using deflattenFields to convert proof...
VK size: 115
Proof size: 508
Public inputs: 1
Done
Output Format
The generated data.json contains:
{
"vkAsFields": ["0x...", "0x...", ...], // 115 field elements
"vkHash": "0x...", // Single field element
"proofAsFields": ["0x...", "0x...", ...], // 508 field elements
"publicInputs": ["2"] // The public input y=2
}
What each field is used for:
vkHash: Passed to the contract constructor, stored permanentlyvkAsFields: Passed toincrement(), verified against stored hashproofAsFields: Passed toincrement(), verified byverify_honk_proofpublicInputs: Passed toincrement(), must match what was used during proof generation
Part 5: Deploying and Verifying
The deployment script connects to the Aztec network, creates an account, deploys the contract, and submits a proof for verification.
Deployment Script
Create scripts/run_recursion.ts:
import { createAztecNodeClient } from "@aztec/aztec.js/node";
import { SponsoredFeePaymentMethod } from "@aztec/aztec.js/fee";
import type { FieldLike } from "@aztec/aztec.js/abi";
import { getSponsoredFPCInstance } from "./sponsored_fpc.ts";
import { SponsoredFPCContract } from "@aztec/noir-contracts.js/SponsoredFPC";
import { ValueNotEqualContract } from "../contract/artifacts/ValueNotEqual";
import data from "../data.json";
import { getPXEConfig } from "@aztec/pxe/config";
import { TestWallet } from "@aztec/test-wallet/server";
import { AztecAddress } from "@aztec/aztec.js/addresses";
import { rm } from "node:fs/promises";
import assert from "node:assert";
export const NODE_URL = "http://localhost:8080";
// Setup sponsored fee payment - the FPC pays transaction fees for us
const sponsoredFPC = await getSponsoredFPCInstance();
const sponsoredPaymentMethod = new SponsoredFeePaymentMethod(
sponsoredFPC.address,
);
// Initialize wallet and connect to local network
// The wallet manages accounts and sends transactions through the PXE
export const setupWallet = async (): Promise<TestWallet> => {
try {
// Connect to the Aztec node (runs the rollup)
const aztecNode = await createAztecNodeClient(NODE_URL);
// Configure PXE (Private eXecution Environment)
// PXE runs on the client and handles private execution
const config = getPXEConfig();
await rm("pxe", { recursive: true, force: true });
config.dataDirectory = "pxe";
config.proverEnabled = true; // Enable proof generation
// Create wallet with embedded PXE
let wallet = await TestWallet.create(aztecNode, config);
// Register the sponsored FPC so the wallet knows about it
await wallet.registerContract(sponsoredFPC, SponsoredFPCContract.artifact);
return wallet;
} catch (error) {
console.error("Failed to setup local network:", error);
throw error;
}
};
async function main() {
// Step 1: Setup wallet and create account
// Accounts in Aztec are smart contracts (account abstraction)
// See: https://docs.aztec.network/aztec/concepts/accounts
const testWallet = await setupWallet();
const account = await testWallet.createAccount();
const manager = await account.getDeployMethod();
// Deploy the account contract
await manager
.send({
from: AztecAddress.ZERO,
fee: { paymentMethod: sponsoredPaymentMethod },
})
.deployed();
const accounts = await testWallet.getAccounts();
// Step 2: Deploy ValueNotEqual contract
// Constructor args: initial counter (10), owner, VK hash
const valueNotEqual = await ValueNotEqualContract.deploy(
testWallet,
10, // Initial counter value
accounts[0].item, // Owner address
data.vkHash as unknown as FieldLike, // VK hash for verification
)
.send({
from: accounts[0].item,
fee: { paymentMethod: sponsoredPaymentMethod },
})
.deployed();
console.log(`Contract deployed at: ${valueNotEqual.address}`);
const opts = {
from: accounts[0].item,
fee: { paymentMethod: sponsoredPaymentMethod },
};
// Step 3: Read initial counter value
// simulate() executes without submitting a transaction
let counterValue = await valueNotEqual.methods
.get_counter(accounts[0].item)
.simulate({ from: accounts[0].item });
console.log(`Counter value: ${counterValue}`); // Should be 10
// Step 4: Call increment() with proof data
// This creates a transaction that:
// 1. Executes the private increment() function (client-side)
// 2. Generates a ZK proof of correct execution
// 3. Submits the proof to the network
// 4. Network verifies the proof
// 5. Executes enqueued _increment_public()
const interaction = await valueNotEqual.methods.increment(
accounts[0].item,
data.vkAsFields as unknown as FieldLike[], // 115 field VK
data.proofAsFields as unknown as FieldLike[], // 508 field proof
data.publicInputs as unknown as FieldLike[], // Public inputs
);
// Step 5: Send transaction and wait for inclusion
// wait() blocks until the transaction is included in a block
await interaction.send(opts).wait();
// Step 6: Read updated counter
counterValue = await valueNotEqual.methods
.get_counter(accounts[0].item)
.simulate({ from: accounts[0].item });
console.log(`Counter value: ${counterValue}`); // Should be 11
assert(counterValue === 11n, "Counter should be 11 after verification");
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
Understanding the Deployment Script
Sponsored Fee Payment
Aztec transactions require fees. For testing, we use a Sponsored Fee Payment Contract (FPC) that pays fees on behalf of users:
const sponsoredFPC = await getSponsoredFPCInstance();
const sponsoredPaymentMethod = new SponsoredFeePaymentMethod(
sponsoredFPC.address,
);
In production, you would use real fee payment methods (native tokens, ERC20, etc.).
What Happens During increment().send().wait()
This single line triggers a complex flow:
-
Private Execution (client-side, in PXE):
- Execute
increment()with provided arguments - Read
vk_hashfrom contract storage - Execute
verify_honk_proof()inside the private function - Generate the
enqueue_self._increment_public(owner)call
- Execute
-
Proof Generation (client-side, in PXE):
- Generate a ZK proof that the private execution was correct
- This proof doesn't reveal inputs (including the 508-field proof!)
-
Transaction Submission:
- Send the proof + encrypted logs + public function calls to the network
-
Verification & Public Execution (onchain):
- Network verifies the private execution proof
- Execute
_increment_public(owner)publicly - Update the counter in storage
Supporting Utility
Create scripts/sponsored_fpc.ts:
import { getContractInstanceFromInstantiationParams } from "@aztec/aztec.js/contracts";
import { Fr } from "@aztec/aztec.js/fields";
import { SponsoredFPCContract } from "@aztec/noir-contracts.js/SponsoredFPC";
const SPONSORED_FPC_SALT = new Fr(BigInt(0));
export async function getSponsoredFPCInstance() {
return await getContractInstanceFromInstantiationParams(
SponsoredFPCContract.artifact,
{
salt: SPONSORED_FPC_SALT,
},
);
}
This utility computes the address of the pre-deployed sponsored FPC contract. The salt ensures we get the same address every time. For more information about fee payment options, see Paying Fees.
Start the Local Network
In a separate terminal, start the Aztec local network:
aztec start --local-network
What this starts:
- Anvil: A local Ethereum node (L1)
- Aztec Node: The L2 rollup node
- PXE: Private eXecution Environment (embedded in node for local development)
Wait for the network to fully initialize. You should see logs indicating readiness. The PXE will be available at http://localhost:8080.
Deploy and Verify
Run the deployment script:
yarn recursion
Expected output:
Contract deployed at: 0x...
Counter value: 10
Counter value: 11
The counter starts at 10 (set during deployment), and after successful proof verification, it increments to 11. This confirms that the Noir proof was verified inside the Aztec contract.
Quick Reference
If you want to run all commands at once, or if you're starting fresh, here's the complete workflow. You can also clone the full working example to get started quickly.
# Install dependencies (after creating package.json and tsconfig.json)
yarn install
# Compile the Noir circuit
cd circuit && nargo compile && cd ..
# Compile the Aztec contract and generate TypeScript bindings
yarn ccc
# Generate proof data
yarn data
# Start the local network (in a separate terminal)
aztec start --local-network
# Deploy and verify
yarn recursion
Next Steps
Now that you understand the basics of proof verification in Aztec contracts, explore these topics:
-
Simpler Contract Examples: If you're new to Aztec contracts, the Counter Tutorial provides a gentler introduction to contract development patterns.
-
Multiple Public Inputs: Extend the circuit to have multiple public inputs. Update
public_inputs: [Field; 1]in the contract to match. -
Noir Language Reference: Explore advanced Noir features like loops, arrays, and standard library functions at noir-lang.org.