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.
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.
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:
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[]
).
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:
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.
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.
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.
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.
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()
).
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.
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.
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):
To run all tests in the project:
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.
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