5/5
## Crafting a `generateProof.ts` Script for Your ZK-Mixer This lesson guides you through creating a TypeScript script, `generateProof.ts`, designed to generate zero-knowledge proofs for a ZK-Mixer. This script interacts with a compiled Noir circuit and is intended to be executed from the command line, often as part of a testing environment like Foundry. The generated proof can then be utilized in on-chain transactions, such as withdrawals from the mixer. ### Prerequisites: Importing Essential Libraries To begin, we need to import several crucial libraries that provide the necessary cryptographic functionalities, Ethereum interaction capabilities, Noir circuit interface, and file system utilities. ```typescript // generateProof.ts import { Barretenberg, Fr, UltraHonkBackend } from "@aztec/bb.js"; import { ethers } from "ethers"; import { Noir } from "@noir-lang/noir_js"; import fs from "fs"; import path from "path"; import { merkleTree } from "./merkleTree.js"; // Helper for off-chain Merkle tree ``` * `@aztec/bb.js`: * `Barretenberg`: The core library for Barretenberg cryptographic operations. * `Fr`: Represents elements in a finite field, fundamental for ZK arithmetic. * `UltraHonkBackend`: The specific proving backend we'll use for generating proofs with the Honk proving system. * `ethers`: A comprehensive library for interacting with the Ethereum blockchain, used here for ABI encoding the proof. * `@noir-lang/noir_js`: The JavaScript/TypeScript library to load and interact with compiled Noir circuits. * `fs` (File System) and `path` (Node.js built-ins): Standard Node.js modules for reading files and resolving file paths. * `./merkleTree.js`: A local helper module responsible for constructing and managing the Merkle tree off-chain. This module typically exports an asynchronous function or class (`merkleTree` in this case) that handles tree construction using Poseidon hashing and proof generation. We'll discuss its conceptual role later. ### Loading Your Compiled Noir Circuit The script needs access to the compiled Noir circuit, which includes the ACIR (Abstract Circuit Intermediate Representation) bytecode and the ABI (Application Binary Interface). This is typically stored in a JSON file generated by the Noir compilation process. ```typescript // generateProof.ts const circuit = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../circuits/target/circuits.json"), "utf8")); ``` * `__dirname`: A Node.js global variable that gives the directory name of the current module. This helps in creating paths relative to the script's location. * `path.resolve(...)`: Combines path segments to create an absolute path to the `circuits.json` file. The relative path `../../circuits/target/circuits.json` indicates that the compiled circuit is located two directories above the script's directory, then within `circuits/target/`. Ensure this path accurately reflects your project structure. * `fs.readFileSync(...)`: Synchronously reads the content of the JSON file. * `JSON.parse(...)`: Parses the JSON string into a JavaScript object. ### The `generateProof` Function: Orchestrating Proof Generation The core logic of our script resides in an asynchronous function, `generateProof`. This function will handle input processing, cryptographic computations, interaction with the Noir circuit, and finally, outputting the generated proof. ```typescript // generateProof.ts export default async function generateProof(): Promise<string> { // ... implementation details will follow } ``` This function is exported as the default, making it the primary entry point if this module is imported elsewhere, and it returns a `Promise<string>` which will resolve to the ABI-encoded proof. #### 1. Initializing Barretenberg First, we initialize an instance of the Barretenberg library. This is a prerequisite for performing cryptographic operations like hashing. ```typescript // Inside generateProof function const bb = await Barretenberg.new(); ``` #### 2. Processing Command-Line Interface (CLI) Inputs The script is designed to receive inputs from the command line. These inputs are crucial for the proof generation process and correspond to the private and public inputs of our ZK-Mixer circuit. ```typescript // Inside generateProof function const cliInputs = process.argv.slice(2); // Get arguments after 'node' and script_name // The script expects: nullifier, secret, recipient, ...leaves const nullifier = Fr.fromString(cliInputs[0]); const secret = Fr.fromString(cliInputs[1]); const recipient = cliInputs[2]; // recipient is an address string const leaves = cliInputs.slice(3); // The rest are Merkle tree leaves ``` * `process.argv`: An array containing the command-line arguments passed when the Node.js process was launched. `process.argv[0]` is the path to `node`, and `process.argv[1]` is the path to the script. * `.slice(2)`: Extracts the actual arguments provided to the script. * The script expects arguments in a specific order: 1. `nullifier` (string, to be converted to `Fr`) 2. `secret` (string, to be converted to `Fr`) 3. `recipient` (string, Ethereum address) 4. `...leaves` (remaining arguments are strings representing Merkle tree leaves) * `Fr.fromString()`: Converts the string representations of the `nullifier` and `secret` from the CLI into `Fr` (Field element) types, as required by Barretenberg and the Noir circuit. #### 3. Calculating Cryptographic Hashes and Commitment Next, we calculate essential cryptographic values: the nullifier hash and the commitment. These are typically derived using a SNARK-friendly hash function like Poseidon. ```typescript // Inside generateProof function const nullifierHash = await bb.poseidon2Hash([nullifier]); const commitment = await bb.poseidon2Hash([nullifier, secret]); ``` * `bb.poseidon2Hash([...])`: Uses the initialized Barretenberg instance to compute Poseidon hashes. * The `nullifierHash` is the hash of the `nullifier` itself. * The `commitment` is the hash of the `nullifier` and `secret` combined, representing the "note" or "deposit" in the mixer. #### 4. Reconstructing the Merkle Tree and Deriving Proof Data To prove membership of our `commitment` in the ZK-Mixer's Merkle tree, we need to reconstruct this tree off-chain using the provided `leaves` and then generate a Merkle proof for our specific commitment. This is where the `merkleTree.js` helper comes into play. ```typescript // Inside generateProof function // merkleTree is an async function imported from ./merkleTree.js const tree = await merkleTree(leaves); const treeProofData = tree.proof(commitment.toString()); // Get proof for our specific commitment const root = treeProofData.root; // Path elements need to be strings for Noir circuit input const merkle_proof_paths = treeProofData.pathElements.map((el: any) => el.toString()); // Path indices (is_even) need to be booleans const is_even_paths = treeProofData.pathIndices.map((i: any) => i % 2 === 0); ``` * `merkleTree(leaves)`: This function (from `./merkleTree.js`) takes the array of `leaves` (which should be strings) and constructs the Merkle tree. Internally, it likely uses `Barretenberg`'s `poseidon2Hash` for hashing nodes. * **Critical Note on `ZERO_VALUES`**: The `merkleTree.js` implementation must use an array of `ZERO_VALUES` (precomputed hashes for empty subtrees at different levels) that are *identical* to those defined in your on-chain `Mixer.sol` contract and any corresponding `merkle_tree.nr` Noir library. Any discrepancy will cause proof verification to fail. * `tree.proof(commitment.toString())`: This method on the constructed tree instance retrieves the Merkle proof for our `commitment`. The `commitment` (an `Fr` object) is converted to a string before being passed. The returned `treeProofData` object typically contains: * `root`: The root of the Merkle tree (as an `Fr` object). * `pathElements`: An array of sibling nodes (as `Fr` objects) forming the path from the leaf to the root. * `pathIndices`: An array of numbers indicating the direction (left/right) at each level of the path. * `leaf`: The leaf itself (our `commitment`). * The `merkle_proof_paths` are converted to an array of strings (`el.toString()`) because the Noir circuit expects field elements as strings in the input JSON. * The `is_even_paths` are converted to an array of booleans. `pathIndices` typically indicate if a path element is a left (0) or right (1) sibling. The circuit often expects this as `true` if the node is on the left (index is even) and `false` if on the right (index is odd), or a similar convention. #### 5. Initializing Noir and the Proving Backend With the compiled circuit loaded, we can initialize the `Noir` object and the chosen proving backend (`UltraHonkBackend`). ```typescript // Inside generateProof function const noir = new Noir(circuit); // Pass the loaded circuit definition const honk = new UltraHonkBackend(circuit.bytecode, { threads: 1 }); // Pass bytecode and options ``` * `new Noir(circuit)`: Creates an instance of the Noir interface, using the parsed circuit definition (ACIR and ABI). * `new UltraHonkBackend(circuit.bytecode, { threads: 1 })`: Initializes the Honk backend. It requires the `circuit.bytecode` (ACIR) and can take options, such as the number of `threads` to use for proving. #### 6. Preparing Inputs for the Noir Circuit The inputs for the Noir circuit must be structured precisely as defined in the `main.nr` file's `main` function. ```typescript // Inside generateProof function const input = { root: root.toString(), nullifier_hash: nullifierHash.toString(), recipient: recipient, // recipient is already a string (address) nullifier: nullifier.toString(), secret: secret.toString(), merkle_proof: merkle_proof_paths, // Already an array of strings is_even: is_even_paths, // Already an array of booleans }; ``` This `input` object maps directly to the arguments of your Noir circuit's `main` function. For example: ```noir // Example structure from main.nr fn main( // Public Inputs root: pub Field, nullifier_hash: pub Field, recipient: pub Field, // Note: In Noir, an address might be represented as a Field // Private Inputs nullifier: Field, secret: Field, merkle_proof: [Field; N], // N is tree depth, e.g., 20 is_even: [bool; N] ) { /* ... circuit logic ... */ } ``` * `root`, `nullifier_hash`, `nullifier`, `secret`: These `Fr` values are converted to strings using `.toString()` for the Noir input. * `recipient`: This is typically an Ethereum address string. If your Noir circuit expects it as a `Field`, ensure proper conversion or representation. * `merkle_proof`: The array of path element strings. * `is_even`: The array of booleans for path indices. #### 7. Generating the Witness and Zero-Knowledge Proof Now we execute the circuit with the prepared inputs to generate a witness, and then use the witness with the backend to generate the actual proof. ```typescript // Inside generateProof function // Suppress Noir's console output during proof generation for cleaner CLI output const originalLog = console.log; console.log = () => {}; const { witness } = await noir.execute(input); // Or noir.generateWitness(input) const proof = await honk.generateProof(witness, { keccak: true }); console.log = originalLog; // Restore console.log ``` * `console.log` suppression: The Noir library can be verbose during execution. To get a clean output (just the proof string), `console.log` is temporarily overridden. * `noir.execute(input)`: Executes the circuit with the provided `input` and returns an object containing the `witness` (the satisfying assignment of all wires in the circuit). Alternatively, `noir.generateWitness(input)` can be used if only the witness is needed separately. * `honk.generateProof(witness, { keccak: true })`: Generates the zero-knowledge proof using the Honk backend and the generated `witness`. * `keccak: true`: This option is crucial. It ensures that any hashing done by the proving system internally for Fiat-Shamir transformations uses Keccak256, which aligns with Ethereum's hashing. This is necessary if the proof is to be verified on an Ethereum smart contract. #### 8. ABI Encoding the Proof for Ethereum Consumption The raw proof generated by the backend needs to be ABI-encoded to be passed as an argument to a smart contract function on Ethereum. ```typescript // Inside generateProof function const result = ethers.AbiCoder.defaultAbiCoder().encode(["bytes"], [proof]); return result; ``` * `ethers.AbiCoder.defaultAbiCoder().encode(["bytes"], [proof])`: Encodes the `proof` (which is typically a `Uint8Array` or similar byte array) as a `bytes` type according to Ethereum's ABI specification. This `result` string is what will be sent to the smart contract. The `generateProof` function then returns this ABI-encoded proof string. ### Executing the Script from the Command Line To make the script directly runnable from the CLI (e.g., `node generateProof.ts arg1 arg2 ...`), we use an Immediately Invoked Function Expression (IIFE) at the end of the file. This calls `generateProof` and handles its output or any errors. ```typescript // generateProof.ts (at the end of the file) (async () => { try { const result = await generateProof(); process.stdout.write(result); // Output result to standard out process.exit(0); // Exit with success code } catch (error) { console.error("Error generating proof:", error); // More descriptive error process.exit(1); // Exit with error code } })(); ``` * The `async` IIFE `(async () => { ... })();` allows us to use `await` inside it. * `generateProof()` is called, and its result (the ABI-encoded proof string) is awaited. * `process.stdout.write(result)`: Writes the proof string directly to the standard output. This is how Foundry tests, for example, can capture the proof when calling the script via `vm.ffi`. * `process.exit(0)`: Exits the Node.js process with a success code (0). * The `catch` block handles any errors during proof generation, prints an error message to `stderr` (using `console.error` is good practice), and exits with an error code (1). ### Key Considerations and Best Practices * **`ZERO_VALUES` Consistency**: This cannot be overstated. The `ZERO_VALUES` (hashes of empty Merkle tree nodes) used in your `merkleTree.js` off-chain helper *must* exactly match those used in your on-chain `Mixer.sol` contract and any `merkle_tree.nr` Noir library. Mismatches will lead to valid proofs failing verification on-chain. * **`merkleTree.js` Complexity**: The `merkleTree.js` helper can be complex. It's often adapted from existing robust implementations like those found in Tornado Cash or community projects. Ensure it correctly implements Poseidon hashing for tree construction and provides accurate proof data (path elements and indices). * **`keccak: true` for Ethereum**: When generating the proof with `honk.generateProof`, remember the `{ keccak: true }` option if the proof is intended for an Ethereum smart contract. * **Type Conversions**: Pay close attention to type conversions: * CLI inputs (strings) to `Fr` objects (`Fr.fromString()`). * `Fr` objects to strings (`.toString()`) when preparing the `input` object for the Noir circuit or when interacting with some `merkleTree.js` methods. * **CLI Input Order**: The order of CLI arguments passed to `generateProof.ts` must match the script's expectations (nullifier, secret, recipient, leaves). This is particularly important when calling this script from another system, like a Foundry test using `vm.ffi`. * **Path to Compiled Circuit**: Double-check the path to your `circuits.json` file. Using `path.resolve(__dirname, ...)` helps create robust paths. * **Return Value**: Ensure your `generateProof` function correctly returns the ABI-encoded proof string. By following these steps and considerations, you can successfully create a `generateProof.ts` script to generate ZK proofs for your ZK-Mixer, enabling private transactions within your Web3 application.
An essential walkthrough of Crafting a `generateProof.ts` Script for Your ZK-Mixer - Learn to develop a TypeScript script for generating ZK proofs in a ZK-Mixer, from Noir circuit interaction to CLI input handling. This lesson covers key cryptographic operations with Barretenberg, Merkle tree data preparation, and ABI-encoding proofs for Ethereum.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Noir syntax
Create a witness, a proof, and Solidity verifier contracts
Use the Poseidon commitment scheme
Create ZK circuits and build a full ZK protocol
ZK Merkle trees and hashing in Noir
Verify signatures without revealing the signer
Build the backend for a full-stack ZK application with noir.js and bb.js
How to create proofs and verify them in a front-end
Last updated on June 12, 2025
Duration: 6min
Duration: 1h 11min
Duration: 2h 12min
Duration: 3h 19min
Course Overview
About the course
Noir syntax
Create a witness, a proof, and Solidity verifier contracts
Use the Poseidon commitment scheme
Create ZK circuits and build a full ZK protocol
ZK Merkle trees and hashing in Noir
Verify signatures without revealing the signer
Build the backend for a full-stack ZK application with noir.js and bb.js
How to create proofs and verify them in a front-end
Last updated on June 12, 2025