Overview
Shielded pools are privacy-preserving smart contracts that allow users to deposit tokens with a cryptographic commitment and later withdraw to a different address using a zero-knowledge proof. This breaks the on-chain link between deposits and withdrawals, preventing address clustering and transaction graph analysis.Shielded pools act as a privacy firewall: observers can see deposits going in and withdrawals coming out, but cannot determine which deposits correspond to which withdrawals.
- Poseidon hash for Merkle tree construction (efficient ZK-SNARK verification)
- Incremental Merkle trees for efficient on-chain state updates
- Nullifiers to prevent double-spending
- Groth16 ZK proofs to prove note ownership without revealing which note
Why shielded pools?
The address clustering problem
Stealth addresses prevent linking payments to a buyer’s identity, but they don’t solve the spending privacy problem: If a buyer combines funds from multiple stealth addresses in a single transaction, on-chain analysis can cluster those addresses as belonging to the same user.How shielded pools solve it
The shielded pool breaks the link: observers see deposits and withdrawals but cannot correlate them.Data structures
Pool state
The shielded pool contract maintains:shielded_pool.move
The pool is generic over token type
T, so separate pools exist for USDC, SUI, or other coins. This prevents mixing different token types.Note commitments
A note commitment is a Poseidon hash of:- amount: The quantity of tokens in the note
- owner_key: The note owner’s secret key
- salt: Random value for uniqueness
Nullifiers
To prevent double-spending, each note has a unique nullifier:Deposit flow
Compute note commitment
Off-chain (in the wallet), generate a random salt and compute:Store
(amount, ownerKey, salt) locally - you’ll need this data to withdraw later.Merkle tree insertion
The contract adds the commitment to the Merkle tree and updates the root:
shielded_pool.move
Withdrawal flow
Generate ZK proof
Off-chain, the wallet constructs a Circom input proving:
- Knowledge of
(amount, ownerKey, salt)such thatPoseidon(amount, ownerKey, salt)equals a commitment in the tree - The Merkle path from the commitment to the current root
- The nullifier derivation:
nullifier = Poseidon(commitment, ownerKey) - The withdrawal amount is ≤ the note amount
Handle change
If withdrawing less than the full note amount, a change commitment is inserted:The wallet can later withdraw the change using a new proof.
shielded_pool.move
Incremental Merkle tree
Why Poseidon?
identiPay uses the Poseidon hash function for the Merkle tree because:- ZK-friendly: Poseidon requires ~10x fewer constraints in Circom circuits compared to SHA-256
- Native support: Sui Move has a built-in
sui::poseidonmodule for BN254 field elements - Circomlib compatibility: The circuit uses
circomlib’s Poseidon implementation, ensuring consistency
shielded_pool.move
Filled subtrees optimization
Storing a full Merkle tree on-chain would be prohibitively expensive. Instead, the contract uses an incremental Merkle tree with “filled subtrees”:- Store one hash per level (not every node)
- When inserting a leaf, walk up the tree and update only the path to the root
- Complexity: O(depth) per insertion instead of O(2^depth) storage
shielded_pool.move
Privacy guarantees
Anonymity set
The privacy of a withdrawal depends on the anonymity set - the number of possible deposits it could correspond to:- Small anonymity set (few deposits): Easier to correlate deposits/withdrawals via timing or amount analysis
- Large anonymity set (many deposits): Strong privacy protection
identiPay’s anonymity set grows with every deposit to the pool. A pool with 10,000 deposits provides strong privacy even if an attacker knows the withdrawal amount.
Amount correlation
To maximize privacy:- Deposit standard amounts (e.g., 10 USDC, 100 USDC, 1000 USDC) instead of arbitrary amounts like 127.43 USDC
- Wait before withdrawing - don’t deposit and immediately withdraw
- Withdraw to fresh stealth addresses - never reuse withdrawal addresses
Limitations
Pool capacity and gas costs
Each pool has a maximum capacity determined by the tree depth:shielded_pool.move
- Proof generation time (more Merkle path elements to hash)
- Proof size (more public inputs)
- Verification gas costs (more constraints to check)
| Tree Depth | Capacity | Proof Size | Typical Gas Cost |
|---|---|---|---|
| 16 | 65,536 | ~2 KB | ~150K gas |
| 20 | 1,048,576 | ~2.5 KB | ~200K gas |
| 24 | 16,777,216 | ~3 KB | ~250K gas |
Security considerations
Nullifier uniqueness
Nullifier uniqueness
The nullifier must be unique per note and deterministic:This ensures:
- Two withdrawals of the same note produce the same nullifier (double-spend prevention)
- Different notes produce different nullifiers (even if owned by the same user)
- An attacker cannot guess nullifiers without knowing the owner key
Merkle root validation
Merkle root validation
The ZK proof must include the current Merkle root as a public input. This binds the proof to a specific tree state, preventing:in the circuit, ensuring the prover used the current tree state.
- Replay attacks using old proofs
- Proofs referencing notes that were never deposited
Change commitment security
Change commitment security
When making a partial withdrawal, the wallet must:
- Generate a fresh random salt for the change note
- Include the change commitment in the ZK proof public inputs
- Store the change note data locally for future withdrawal
Usage example
Here’s a typical workflow for using shielded pools:Related concepts
Zero-knowledge proofs
Learn how withdrawal proofs are constructed and verified
Stealth addresses
Understand how to generate fresh addresses for withdrawals
