5/5
## Implementing and Testing Private Withdrawals in a zk-SNARK Mixer with Foundry This lesson details the final steps in developing and testing the withdrawal functionality of a zk-SNARKs based mixer smart contract using Foundry. Our objective is to ensure that a user can deposit an amount, generate a zero-knowledge proof of this deposit, and subsequently use this proof to withdraw the same amount to a new, specified recipient address, all while preserving the privacy of the transaction link between the deposit and withdrawal. We'll walk through the necessary modifications to our Solidity test contract and a supporting JavaScript proof generation script, culminating in a successful test run that validates the end-to-end withdrawal process. ### Refining Proof Generation: Incorporating Public Inputs A critical aspect of zk-SNARK verification is the use of public inputs. These are pieces of data that, while public, are essential for the verifier contract to confirm the validity of a proof. For our mixer, these include the Merkle root, the nullifier hash, and the recipient address. **1. Updating `_getProof` in Solidity (`Mixer.t.sol`)** Our helper function `_getProof` in the Foundry test file (`Mixer.t.sol`) is responsible for invoking an external JavaScript script via Foundry's Foreign Function Interface (FFI) to generate the zk-SNARK proof. Previously, it only returned the proof itself. We now need it to return the public inputs as well. The FFI call returns an ABI-encoded `bytes` string containing both the proof and the public inputs. We must update our decoding logic and the function's return signature. * **Original ABI Decode (Conceptual):** Initially, the FFI result was decoded to extract only the proof: ```solidity // bytes memory result = vm.ffi(inputs); // _proof = abi.decode(result, (bytes)); ``` * **Updated ABI Decode:** We now decode the `result` to populate both a `_proof` variable (of type `bytes`) and a new `_publicInputs` variable (of type `bytes32[]`). ```solidity // Inside the _getProof function bytes memory result = vm.ffi(generateProofCmd); // Assuming generateProofCmd holds the FFI command (bytes memory _proof, bytes32[] memory _publicInputs) = abi.decode(result, (bytes, bytes32[])); ``` It's crucial to ensure `_publicInputs` is declared with the `memory` keyword, as it's an array. * **Updated `_getProof` Return Signature:** The function signature must be changed to reflect the new return values: ```solidity internal returns (bytes memory _proof, bytes32[] memory _publicInputs) ``` **2. Modifying the JavaScript Proof Generation Script (`generateProof.ts`)** The corresponding JavaScript script (`generateProof.ts`), which uses libraries like Aztec/Barretenberg's `honk` (or a similar proving system utility), must be updated to provide these public inputs, ABI-encoded alongside the proof. * **Obtaining Proof and Public Inputs:** The proof generation function (e.g., `honk.generateProof`) typically returns both the proof and the public inputs. ```typescript // Conceptual structure, actual API might vary based on the specific zk-SNARK library version // const { proof, publicInputs } = await honk.generateProof(witness, { keccak: true }); // As shown in a typical implementation: const [proof, publicInputs] = await honk.generateProof(witness, { keccak: true }); ``` Here, `witness` represents the private inputs and circuit-specific data. The `{ keccak: true }` option might be specific to how public inputs are hashed or processed. * **ABI Encoding in JavaScript:** The proof (as `bytes`) and public inputs (as `bytes32[]`) are then ABI-encoded together using a library like `ethers.js`. Public inputs, often represented as Field elements (`Fr`), may need conversion to Buffers before encoding. ```typescript // Using ethers.js AbiCoder const ethers = require("ethers"); // Ensure ethers is imported const result = ethers.AbiCoder.defaultAbiCoder().encode( ["bytes", "bytes32[]"], [proof, publicInputs.map(i => i.toBuffer())] // Assuming publicInputs are Fr elements with a toBuffer() method ); // The script then prints this 'result' to stdout, which Foundry's FFI captures. process.stdout.write(result); // return result; // If called as a module, otherwise print for FFI ``` ### Testing the Withdrawal Flow With the proof generation mechanism updated, we can now implement the `testMakeWithdrawal` function in `Mixer.t.sol`. **1. Proof Verification Step** Before attempting the actual withdrawal from the `mixer` contract, it's a best practice to verify the generated proof using the `verifier` contract. This isolates proof generation and verification logic, making debugging easier. * **Calling `_getProof`:** First, we call our updated `_getProof` function, passing the necessary private and public information (like nullifier, secret, recipient address, and Merkle tree leaves) to generate the proof and retrieve the public inputs. ```solidity // In testMakeWithdrawal // Assume _nullifier, _secret, recipient (address), and leaves (bytes32[]) are defined (bytes memory _proof, bytes32[] memory _publicInputs) = _getProof(_nullifier, _secret, recipient, leaves); ``` Pay close attention to variable naming consistency (e.g., using `_proof` and `_publicInputs`) to avoid "Undeclared Identifier" errors. * **Asserting Verification:** We then use the `verifier` contract's `verify` function with the obtained proof and public inputs. ```solidity assertTrue(verifier.verify(_proof, _publicInputs), "Proof verification failed"); ``` This assertion confirms that our zk-SNARK system is correctly generating valid proofs for the given inputs. **2. Making the Withdrawal and Asserting State Changes** Once the proof is verified, we proceed with the withdrawal operation on the `mixer` contract. * **Initial Balance Assertions:** We start by asserting the initial state of balances: the recipient should have a zero balance, and the mixer contract should hold the deposited funds (equal to `mixer.DENOMINATION()`). ```solidity assertEq(recipient.balance, 0, "Recipient initial balance should be zero"); assertEq(address(mixer).balance, mixer.DENOMINATION(), "Mixer initial balance incorrect after deposit"); ``` * **Calling `mixer.withdraw`:** The `mixer.withdraw` function expects the proof and specific public inputs: the Merkle root, the nullifier hash, and the recipient's address. The recipient address is one of the public inputs (`_publicInputs[2]`) and is of type `bytes32`. It needs to be carefully cast to an `address payable` type for the withdrawal. ```solidity // publicInputs array typically contains: [merkleRoot, nullifierHash, recipientAddress, ...] // Ensure the indices match your circuit's public input order. mixer.withdraw( _proof, _publicInputs[0], // Merkle root _publicInputs[1], // Nullifier hash payable(address(uint160(uint256(_publicInputs[2])))) // Recipient address cast ); ``` This type conversion from `bytes32` to `address payable` is a multi-step process: `bytes32 -> uint256 -> uint160 -> address -> payable(address)`. Solidity's type safety demands such explicit casts. During development, typos like `publicInputs` instead of `_publicInputs` or `proof` instead of `_proof`, and `assertEd` instead of `assertEq` are common and can be caught by the compiler or during test runs. * **Final Balance Assertions:** After the `withdraw` call, we assert the expected final state: the recipient should have received the denomination amount, and the mixer's balance should be zero. ```solidity assertEq(recipient.balance, mixer.DENOMINATION(), "Recipient did not receive funds"); assertEq(address(mixer).balance, 0, "Mixer balance not zero after withdrawal"); ``` ### Running and Validating with Foundry Foundry provides powerful tools for executing and debugging tests: * To run a specific test function with high verbosity (useful for debugging FFI calls and assertion failures): ```bash forge test --mt testMakeWithdrawal -vvv ``` * To run all tests in the project: ```bash forge test ``` Successful execution of both `testMakeDeposit` (assumed to be pre-existing) and our new `testMakeWithdrawal` test indicates that the core functionality of the ZK-mixer – deposit, private proof generation via FFI, on-chain proof verification, and private withdrawal – is working as intended. ### Key Technical Takeaways This process highlights several important concepts in Web3 and smart contract development: * **Zero-Knowledge Proofs in Practice:** Demonstrates the integration of zk-SNARKs to achieve privacy in smart contract interactions. * **Foundry for Advanced Testing:** Showcases Foundry's FFI capability for bridging Solidity with external JavaScript libraries, essential for off-chain proof generation. * **Significance of Public Inputs:** Underscores that public inputs are indispensable for the verifier contract to validate zk-SNARK proofs. * **ABI Encoding/Decoding:** Reinforces the necessity of robust ABI encoding and decoding for seamless data exchange between Solidity and external environments (like JavaScript FFI scripts). * **Rigorous State Verification:** Emphasizes the importance of asserting contract state changes (e.g., account balances) before and after key operations to ensure correctness. * **Solidity Type System and Casting:** Illustrates Solidity's static typing and the need for careful, explicit type casting, particularly when dealing with addresses derived from `bytes32` public inputs. * **Development Best Practices:** * **Consistent Naming:** Using consistent variable names (e.g., prefixing internal/temporary variables with underscores like `_proof`, `_publicInputs`) helps prevent `Undeclared Identifier` errors. * **Data Locations:** Always explicitly specify data locations (`memory`, `storage`, `calldata`) for complex types like arrays and structs in Solidity function parameters and local variables to avoid unexpected behavior and errors. * **Debugging Tools:** Leveraging Foundry's test filtering (`--mt`) and verbosity flags (`-v`, `-vv`, `-vvv`, `-vvvv`) is crucial for efficient debugging. By successfully implementing and testing this withdrawal functionality, we've achieved a major milestone in building a private ZK-mixer, showcasing a practical application of zero-knowledge cryptography on the blockchain.
A capstone guide to Implementing and Testing Private Withdrawals in a zk-SNARK Mixer with Foundry - Master the final steps of developing and validating private withdrawals in a zk-SNARK mixer with Foundry, focusing on incorporating public inputs for proof generation. This lesson details Solidity and JavaScript modifications for a secure, private transaction flow.
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