Overview
Shielded Pool V4 enables private WBTC deposits with:
Groth16 ZK proofs for deposit/withdrawal unlinkability
Variable batch sizes (3, 5, or 7 deposits per batch)
Auto-deploy to ERC-4626 vaults when batch threshold is reached
Merkle tree commitment tracking (depth 10, capacity 1,024)
BN254 Poseidon hash function (Garaga-compatible)
Privacy Properties
Hidden
Depositor address (relayer submits)
Deposit amount (fixed denomination)
Depositor-withdrawer link (ZK proof)
Unpredictable
Batch boundaries (variable sizes)
Anonymity set per batch (3-7 users)
Architecture
User → Relayer → ShieldedPoolV4 → ERC-4626 Vault (Sentinel/Citadel/Apex)
↓
Merkle Tree (BN254 Poseidon)
↓
Batch Tracking
Core Functions
deposit
Deposit exactly denomination WBTC with a commitment hash. Relayer-only.
fn deposit(ref self: ContractState, commitment: u256, depositor: ContractAddress)
BN254 Poseidon hash of (secret, nullifier, leafIndex)
Original depositor address (WBTC source)
Transfer denomination WBTC from depositor to pool
Insert commitment into Merkle tree at next available leaf index
Mark commitment as used (prevent duplicates)
Auto-deploy batch of 3 if threshold reached (when auto-deploy enabled)
Auto-deploy is enabled by default . Batches of 3 are automatically deployed to the vault when 3+ undeployed deposits accumulate.
deploy_batch
Manually deploy a batch of pending deposits to the vault.
fn deploy_batch(ref self: ContractState, count: u32)
Batch size: must be 3, 5, or 7
Validate count is 3, 5, or 7
Check enough undeployed deposits exist
Transfer count * denomination WBTC to vault via vault.deposit()
Record batch ID, start index, deposit count, and shares per deposit
Emit BatchDeployedEvent with batch metadata
deploy_partial_batch
Deploy a partial batch (any count ≥ 1) for timeout scenarios.
fn deploy_partial_batch(ref self: ContractState, count: u32)
Partial batches break the privacy model’s predictable batch sizes. Use only for emergency timeout scenarios.
withdraw
Withdraw using a Groth16 ZK proof with 7 public inputs.
fn withdraw(ref self: ContractState, proof_with_hints: Span<felt252>)
Garaga Groth16 proof blob with embedded public inputs
Public Inputs (7) :
Index Field Type Description 0 root u256 Merkle root (must be in recent history) 1 nullifierHash u256 Unique spend tag (prevents double-spend) 2 recipient u256 Withdrawal recipient address 3 relayer u256 Relayer address (fee recipient) 4 fee u256 Relayer fee in WBTC sats 5 batchStart u256 First leaf index of batch 6 batchSize u256 Number of deposits in batch
Verify Groth16 proof via Garaga BN254 verifier
Check nullifier hasn’t been spent
Verify Merkle root is known (within last 30 roots)
Verify fee ≤ max_fee_bps of denomination
Look up batch_id from batchStart
Validate batch parameters (start, size) match stored batch
Redeem exact batch_shares[batch_id] from vault
Send (payout - fee) to recipient, fee to relayer
Mark nullifier as spent
Storage
Merkle Tree
next_index: u32 // Next available leaf index
filled_subtrees: Map<u32, u256> // Sparse Merkle tree levels
roots: Map<u32, u256> // Ring buffer of recent roots (size 30)
current_root_index: u32 // Current root position in ring buffer
commitments: Map<u256, bool> // Commitment uniqueness check
Tree Depth : 10 levels (max 1,024 deposits per pool)Hash Function : BN254 Poseidon(2) via Garaga, matching circomlib
Batch Tracking
next_undeployed_index: u32 // First leaf not yet in a batch
batch_id_counter: u32 // Auto-incrementing batch ID
batch_start: Map<u32, u32> // batch_id → first leaf index
batch_count: Map<u32, u32> // batch_id → number of deposits
batch_shares: Map<u32, u256> // batch_id → yvBTC shares per deposit
batch_deployed: Map<u32, bool> // batch_id → deployed status
leaf_batch_id: Map<u32, u32> // leaf_index → batch_id (reverse lookup)
leaf_has_batch: Map<u32, bool> // leaf_index → has_batch flag
View Functions
fn denomination(self: @ContractState) -> u256
fn undeployed_count(self: @ContractState) -> u32
fn active_deposits(self: @ContractState) -> u32
fn total_deposits(self: @ContractState) -> u32
fn get_batch_info(self: @ContractState, batch_id: u32) -> (u32, u32, u256, bool)
fn get_leaf_batch_id(self: @ContractState, leaf_index: u32) -> u32
fn is_spent(self: @ContractState, nullifier_hash: u256) -> bool
fn get_last_root(self: @ContractState) -> u256
Events
DepositEvent
pub struct DepositEvent {
pub commitment: u256, // [key]
pub leaf_index: u32,
pub timestamp: u64,
}
BatchDeployedEvent
pub struct BatchDeployedEvent {
pub batch_id: u32, // [key]
pub start_index: u32,
pub deposit_count: u32,
pub total_wbtc: u256,
pub shares_received: u256,
pub shares_per_deposit: u256,
}
WithdrawalEvent
pub struct WithdrawalEvent {
pub nullifier_hash: u256, // [key]
pub recipient: ContractAddress,
pub batch_id: u32,
pub payout: u256,
pub fee: u256,
}
Configuration
Constructor
fn constructor(
ref self: ContractState,
asset: ContractAddress, // WBTC
verifier: ContractAddress, // Garaga Groth16 BN254 verifier
vault: ContractAddress, // ERC-4626 vault (yvBTC)
owner: ContractAddress,
denomination: u256, // Fixed deposit amount (sats)
)
Parameters
fn set_denomination(ref self: ContractState, denomination: u256) // Requires no undeployed deposits
fn set_max_fee_bps(ref self: ContractState, bps: u32) // Hard cap: 1000 (10%)
fn set_verifier(ref self: ContractState, verifier: ContractAddress)
fn set_auto_deploy(ref self: ContractState, enabled: bool)
set_denomination() only succeeds when undeployed_count() == 0 to prevent batch size mismatches.
Curator Functions
fn pause(ref self: ContractState)
fn unpause(ref self: ContractState)
fn upgrade(ref self: ContractState, new_class_hash: ClassHash)
fn emergency_withdraw(ref self: ContractState, recipient: ContractAddress)
Security Model
Depositor anonymity : Relayer submits all deposits
Amount privacy : Fixed denomination per pool
Unlinkability : Groth16 proof breaks depositor-withdrawer link
Anonymity set : 3-7 users per batch (variable)
Prove knowledge of secret + nullifier preimage
Prove commitment exists in Merkle tree at claimed root
Prove nullifier derives from same secret as commitment
Prove batch membership (batchStart, batchSize)
Max fee cap prevents relayer extraction
Root history (30) tolerates network delays
Nullifier registry prevents double-spend
Vault shares locked until withdrawal
Integration Example
use btcvault::shielded_pool_v4::{IShieldedPoolV4Dispatcher, IShieldedPoolV4DispatcherTrait};
// Relayer deposits on behalf of user
let pool = IShieldedPoolV4Dispatcher { contract_address: pool_addr };
let commitment = poseidon_hash_2(secret, nullifier); // Off-chain
pool.deposit(commitment, user_address);
// Check batch status
let (start, count, shares, deployed) = pool.get_batch_info(batch_id);
assert (deployed, 'Batch not deployed' );
// User withdraws (generates proof off-chain)
let proof_blob = generate_groth16_proof(secret, nullifier, recipient, ...);
pool.withdraw(proof_blob);
See Also