5/5
## Recapping Our Zero-Knowledge Proof Application Build Welcome to this detailed walkthrough where we revisit the key stages of constructing our Zero-Knowledge Proof (ZKP) application, the "Panagram" word guessing game. This lesson recaps the entire development lifecycle, from designing the ZK circuit in Noir to integrating and testing the full system using Solidity smart contracts and Foundry. We'll cover the creation of the ZK circuit, its compilation, the generation of a verifier smart contract, the development of the main game logic smart contract that utilizes this verifier, and finally, how we've tested this entire flow, including an off-chain proof generation script invoked via Foundry's Foreign Function Interface (FFI). ## Understanding Our ZK Circuit in Noir (`circuits/src/main.nr`) At the heart of our ZKP application lies the Noir circuit, defined in `circuits/src/main.nr`. This circuit is responsible for defining the private computation we want to prove. **Purpose and Inputs:** The primary goal of this circuit is to allow a user to prove they know a word (guess) that hashes to a specific value, without revealing the word itself. It takes three inputs: * `guess_hash`: A private input representing the Keccak256 hash of the user's guessed word. Being private, this value is known only to the prover. * `answer_hash`: A public input representing the Keccak256 hash of the correct answer. This hash is known by the verifier (our smart contract). * `address`: A public input representing the Ethereum address of the user submitting the proof. **Core Logic:** The circuit performs two crucial assertions: 1. `assert(guess_hash == answer_hash);`: This is the core ZK statement. It verifies that the hash of the user's private guess matches the publicly known correct answer hash. The magic of ZKPs ensures this check happens without the `guess_hash` (or the underlying guess) ever being revealed on-chain. 2. `let addr_pow = address.pow_32(exponent: 2); assert(addr_pow == address.pow_32(exponent: 2));`: This seemingly redundant calculation serves a vital security purpose. By including the public `address` input in a computation within the circuit, we tie the generated proof to a specific user address. This ensures that the proof is valid only if the `msg.sender` in the subsequent smart contract transaction matches this `address` public input, preventing one user from submitting a valid proof on behalf of another. **Compilation:** To make this circuit usable, we compile it using a command like `nargo compile`. This process outputs two critical artifacts: * The compiled circuit definition (e.g., `target/zk_panagram.json`), which contains the circuit's bytecode. * The verification key (e.g., `target/vk`), essential for the verifier smart contract to check proofs. ## The Auto-Generated Verifier Smart Contract (`contracts/src/Verifier.sol`) Once our Noir circuit is compiled and we have a verification key, Noir provides a tool to generate a Solidity verifier contract. This is typically done with a command like `nargo codegen-verifier`. **Purpose and Functionality:** The generated `contracts/src/Verifier.sol` (referred to as a "HonkVerifier" in this context, specific to the proving system used) has one primary function: `verify(bytes memory proof, bytes32[] memory public_inputs)`. This function takes the cryptographic proof generated by the prover and an array of public inputs. Internally, it uses the verification key (embedded within the contract during its generation) to determine if the provided proof is valid for the given public inputs and the circuit logic. If the proof is valid, it returns `true`; otherwise, it returns `false` or reverts. ## Implementing Game Logic with the Panagram Smart Contract (`contracts/src/Panagram.sol`) The `contracts/src/Panagram.sol` smart contract orchestrates the game's logic, manages game rounds, stores correct answers (as hashes), and handles the minting of NFTs to reward players. **Constructor:** The `Panagram` contract is initialized with the address of the deployed `Verifier.sol` contract. This allows `Panagram` to delegate the proof verification task. ```solidity // contract Panagram is ERC1155 { // Assuming ERC1155 for NFTs // IVerifier s_verifier; // bytes32 s_answer; // Stores the hash of the correct answer for the current round // address s_currentRoundWinner; // uint256 s_currentRound; // // ... other state variables and events ... // // Actual constructor shown in video // constructor(IVerifier _verifier) ERC1155("ipfs://...") { // ERC1155 URI for token metadata // s_verifier = _verifier; // } // // ... // } ``` **The `makeGuess` Function:** This is the primary function users interact with to submit their proofs. ```solidity // function makeGuess(bytes memory proof) external returns (bool) { // // ... other checks like round status, etc. ... // // Prepare public inputs *inside the smart contract* for security // bytes32[] memory publicInputs = new bytes32[](2); // publicInputs[0] = s_answer; // The contract's stored answer hash // publicInputs[1] = bytes32(uint256(uint160(msg.sender))); // The address of the caller // // Call the verifier contract // bool proofResult = s_verifier.verify(proof, publicInputs); // if (!proofResult) { // revert Panagram_InvalidProof(); // Custom error // } // // If proof is valid, mint NFT // if (s_currentRoundWinner == address(0)) { // s_currentRoundWinner = msg.sender; // _mint(msg.sender, 0, 1, ""); // Winner NFT (ID 0) // // emit Panagram_WinnerCrowned(msg.sender, s_currentRound); // } else { // _mint(msg.sender, 1, 1, ""); // Runner-up NFT (ID 1) // // emit Panagram_RunnerUpCrowned(msg.sender, s_currentRound); // } // // ... further logic like starting a new round ... // return proofResult; // } ``` A critical aspect here is how `publicInputs` are constructed. The `Panagram` contract uses its own state variable `s_answer` (the trusted hash of the correct answer for the current round) and the transaction's `msg.sender` as the public inputs for verification. This prevents a malicious user from providing arbitrary public inputs to try and validate a fraudulent proof. If `s_verifier.verify` returns `true`, the contract proceeds to mint an ERC1155 NFT to the `msg.sender`. The first correct guesser in a round might receive an NFT with ID 0 (winner), while subsequent correct guessers receive an NFT with ID 1 (runner-up). ## Testing Our ZKP Implementation with Foundry (`contracts/test/Panagram.t.sol`) Thorough testing is paramount. We use Foundry to write Solidity-based tests for our `Panagram.sol` contract. **The `FIELD_MODULUS` Challenge:** A key challenge arises when preparing inputs for the ZK circuit, both in tests and in the off-chain proof generation script. Noir circuits operate over a prime finite field (BN254 curve in this case). This means all numerical inputs to the circuit must be less than a specific prime number, the `FIELD_MODULUS`. When we hash a word like "triangles" using `keccak256`, the resulting `bytes32` value, interpreted as an integer, might be larger than this modulus. To ensure compatibility, we must take the hash modulo the `FIELD_MODULUS` before passing it to the circuit (or its representation in the proof generation script). ```solidity // // In Panagram.t.sol // uint256 constant FIELD_MODULUS = 2188824287183927522224640574527275088548364400416034343698204186575808495617; // bytes32 ANSWER_STRING_HASHED = keccak256(abi.encodePacked("triangles")); // bytes32 ANSWER_FOR_CIRCUIT = bytes32(uint256(ANSWER_STRING_HASHED) % FIELD_MODULUS); ``` **The `_getProof` Helper Function and FFI:** Generating a ZK proof is computationally intensive and typically done off-chain. To integrate this into our Foundry tests, we use `vm.ffi()` (Foreign Function Interface). This allows our Solidity test code to execute an external script (in our case, `js-scripts/generateProof.ts`) and capture its output. The `_getProof` helper function in `Panagram.t.sol` orchestrates this: ```solidity // // function _getProof(bytes32 guessHashForCircuit, bytes32 correctAnswerHashForCircuit, address sender) internal returns (bytes memory _proof) { // // NUM_ARGS would be 6: npx, tsx, script_path, guess_hash, answer_hash, sender_address // string[] memory inputs = new string[](NUM_ARGS); // inputs[0] = "npx"; // inputs[1] = "tsx"; // Or node, depending on how generateProof.ts is executed // inputs[2] = "js-scripts/generateProof.ts"; // inputs[3] = vm.toString(guessHashForCircuit); // inputs[4] = vm.toString(correctAnswerHashForCircuit); // inputs[5] = vm.toString(sender); // // vm.ffi executes the command and captures its stdout // bytes memory encodedProof = vm.ffi(inputs); // // The script outputs ABI-encoded bytes, so we decode it // _proof = abi.decode(encodedProof, (bytes)); // // For debugging: console.logBytes(_proof); // } ``` This function assembles the command-line arguments for the proof generation script (including the pre-processed guess hash, answer hash, and sender address), executes it via `vm.ffi`, and decodes the returned ABI-encoded proof. ## Generating Proofs Off-Chain with Our TypeScript Script (`js-scripts/generateProof.ts`) The `js-scripts/generateProof.ts` script is responsible for the heavy lifting of generating the ZK proof off-chain. It's called by Foundry's `vm.ffi()` during testing and would be run by a user's client application in a live scenario. **Key Steps in `generateProof.ts`:** 1. **Read CLI Arguments:** The script receives the `guess_hash`, `answer_hash`, and `address` as command-line arguments. ```typescript // const inputsArray = process.argv.slice(2); // [guess_hash_str, answer_hash_str, address_str] ``` 2. **Load Circuit Definition:** It reads the compiled circuit JSON file. ```typescript // import * as fs from 'fs'; // import { Noir } from '@noir-lang/noir_js'; // import { UltraHonkBackend } from '@noir-lang/backend_barretenberg'; // Or similar backend // const circuitPath = 'target/zk_panagram.json'; // Path to compiled circuit // const circuit = JSON.parse(fs.readFileSync(circuitPath, 'utf8')); ``` 3. **Initialize Noir and Backend:** Instances of the Noir execution environment and the specific proving backend (e.g., `UltraHonkBackend`) are created. ```typescript // const noir = new Noir(circuit); // const backend = new UltraHonkBackend(circuit.bytecode, { threads: 1 }); // Backend setup ``` 4. **Prepare Inputs for Circuit:** The input arguments are formatted into an object matching the circuit's expected input structure. ```typescript // const inputs = { // // Private Inputs // guess_hash: inputsArray[0], // Already pre-processed (mod FIELD_MODULUS) if necessary // // Public Inputs // answer_hash: inputsArray[1], // Already pre-processed // address: inputsArray[2], // }; ``` 5. **Execute Circuit (Generate Witness):** The `noir.execute(inputs)` method runs the circuit with the provided inputs to generate a "witness" – a trace of all intermediate values in the computation. ```typescript // const { witness } = await noir.execute(inputs); ``` 6. **Generate Proof:** The backend's `generateProof` method takes the witness and produces the actual cryptographic proof. ```typescript // // Suppress console logs from the backend during proof generation if it interferes with FFI // const originalLog = console.log; // console.log = () => {}; // Temporarily disable console.log // const { proof } = await backend.generateProof(witness, { keccak: true }); // {keccak: true} option might be important for compatibility // console.log = originalLog; // Restore console.log ``` The `{ keccak: true }` option is significant; it likely ensures that hashing operations within the proving system are compatible with how Keccak is handled by the verifier contract. 7. **ABI Encode Proof:** The raw proof (often `Uint8Array`) is ABI-encoded into a `bytes` string, as this is the format `vm.ffi` expects for clean output capture. ```typescript // import { ethers } from 'ethers'; // For AbiCoder // const proofEncoded = ethers.AbiCoder.defaultAbiCoder.encode(["bytes"], [proof]); ``` 8. **Output Proof:** The script writes the ABI-encoded proof to `process.stdout`. `vm.ffi` captures this output. ```typescript // async function main() { // // ... all the above steps ... // return proofEncoded; // } // main() // .then((encodedProof) => { // process.stdout.write(encodedProof); // process.exit(0); // }) // .catch((error) => { // console.error(error); // process.exit(1); // }); ``` Suppressing `console.log` during `backend.generateProof` is crucial if the underlying proof generation library itself logs to stdout. `vm.ffi` captures *all* stdout, so any extraneous logs would corrupt the proof data received by the Solidity test. ## Core Zero-Knowledge Proof Concepts in Practice This application demonstrates several fundamental ZKP concepts: * **Private vs. Public Inputs:** * **Private Inputs** (like `guess_hash` in the circuit): These are known only to the prover. The ZKP attests that the prover knows these inputs and that they satisfy the circuit's logic, without revealing the inputs themselves. * **Public Inputs** (like `answer_hash` and `address` in the circuit): These are known to both the prover and the verifier. They serve as anchors, binding the proof to a specific, publicly known context. * **Security Criticality:** It is paramount that the verifier smart contract (`Panagram.sol`) uses its own trusted sources for public inputs when calling the `verify` function. For instance, it uses `s_answer` (its own state variable) and `msg.sender` (from the transaction context), not values supplied by the user as part of the public inputs argument to `makeGuess` (other than the proof itself). The ZK proof ensures the prover used *their claimed* public inputs correctly according to the circuit, and the smart contract then matches these against its *own trusted* public inputs. * **Off-Chain Proof Generation, On-Chain Verification:** * Generating ZKPs is computationally intensive (can take seconds or more). Performing this on-chain would be prohibitively expensive in gas costs and block execution time. * Verifying ZKPs, however, is relatively lightweight and efficient, making it perfectly suitable for execution within a smart contract's gas limits. This "prove off-chain, verify on-chain" model is standard for ZKP applications. ## Critical Considerations for ZKP Development Building ZKP applications requires attention to specific details: * **Hashing and Field Modulus Arithmetic:** As discussed, ensuring that data like hashed answers is represented compatibly with the ZK circuit's finite field arithmetic (e.g., by applying `% FIELD_MODULUS`) is essential for correctness. * **Foreign Function Interface (FFI) for Testing:** Foundry's `vm.ffi` is an invaluable tool for integrating off-chain processes like proof generation into your Solidity test suites, enabling comprehensive end-to-end testing. * **Immutability and Trust in Public Inputs for Verification:** The smart contract must establish the public inputs it uses for verification from trusted, immutable sources (its own state, `msg.sender`, `block.timestamp`, etc.). This prevents manipulation by users submitting proofs. * **Proof System Compatibility:** The options used during proof generation (e.g., `{ keccak: true }` in `backend.generateProof`) must align with how the verifier contract was generated and expects to interpret proofs. Mismatches can lead to valid proofs failing verification. ## The Panagram Game: A ZKP Use Case Example Let's quickly trace the user journey in our Panagram game: 1. The `Panagram.sol` smart contract has a `s_answer` state variable, which is the hash of the current round's secret word. 2. Alice, the user (prover), believes she knows the secret word. She calculates its hash (`guess_hash`). 3. Alice runs the off-chain proof generation script. This script takes her private `guess_hash`, the public `s_answer` (which she gets from the contract), and her Ethereum `address` as inputs. It generates a cryptographic proof. 4. Alice submits this proof by calling the `makeGuess(proof)` function on the `Panagram.sol` contract. 5. The `Panagram.sol` contract prepares its own set of public inputs: its internally stored `s_answer` and Alice's `msg.sender`. 6. It then calls `s_verifier.verify(proof, publicInputs)`. 7. If the verifier confirms the proof is valid, Alice is rewarded with an NFT, proving she knew the word without ever revealing it on-chain. ## Developer Insights and Learning Aids As you continue your journey with ZKPs, keep these points in mind: * **JavaScript/TypeScript Tooling:** The JavaScript or TypeScript code for proof generation can sometimes appear verbose or complex, especially when dealing with low-level cryptographic libraries. * **Console Log Management with FFI:** Remember to suppress or manage `console.log` output from external scripts called via `vm.ffi` to ensure clean data capture. * **Take Breaks:** ZKP concepts can be dense. Allow yourself time to absorb and process the information. * **Leverage AI for Learning:** Don't hesitate to use AI tools like ChatGPT or Claude. Ask them to explain concepts ("Explain public vs. private inputs in ZKPs"), code snippets ("Explain this Noir circuit logic like I'm 5"), or error messages. * **Community Support:** If AI tools aren't sufficient, tap into community resources. GitHub discussions for the relevant libraries or courses, and Discord servers dedicated to ZK development, are excellent places to ask questions and learn from others. This recap has connected the various components of our ZKP application, from the Noir circuit logic to the Solidity smart contracts and the TypeScript tooling that binds them. By understanding these interactions, you're well-equipped to build and debug complex ZKP systems.
A consolidating overview to Recapping Our Zero-Knowledge Proof Application Build - This lesson brings together all components of the "Panagram" ZKP game, detailing the Noir circuit, Solidity contracts (Verifier & game logic), and testing with Foundry FFI for off-chain proof generation. It reinforces key ZKP concepts and practical development steps for building a complete ZK application.
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