Deposit and Withdraw
Technical walkthrough of the deposit and withdrawal flows, including code examples and the cryptographic operations involved.
Deposit Flow
Client-side random generation
Poseidon(secret, nullifier)
ERC20 allowance
Submit commitment on-chain
Encrypted local storage
Client-side random generation
Poseidon(secret, nullifier)
ERC20 allowance
Submit commitment on-chain
Encrypted local storage
Step 1: Generate Secrets
The client generates two random 256-bit numbers: a secret and a nullifier. These are generated using the browser's cryptographically secure random number generator.
// crypto.ts - generateSecrets()
const secret = BigInt(
'0x' + crypto.getRandomValues(new Uint8Array(32))
.reduce((hex, byte) => hex + byte.toString(16).padStart(2, '0'), '')
);
const nullifier = BigInt(/* same pattern */);Step 2: Compute Commitment
The commitment is a Poseidon hash of the secret and nullifier. This is done server-side via an API route to avoid loading the heavy circomlibjs library in the browser.
// API route: /api/poseidon const poseidon = await buildPoseidon(); const commitment = poseidon.F.toString( poseidon([secret, nullifier]) ); // Returns: "0x1a2b3c..." (32-byte hex string)
Step 3: Approve and Deposit
Two transactions are needed: first approve the PoolController to spend your wETH, then call the deposit function with your commitment.
// Transaction 1: Approve wETH spending await wethContract.approve(poolController.address, amount); // Transaction 2: Deposit with commitment await poolController.deposit(commitment, amount); // Emits: Deposit(commitment, amount, index)
Step 4: Save Deposit Note
The deposit note (containing secret, nullifier, amount) is encrypted with a key derived from your wallet signature and stored in localStorage.
// Deposit note structure
interface DepositNote {
secret: string; // Hex-encoded BigInt
nullifier: string; // Hex-encoded BigInt
commitment: string; // Poseidon hash
amount: string; // In wei
timestamp: number; // Unix timestamp
_encryptedBy?: string; // Wallet address
}Withdrawal Flow
Decrypt from local storage
Browser-side (~5s)
Proof + nullifier hash
On-chain verification
Funds transferred
Decrypt from local storage
Browser-side (~5s)
Proof + nullifier hash
On-chain verification
Funds transferred
Step 1: Load Deposit Note
The encrypted note is loaded from localStorage and decrypted using your wallet-derived key. Only notes encrypted by your current wallet can be decrypted.
Step 2: Generate ZK Proof
The proof is generated in-browser using snarkjs. This proves you know the secret without revealing it.
// Proof generation with snarkjs
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
{
secret: note.secret,
nullifier: note.nullifier,
commitment: note.commitment,
nullifierHash: computeNullifierHash(note.nullifier),
recipient: recipientAddress,
amount: note.amount,
},
'/circuits/withdraw_js/withdraw.wasm',
'/circuits/keys/withdraw_final.zkey'
);Step 3: Submit Withdrawal
The proof is ABI-encoded and sent to the contract along with the nullifier hash, recipient, and amount.
// Format proof for Solidity const proofBytes = ethers.utils.defaultAbiCoder.encode( ['uint256[2]', 'uint256[2][2]', 'uint256[2]'], [proof.pi_a, proof.pi_b, proof.pi_c] ); // Call withdraw await poolController.withdraw( nullifierHash, recipientAddress, amount, proofBytes );
Step 4: On-Chain Verification
The Groth16Verifier contract checks the proof using pairing operations on the BN254 curve. If valid, the nullifier is marked as used and funds are transferred.
| Check | What Happens | If Failed |
|---|---|---|
| Nullifier unused | Verify not double-spending | Revert: NullifierAlreadyUsed |
| Proof valid | Pairing check passes | Revert: InvalidProof |
| Amount available | Pool has sufficient funds | Revert: InsufficientBalance |
| All pass | Transfer funds to recipient | Emit: Withdraw event |
Gas Costs
| Operation | Gas (Anvil) | Est. Cost (L2) | Est. Cost (Mainnet) |
|---|---|---|---|
| Approve wETH | ~46,000 | $0.01 | $0.50 |
| Deposit | ~140,000 | $0.03 | $1.50 |
| Withdraw | ~220,000 | $0.05 | $2.50 |
| Total (deposit + withdraw) | ~406,000 | $0.09 | $4.50 |
* Estimates based on 20 gwei gas price on mainnet, ~0.001 gwei on L2s.
Common Issues
Proof generation fails
Usually caused by mismatched inputs. Ensure:
- ‣Secret and nullifier match the original deposit
- ‣Amount matches exactly (in wei)
- ‣Commitment was computed with the same Poseidon parameters
InvalidProof revert
The on-chain verifier rejected the proof. Check:
- ‣Proof was formatted correctly for Solidity
- ‣Public signals match what the contract expects
- ‣You are using the correct verifier contract
NullifierAlreadyUsed
This deposit was already withdrawn. Each deposit can only be withdrawn once.