5/5
## Crafting Your First ZK-Mixer Test: Verifying Deposits This lesson walks you through creating the initial test for a ZK-Mixer smart contract using the Foundry testing framework. Our primary objective is to thoroughly test the `deposit` functionality, ensuring it correctly processes Ether, emits the designated event, and establishes a foundation for subsequent withdrawal tests. We'll be working primarily within the `MixerTest.sol` file, typically located at `contracts/test/Mixer.t.sol`. ## Testing the `makeDeposit` Function We begin by implementing the `testMakeDeposit` function. This test will simulate a user depositing funds into the mixer. A crucial aspect of the `Mixer` contract's `deposit` function is that it's `payable`, meaning it's designed to receive Ether. To send Ether along with a function call in a Foundry test, we employ a specific syntax. Consider the `deposit` function call within `testMakeDeposit()`: ```solidity // function testMakeDeposit() public { // ... (obtain _commitment) ... // Original attempt (does not send Ether): // mixer.deposit(_commitment); // Updated call to send Ether: mixer.deposit{value: mixer.DENOMINATION()}(_commitment); // } ``` In the updated line, `mixer.deposit{value: mixer.DENOMINATION()}(_commitment);`, we use `{value: mixer.DENOMINATION()}` right before the arguments. `mixer.DENOMINATION()` refers to a public constant defined within the `Mixer` contract (e.g., `uint256 public constant DENOMINATION = 1 ether;`). Solidity automatically generates a getter function for public state variables, allowing us to access `DENOMINATION` as `mixer.DENOMINATION()`. This ensures that our test sends the precise amount of Ether required for a valid deposit. ## Verifying Event Emissions with `vm.expectEmit` A successful deposit operation in our `Mixer` contract is expected to emit a `Deposit` event. This event is vital for off-chain services to track deposits and reconstruct the state of the mixer. Foundry provides a powerful cheatcode, `vm.expectEmit`, to verify that specific events are emitted with the correct parameters. The `Deposit` event in the `Mixer.sol` contract is defined as: ```solidity event Deposit(bytes32 indexed commitment, uint32 insertedIndex, uint256 timestamp); ``` Notice that only the `commitment` parameter is marked as `indexed`. Indexed event parameters are stored as "topics" in the transaction logs, allowing for efficient searching and filtering. Non-indexed parameters are stored in the data portion of the log. The `vm.expectEmit` cheatcode takes four boolean arguments that control which parts of the event are checked: 1. `checkTopic1`: Set to `true` to check the first indexed parameter (topic), `false` to ignore. 2. `checkTopic2`: Set to `true` to check the second indexed parameter (topic), `false` to ignore. 3. `checkTopic3`: Set to `true` to check the third indexed parameter (topic), `false` to ignore. 4. `checkData`: Set to `true` to check all non-indexed parameters (the data part), `false` to ignore. Here's how we use `vm.expectEmit` in our `testMakeDeposit` function: ```solidity // function testMakeDeposit() public { bytes32 _commitment; bytes32 _nullifier; // Will be used later for withdrawals bytes32 _secret; // Will be used later for withdrawals (_commitment, _nullifier, _secret) = _getCommitment(); // Helper function call // For debugging purposes, you might log the commitment: // console.log("Commitment: "); // console.logBytes32(_commitment); // Set up expectation for the Deposit event vm.expectEmit(true, false, false, true); emit mixer.Deposit(_commitment, 0, block.timestamp); // Define the expected event signature and values // Actual contract call that should trigger the event mixer.deposit{value: mixer.DENOMINATION()}(_commitment); // } ``` Let's break down the `vm.expectEmit` usage: * The line `emit mixer.Deposit(_commitment, 0, block.timestamp);` immediately *following* `vm.expectEmit(...)` does not actually emit an event itself during the test. Instead, it declares the exact signature and values that the test *expects* the *next* contract call (`mixer.deposit` in this case) to emit. * `_commitment`: This is the expected value for the first parameter of the `Deposit` event, which is `indexed`. * `0`: This is the expected value for the `insertedIndex`. We assume this is the first deposit in this specific test's context, so its index in the Merkle tree would be 0. * `block.timestamp`: This is the expected value for the `timestamp` parameter. We use `block.timestamp` as it will match the timestamp recorded by the contract during the `deposit` execution. * The arguments `(true, false, false, true)` to `vm.expectEmit` specify: * `true`: Check the first topic, which corresponds to our `indexed commitment`. * `false`: Do not check for a second indexed topic (since `insertedIndex` is not indexed). * `false`: Do not check for a third indexed topic. * `true`: Check the data part of the event log, which will contain the non-indexed parameters `insertedIndex` and `timestamp`. Running the tests with a verbose flag, like `forge test -vvv`, will confirm if this test passes, indicating that the `deposit` function correctly emitted the `Deposit` event with the specified parameters. ## Preparing for Withdrawal Tests: Propagating Essential Data With the deposit test in place, we begin scaffolding the `testMakeWithdrawal` function. A withdrawal operation in a ZK-Mixer requires a Zero-Knowledge proof. Generating this proof, in turn, requires the `nullifier` and `secret` that were used to generate the original `commitment` for the deposit. These values (`nullifier` and `secret`) are typically generated by an off-chain script. In our test setup, we simulate this using Foundry's `vm.ffi` (Foreign Function Interface) cheatcode, which calls a JavaScript helper script. **Modifying the `_getCommitment` Helper Function** Our existing `_getCommitment` helper function, which calls the JavaScript script, needs to be updated to return not just the `commitment`, but also the `nullifier` and `secret`. Original `_getCommitment` (simplified): ```solidity // function _getCommitment() public returns (bytes32 _commitment) { // // ... (vm.ffi call to script) ... // bytes memory result = vm.ffi(inputs); // _commitment = abi.decode(result, (bytes32)); // return _commitment; // } ``` Updated `_getCommitment` to return three values: ```solidity function _getCommitment() public returns (bytes32 _commitment, bytes32 _nullifier, bytes32 _secret) { string[] memory inputs = new string[](3); inputs[0] = "npx"; inputs[1] = "tsx"; inputs[2] = "js-scripts/generateCommitment.ts"; // Path to your script bytes memory result = vm.ffi(inputs); // Decode all three values returned by the script (_commitment, _nullifier, _secret) = abi.decode(result, (bytes32, bytes32, bytes32)); return (_commitment, _nullifier, _secret); } ``` The key change here is updating the return type of the function and modifying the `abi.decode` call to expect a tuple of three `bytes32` values, corresponding to the commitment, nullifier, and secret. **Updating Calls to `_getCommitment`** Consequently, any function calling `_getCommitment`, including our `testMakeDeposit` and the new `testMakeWithdrawal`, must be updated to receive these three values: ```solidity // In testMakeDeposit() and testMakeWithdrawal() // Original: // bytes32 _commitment = _getCommitment(); // Updated: (bytes32 _commitment, bytes32 _nullifier, bytes32 _secret) = _getCommitment(); ``` ## Synchronizing Off-Chain Script for Data Generation The JavaScript script invoked by `vm.ffi` (e.g., `js-scripts/generateCommitment.ts`) must also be updated to ABI-encode and return the `nullifier` and `secret` alongside the `commitment`. Original JavaScript encoding (simplified, using `ethers.js`): ```typescript // const result = ethers.AbiCoder.defaultAbiCoder().encode( // ["bytes32"], // Only commitment // [commitment.toBuffer()] // ); // process.stdout.write(result); ``` Updated JavaScript encoding: ```typescript // Assuming 'commitment', 'nullifier', and 'secret' are available as objects // with a .toBuffer() method (e.g., from a library like circomlibjs) const result = ethers.AbiCoder.defaultAbiCoder().encode( ["bytes32", "bytes32", "bytes32"], // Types for commitment, nullifier, secret [commitment.toBuffer(), nullifier.toBuffer(), secret.toBuffer()] // Values ); process.stdout.write(result); // Output the ABI-encoded data // return result; // Or if it's a function, return it. ``` The order and types in `ethers.AbiCoder.defaultAbiCoder().encode` in the JavaScript script must precisely match the order and types expected by `abi.decode` in the Solidity contract. ## Laying the Groundwork for `_getProof` in Withdrawal Tests Now, let's start the `testMakeWithdrawal` function. It will initially perform a deposit to have a commitment to withdraw. Then, it will need to generate a ZK-proof. We'll add a placeholder for a `_getProof` helper function. ```solidity function testMakeWithdrawal() public { // 1. Make a deposit to have something to withdraw (bytes32 _commitment, bytes32 _nullifier, bytes32 _secret) = _getCommitment(); vm.expectEmit(true, false, false, true); emit mixer.Deposit(_commitment, 0, block.timestamp); mixer.deposit{value: mixer.DENOMINATION()}(_commitment); // 2. Prepare inputs for proof generation // address recipient = msg.sender; // Example recipient // For now, these are placeholders and commented out: // bytes32[] memory leaves; // Array of all commitments in the mixer // bytes memory _proof = _getProof(_nullifier, _secret, recipient, leaves); // 3. Make the withdrawal (to be implemented) // mixer.withdraw(_proof, _root, _nullifierHash, _recipient, _fee, _relayer); } ``` The call to `_getProof` is commented out because we haven't defined the function or all its necessary arguments yet, particularly `recipient` and `leaves`. **The `leaves` Parameter for Proof Generation** The `leaves` parameter, which would be an array of `bytes32`, is crucial for generating the Merkle proof component of the ZK-SNARK. It represents all the commitments (leaves of the Merkle tree) that have been deposited into the mixer up to the point of withdrawal. Typically, an off-chain script or service would: 1. Listen to `Deposit` events emitted by the `Mixer` contract. 2. Collect all `commitment` values from these events. These are the `leaves`. 3. Reconstruct the current state of the Merkle tree using these leaves. 4. Generate a Merkle path (or Merkle proof) for the specific `commitment` being withdrawn. 5. Use this Merkle path, along with the `secret`, `nullifier`, and other public inputs (like the Merkle `root` and `recipient`), to generate the ZK-SNARK proof using a proving system (e.g., Groth16, PLONK). The Merkle `root` derived from this off-chain constructed tree is a public input to the ZK-SNARK verifier on-chain. ## Key Concepts Recap This lesson has touched upon several important concepts in smart contract development and testing, especially within the ZK context: * **Foundry Cheatcodes:** `vm.ffi` for interacting with external scripts (essential for off-chain computations like proof generation in tests) and `vm.expectEmit` for robust event testing. * **Payable Functions in Tests:** Correctly sending Ether to `payable` functions using the `{value: ...}` syntax. * **Event Testing:** Verifying event emissions is critical for ensuring contract interoperability and allowing off-chain systems to monitor contract state. * **Solidity Event Indexing:** Understanding how `indexed` event parameters are treated as topics, enabling efficient log filtering, and how `vm.expectEmit` can specifically target these topics or the data payload. * **Data Flow for ZK Proofs:** Recognizing that private inputs like `nullifier` and `secret` (generated during commitment creation) are essential for later proof generation during withdrawal. The current state of the Merkle tree (`leaves`) is also a necessary input. * **Off-Chain Computation:** Many operations in ZK systems, such as complex cryptographic proof generation or Merkle tree management, are computationally intensive and are therefore handled off-chain (e.g., by user clients or specialized services). ## Important Considerations and Best Practices * **Automatic Getters:** Remember that `public` state variables in Solidity automatically have getter functions generated, simplifying access (e.g., `mixer.DENOMINATION()`). * **ABI Encoding/Decoding:** When using `vm.ffi` to pass multiple values between Solidity and an external script, ensure `abi.decode` in Solidity matches the types and order of `abi.encode` in the script. * **Secure Data Management:** In a real-world application, sensitive data like the `nullifier` and `secret` must be managed securely by the user (e.g., stored locally and encrypted). The approach of logging them or passing them directly through test functions is for demonstration purposes only. * **User-Facing Interfaces:** The process of generating commitments, storing secrets/nullifiers, and generating proofs for withdrawal would typically be handled by a user-facing interface (e.g., a command-line tool or a web frontend), not directly within Solidity test helper functions for end-users. By following these steps, you've successfully tested the deposit functionality of the ZK-Mixer and laid crucial groundwork for testing the more complex withdrawal mechanism, understanding the interplay between on-chain contracts and off-chain computations inherent in ZK systems.
A crucial first step into Crafting Your First ZK-Mixer Test: Verifying Deposits - Discover how to write the initial Foundry test for a ZK-Mixer's `deposit` function, covering Ether transactions in tests and event verification with `vm.expectEmit`. This lesson also prepares the groundwork for withdrawal testing by modifying data handling in helper functions and associated scripts.
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