Deposit and Withdraw

Technical walkthrough of the deposit and withdrawal flows, including code examples and the cryptographic operations involved.

Deposit Flow

Generate Secrets

Client-side random generation

Compute Hash

Poseidon(secret, nullifier)

Approve wETH

ERC20 allowance

Deposit

Submit commitment on-chain

Save Note

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
}
Critical: Backup Your Note
If you lose your deposit note, you cannot withdraw. The secret and nullifier are never stored on-chain. Export a backup using the Backup tab in the demo.

Withdrawal Flow

Load Note

Decrypt from local storage

Generate Proof

Browser-side (~5s)

Submit TX

Proof + nullifier hash

Verify

On-chain verification

Receive

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
Nullifier unused
What Happens
Verify not double-spending
If Failed
Revert: NullifierAlreadyUsed
Check
Proof valid
What Happens
Pairing check passes
If Failed
Revert: InvalidProof
Check
Amount available
What Happens
Pool has sufficient funds
If Failed
Revert: InsufficientBalance
Check
All pass
What Happens
Transfer funds to recipient
If Failed
Emit: Withdraw event

Gas Costs

Operation
Approve wETH
Gas (Anvil)
~46,000
Est. Cost (L2)
$0.01
Est. Cost (Mainnet)
$0.50
Operation
Deposit
Gas (Anvil)
~140,000
Est. Cost (L2)
$0.03
Est. Cost (Mainnet)
$1.50
Operation
Withdraw
Gas (Anvil)
~220,000
Est. Cost (L2)
$0.05
Est. Cost (Mainnet)
$2.50
Operation
Total (deposit + withdraw)
Gas (Anvil)
~406,000
Est. Cost (L2)
$0.09
Est. Cost (Mainnet)
$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.