Skip to main content

Overview

Shielded Swap Pool enables private swaps from WBTC shielded pools to output tokens (ETH, USDC, STRK) without revealing:
  • Source WBTC amount
  • Swap timing
  • Output token type (until withdrawal)
  • User identity
Key Innovation: Reuses the same V4 Groth16 circuit — no new trusted setup.

Architecture

V4 Pool (WBTC) → [ZK Proof] → Swap Pool → AVNU → Output Token Pool

                            Merkle Tree (BN254)

                            Per-Leaf Storage

                        [ZK Proof] → Recipient

Flow

  1. Deposit Phase: User deposits WBTC into V4 shielded pool
  2. Swap Phase: Relayer calls private_swap() with:
    • V4 withdrawal proof (recipient = swap pool)
    • New commitment for output token
    • AVNU routes
  3. Withdrawal Phase: User proves ownership of output commitment, receives output token

Core Functions

private_swap

Execute a private swap: withdraw WBTC from V4 pool → swap to output token → store commitment.
fn private_swap(
    ref self: ContractState,
    wbtc_pool: ContractAddress,
    proof_calldata: Span<felt252>,
    new_commitment: u256,
    output_token: ContractAddress,
    min_amount_out: u256,
    routes: Array<Route>,
)
wbtc_pool
ContractAddress
required
V4 shielded pool contract address
proof_calldata
Span<felt252>
required
V4 withdrawal proof where recipient = this contract
new_commitment
u256
required
BN254 Poseidon hash for output token deposit
output_token
ContractAddress
required
Desired output token (ETH, USDC, STRK, etc.)
min_amount_out
u256
required
Minimum output amount (slippage protection)
routes
Array<Route>
required
AVNU swap routes (computed off-chain)
  1. Record WBTC balance before V4 withdrawal
  2. Call wbtc_pool.withdraw(proof_calldata) — WBTC arrives atomically
  3. Measure WBTC received
  4. Approve AVNU and swap WBTC → output_token
  5. Measure output token received
  6. Insert new_commitment into Merkle tree
  7. Store per-leaf amount and token address
  8. Emit SwapDepositEvent
Each swap deposit is treated as a “batch of 1” for V4 circuit compatibility.

withdraw

Withdraw output token using a Groth16 ZK proof.
fn withdraw(ref self: ContractState, proof_with_hints: Span<felt252>)
proof_with_hints
Span<felt252>
required
Garaga Groth16 proof blob (same 7 public inputs as V4)
Public Inputs (7):
IndexFieldTypeConstraint
0rootu256Must be in recent history (last 30)
1nullifierHashu256Must not be spent
2recipientu256Output token recipient
3relayeru256Fee recipient
4feeu256Relayer fee (≤ max_fee_bps)
5batchStartu256Leaf index (= leafIndex)
6batchSizeu256MUST be 1
  1. Verify Groth16 proof via Garaga
  2. Verify batchSize == 1 (single-leaf batch)
  3. Check nullifier hasn’t been spent
  4. Verify Merkle root is known
  5. Look up stored amount and token for leaf at batchStart
  6. Verify fee ≤ max_fee_bps of stored amount
  7. Mark nullifier as spent
  8. Send (amount - fee) to recipient, fee to relayer
batchSize MUST be 1. The circuit proves the leaf is at batchStart index with a single-element batch.

Storage

Merkle Tree (Same as V4)

next_index: u32
filled_subtrees: Map<u32, u256>
roots: Map<u32, u256>
current_root_index: u32
commitments: Map<u256, bool>
nullifier_hashes: Map<u256, bool>

Per-Leaf Token Tracking

leaf_amount: Map<u32, u256>              // Stored output token amount
leaf_token: Map<u32, ContractAddress>    // Stored output token address
Unlike V4’s batch/vault system, Swap Pool stores per-leaf amounts and token addresses directly.

View Functions

fn get_leaf_info(self: @ContractState, leaf_index: u32) -> (ContractAddress, u256)
fn get_next_index(self: @ContractState) -> u32
fn is_spent(self: @ContractState, nullifier_hash: u256) -> bool
fn get_last_root(self: @ContractState) -> u256
fn active_deposits(self: @ContractState) -> u32
fn total_swaps(self: @ContractState) -> u32

Events

SwapDepositEvent

pub struct SwapDepositEvent {
    pub commitment: u256,          // [key]
    pub leaf_index: u32,
    pub output_token: ContractAddress,
    pub output_amount: u256,
    pub timestamp: u64,
}

SwapWithdrawEvent

pub struct SwapWithdrawEvent {
    pub nullifier_hash: u256,      // [key]
    pub recipient: ContractAddress,
    pub output_token: ContractAddress,
    pub payout: u256,
    pub fee: u256,
}

Configuration

Constructor

fn constructor(
    ref self: ContractState,
    wbtc: ContractAddress,
    verifier: ContractAddress,     // Garaga Groth16 BN254 verifier
    owner: ContractAddress,
)

Parameters

fn set_verifier(ref self: ContractState, verifier: ContractAddress)
fn set_max_fee_bps(ref self: ContractState, bps: u32)  // Hard cap: 1000 (10%)

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,
    token: ContractAddress,
    recipient: ContractAddress
)

Privacy Model

Hidden from V4 Pool

  • Withdrawal destination (swap pool)
  • Intent to swap
  • Output token choice

Hidden from AVNU

  • Original depositor
  • Final recipient
  • Source pool
  • WBTC withdrawal uses V4 anonymity set (3-7 users)
  • Swap timing decoupled from deposit timing
  • Output token type hidden until final withdrawal
  • Final recipient unlinked from WBTC depositor
  • On-chain swap event reveals output_token and output_amount
  • AVNU swap is visible (but relayer-submitted)
  • Final withdrawal reveals recipient address

Integration Example

use btcvault::shielded_swap_pool::{IShieldedSwapPoolDispatcher, IShieldedSwapPoolDispatcherTrait};
use btcvault::interfaces::Route;

// Step 1: User deposits WBTC into V4 pool (off-chain proof generation)
let wbtc_pool = IShieldedPoolV4Dispatcher { contract_address: wbtc_pool_addr };
wbtc_pool.deposit(wbtc_commitment, user_addr);

// Step 2: Relayer executes private swap
let swap_pool = IShieldedSwapPoolDispatcher { contract_address: swap_pool_addr };
let v4_proof = generate_v4_proof(
    secret: secret,
    nullifier: wbtc_nullifier,
    recipient: swap_pool_addr,  // KEY: recipient = swap pool
    relayer: relayer_addr,
    fee: 0,
    batchStart: batch_start,
    batchSize: batch_size,
);

let avnu_routes = compute_routes_offchain(WBTC, USDC, amount);
let output_commitment = poseidon_hash_2(output_secret, output_nullifier);

swap_pool.private_swap(
    wbtc_pool: wbtc_pool_addr,
    proof_calldata: v4_proof,
    new_commitment: output_commitment,
    output_token: USDC_ADDRESS,
    min_amount_out: min_usdc,
    routes: avnu_routes,
);

// Step 3: User withdraws output token (generates new proof)
let output_proof = generate_v4_proof(
    secret: output_secret,
    nullifier: output_nullifier,
    recipient: final_recipient,
    relayer: relayer_addr,
    fee: relayer_fee,
    batchStart: leaf_index,      // Same as leaf_index
    batchSize: 1,                 // MUST be 1
);
swap_pool.withdraw(output_proof);

Security Considerations

Circuit Reuse: The V4 circuit was designed for fixed-denomination batches. Swap Pool adapts it by treating each swap as a “batch of 1” — this works but reduces anonymity set to 1 per swap.
AVNU Integration: Swap execution is permissionless and atomic. Slippage protection via min_amount_out.
Fee Model: Relayer earns fees in output token, NOT WBTC. This decouples relayer incentives from V4 pool.

AVNU Route Format

pub struct Route {
    pub token_from: ContractAddress,
    pub token_to: ContractAddress,
    pub exchange_address: ContractAddress,
    pub percent: u128,  // Basis points (10000 = 100%)
    pub additional_swap_params: Array<felt252>,
}
Use AVNU API to compute optimal routes off-chain. Pass the result directly to private_swap().

See Also

Build docs developers (and LLMs) love