Withdrawal Flow
Withdrawals use zero-knowledge proofs to privately transfer funds without revealing the original depositor. The proof demonstrates that the user knows the secret behind a valid commitment in the pool, while a nullifier prevents double-spending.
Sequence Overview
Step-by-Step Walkthrough
Load Account
User enters their 12-word recovery mnemonic. The SDK reconstructs the secret seed and derives all deposit precommitments from it.
Fetch Merkle Data
The SDK contacts the ASP (Association Set Provider) service to retrieve the current state tree and ASP tree data, including the Merkle proofs needed for proof generation.
Generate ZK Proof
A Groth16 ZK-SNARK proof is generated entirely client-side using snarkjs. The proof demonstrates knowledge of the deposit secret and valid tree membership without revealing the original depositor.
Submit to Relayer
The proof and withdrawal parameters are submitted to a relayer service. The relayer pays the gas cost and broadcasts the transaction, preventing the recipient address from being linked to the depositor.
On-Chain Verification
The Entrypoint contract forwards the proof to the on-chain Groth16 verifier. The verifier checks all public signals against the proof and confirms validity.
Execute Withdrawal
The Privacy Pool marks the nullifier as spent (preventing double-spending), inserts a new commitment for any remaining change, and transfers the withdrawn amount minus the relay fee to the recipient.
Client-Side Proving
ZK Proof Public Signals
The Groth16 proof exposes the following public signals that are verified on-chain. These signals constrain the proof without revealing any private inputs (deposit secret, Merkle paths, or precommitment).
| Signal | Description |
|---|---|
| newCommitmentHash | Hash of the new "change" commitment created for any unspent balance remaining after the withdrawal. |
| existingNullifierHash | Derived from the deposit secret. Published on-chain and recorded to prevent the same deposit from being spent twice. |
| withdrawnValue | The amount being withdrawn from the pool, denominated in the asset's smallest unit. |
| stateRoot | The root of the state Merkle tree at proof generation time. Must match one of the recent on-chain roots to be accepted. |
| stateTreeDepth | Depth of the state Merkle tree. Used by the circuit to verify the inclusion proof path length. |
| ASPRoot | Must match the latest on-chain ASP (Association Set Provider) root. Proves the deposit is part of the compliant set. |
| ASPTreeDepth | Depth of the ASP Merkle tree. Used to verify the ASP inclusion proof within the circuit. |
| context | Hash of the withdrawal parameters (recipient, relayer fee, etc.) combined with the pool scope. Binds the proof to a specific withdrawal request. |
Fee Structure
Two fees are applied across the deposit-withdrawal lifecycle. Both are denominated in basis points (BPS), where 1 BPS = 0.01%.
Vetting Fee
baseFeeBPSDeducted at deposit time by the Entrypoint contract. Covers the cost of ASP compliance vetting. The remaining amount after deduction is forwarded to the Privacy Pool.
Relay Fee
relayFeeBPSDeducted at withdrawal time. Compensates the relayer for gas costs and transaction submission. The relayer broadcasts the withdrawal transaction so the recipient address is never linked to the depositor.
Total Fee Calculation
netReceived = depositAmount * (1 - baseFeeBPS / 10000) - relayFeeThe vetting fee is percentage-based and deducted from the deposit. The relay fee is deducted from the withdrawal amount. The user receives the net amount after both deductions. Exact fee values are configured per pool and can be queried from the Entrypoint contract.
Stale State Roots
SDK Integration
The SDK handles account loading, Merkle data fetching, proof generation, and relayer submission in a streamlined API:
import { ShieldFlow } from '@shieldflow/sdk';
const sf = new ShieldFlow({ chainId: 1, rpcUrl: process.env.RPC_URL });
// Load account from recovery phrase
const account = await sf.loadAccount({
mnemonic: 'word1 word2 word3 ... word12',
});
// List available deposits
const deposits = await account.getDeposits();
console.log('Available balance:', deposits.totalBalance);
// Withdraw to a recipient address
const withdrawal = await sf.withdraw({
account,
depositIndex: 0,
recipient: '0xRecipientAddress...',
amount: '0.5',
relayerUrl: 'https://relay.shieldflow.xyz',
});
console.log('Withdrawal tx:', withdrawal.txHash);
console.log('Nullifier:', withdrawal.nullifierHash);Partial Withdrawals
