Skip to main content

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)
commitment
u256
required
BN254 Poseidon hash of (secret, nullifier, leafIndex)
depositor
ContractAddress
required
Original depositor address (WBTC source)
  1. Transfer denomination WBTC from depositor to pool
  2. Insert commitment into Merkle tree at next available leaf index
  3. Mark commitment as used (prevent duplicates)
  4. 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)
count
u32
required
Batch size: must be 3, 5, or 7
  1. Validate count is 3, 5, or 7
  2. Check enough undeployed deposits exist
  3. Transfer count * denomination WBTC to vault via vault.deposit()
  4. Record batch ID, start index, deposit count, and shares per deposit
  5. 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>)
proof_with_hints
Span<felt252>
required
Garaga Groth16 proof blob with embedded public inputs
Public Inputs (7):
IndexFieldTypeDescription
0rootu256Merkle root (must be in recent history)
1nullifierHashu256Unique spend tag (prevents double-spend)
2recipientu256Withdrawal recipient address
3relayeru256Relayer address (fee recipient)
4feeu256Relayer fee in WBTC sats
5batchStartu256First leaf index of batch
6batchSizeu256Number of deposits in batch
  1. Verify Groth16 proof via Garaga BN254 verifier
  2. Check nullifier hasn’t been spent
  3. Verify Merkle root is known (within last 30 roots)
  4. Verify fee ≤ max_fee_bps of denomination
  5. Look up batch_id from batchStart
  6. Validate batch parameters (start, size) match stored batch
  7. Redeem exact batch_shares[batch_id] from vault
  8. Send (payout - fee) to recipient, fee to relayer
  9. 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

Build docs developers (and LLMs) love