Skip to main content

Overview

The Shielded Pool contract (identipay::shielded_pool) provides a privacy layer for breaking address clustering. Users deposit coins with a cryptographic commitment, then withdraw to fresh stealth addresses via ZK proofs. This prevents observers from linking multiple stealth addresses belonging to the same user, as described in the whitepaper section 4.8.

Source Code

Location: contracts/sources/shielded_pool.move:11

How It Works

1

Deposit

User deposits tokens with a Poseidon commitment C = Poseidon(amount, owner_pubkey, salt). The commitment is inserted into an incremental Merkle tree.
2

Wait

User waits for other deposits to mix their transaction in the anonymity set.
3

Withdraw

User generates a ZK proof that they own a note in the Merkle tree without revealing which one. The proof also derives a nullifier to prevent double-spending.
4

Fresh Address

Tokens are withdrawn to a new stealth address, breaking the link to previous addresses.

Data Structures

ShieldedPool

The main pool object, generic over token type <T>.
id
UID
required
Sui object identifier
balance
Balance<T>
required
Pool’s token balance
merkle_root
u256
required
Current Merkle root (BN254 field element)
nullifiers
Table<u256, bool>
required
Spent nullifiers for double-spend prevention
next_leaf_index
u64
required
Next available leaf slot in the Merkle tree
filled_subtrees
vector<u256>
required
Incremental Merkle tree state (one hash per level)
tree_depth
u8
required
Merkle tree depth (default 20, supports 2^20 = ~1M notes)
public struct ShieldedPool<phantom T> has key {
    id: UID,
    balance: Balance<T>,
    merkle_root: u256,
    nullifiers: Table<u256, bool>,
    next_leaf_index: u64,
    filled_subtrees: vector<u256>,
    tree_depth: u8,
}

DepositEvent

Emitted when tokens are deposited into the pool.
note_commitment
u256
Poseidon commitment C = Poseidon(amount, owner_pubkey, salt)
leaf_index
u64
Position in the Merkle tree (needed for generating withdrawal proofs)
new_merkle_root
u256
Updated Merkle root after insertion
public struct DepositEvent has copy, drop {
    note_commitment: u256,
    leaf_index: u64,
    new_merkle_root: u256,
}

WithdrawEvent

Emitted when tokens are withdrawn from the pool.
nullifier
u256
Unique nullifier N = Poseidon(commitment, owner_key)
recipient
address
Fresh stealth address receiving the withdrawal
amount
u64
Amount withdrawn
new_merkle_root
u256
Updated Merkle root (if change commitment was added)
public struct WithdrawEvent has copy, drop {
    nullifier: u256,
    recipient: address,
    amount: u64,
    new_merkle_root: u256,
}

Entry Functions

create_pool

Create a new shielded pool for a token type. Called once during deployment.
entry fun create_pool<T>(ctx: &mut TxContext)
Location: shielded_pool.move:77-101

deposit

Deposit tokens into the shielded pool with a note commitment. Function Signature:
entry fun deposit<T>(
    pool: &mut ShieldedPool<T>,
    coin: Coin<T>,
    note_commitment: u256,
    _ctx: &mut TxContext,
)
pool
&mut ShieldedPool<T>
required
Mutable reference to the shielded pool
coin
Coin<T>
required
Coin to deposit (consumed entirely)
note_commitment
u256
required
Poseidon commitment C = Poseidon(amount, owner_pubkey, salt)
Errors:
  • EZeroCommitment (3): Note commitment is zero
  • EPoolFull (4): Merkle tree is at capacity
  • EInvalidAmount (5): Coin value is zero
Location: shielded_pool.move:113-139 Example:
import { TransactionBlock } from '@mysten/sui.js/transactions';
import { poseidon } from '@identipay/crypto';

// Generate note commitment off-chain
const salt = randomBytes(32);
const commitment = poseidon([
  BigInt(amount),
  BigInt(ownerPubkey),
  BigInt(salt),
]);

// Deposit into pool
const tx = new TransactionBlock();
tx.moveCall({
  target: `${PACKAGE_ID}::shielded_pool::deposit`,
  typeArguments: [USDC_TYPE],
  arguments: [
    tx.object(POOL_ID),
    tx.object(coinId),
    tx.pure(commitment.toString()),
  ],
});

await wallet.signAndExecuteTransactionBlock({ transactionBlock: tx });

// Store note for later withdrawal
const note = { amount, ownerPubkey, salt, leafIndex, commitment };
saveNote(note);

withdraw

Withdraw tokens from the shielded pool using a ZK proof of note ownership. Function Signature:
entry fun withdraw<T>(
    pool: &mut ShieldedPool<T>,
    vk: &VerificationKey,
    proof: vector<u8>,
    public_inputs: vector<u8>,
    nullifier: u256,
    recipient: address,
    amount: u64,
    change_commitment: u256,
    ctx: &mut TxContext,
)
pool
&mut ShieldedPool<T>
required
Mutable reference to the shielded pool
vk
&VerificationKey
required
ZK verification key for the pool spend circuit
proof
vector<u8>
required
Groth16 proof bytes
public_inputs
vector<u8>
required
Public inputs: [merkle_root, nullifier, recipient, amount, change_commitment]
nullifier
u256
required
Unique nullifier N = Poseidon(commitment, owner_key)
recipient
address
required
Fresh stealth address for withdrawal
amount
u64
required
Amount to withdraw
change_commitment
u256
If withdrawing partial amount, commitment for change note. Pass 0 for full withdrawal.
Errors:
  • EInvalidAmount (5): Amount is zero
  • EZeroNullifier (6): Nullifier is zero
  • EInsufficientPoolBalance (2): Pool doesn’t have enough tokens
  • ENullifierAlreadySpent (0): This nullifier was already used
  • EProofVerificationFailed (1): ZK proof is invalid
Location: shielded_pool.move:150-193 Example:
import { generatePoolWithdrawProof } from '@identipay/zk';

// Find a note to spend
const note = await findNote(amount);

// Generate Merkle proof for this note
const { merkleProof, merkleRoot } = await getMerkleProof(
  note.leafIndex
);

// Generate nullifier
const nullifier = poseidon([note.commitment, ownerPrivateKey]);

// Generate ZK proof
const { proof, publicInputs } = await generatePoolWithdrawProof({
  note,
  merkleProof,
  merkleRoot,
  nullifier,
  recipient: freshStealthAddress,
  withdrawAmount: amount,
  changeCommitment: 0, // full withdrawal
});

// Execute withdrawal
const tx = new TransactionBlock();
tx.moveCall({
  target: `${PACKAGE_ID}::shielded_pool::withdraw`,
  typeArguments: [USDC_TYPE],
  arguments: [
    tx.object(POOL_ID),
    tx.object(VK_ID),
    tx.pure(proof),
    tx.pure(publicInputs),
    tx.pure(nullifier.toString()),
    tx.pure(freshStealthAddress),
    tx.pure(amount),
    tx.pure('0'), // no change
  ],
});

await wallet.signAndExecuteTransactionBlock({ transactionBlock: tx });

Public Functions

pool_balance

public fun pool_balance<T>(pool: &ShieldedPool<T>): u64
Returns the pool’s total token balance.

merkle_root

public fun merkle_root<T>(pool: &ShieldedPool<T>): u256
Returns the current Merkle root.

next_leaf_index

public fun next_leaf_index<T>(pool: &ShieldedPool<T>): u64
Returns the next available leaf index.

is_nullifier_spent

public fun is_nullifier_spent<T>(
    pool: &ShieldedPool<T>,
    nullifier: u256
): bool
Check if a nullifier has been spent. Location: shielded_pool.move:200-202

Merkle Tree Implementation

The pool uses an incremental Merkle tree with Poseidon BN254 hashing for ZK circuit compatibility.

Tree Structure

  • Depth: 20 levels (supports 2^20 = 1,048,576 notes)
  • Hash function: Poseidon BN254 (2 inputs)
  • Zero hash: 0u256 at leaf level
  • Storage: O(depth) using filled subtrees approach

Insertion Algorithm

fun insert_leaf<T>(pool: &mut ShieldedPool<T>, leaf: u256): u256
Inserts a leaf and returns the new Merkle root in O(depth) time. For each level from bottom to top:
  • If current index is even (left child): sibling is zero hash
  • If current index is odd (right child): sibling is filled_subtrees[level]
  • Compute parent as Poseidon(left, right)
  • Move up one level
Location: shielded_pool.move:226-258

Privacy Guarantees

The privacy level equals the number of deposits in the pool. With 1,000 deposits, withdrawals have 1-in-1,000 anonymity.
Zero-knowledge proofs ensure observers cannot link deposits to withdrawals. Even the contract cannot determine which note is being spent.
Nullifiers are cryptographically derived from note commitments. Each note can only be spent once, even though its commitment remains in the tree forever.
Note amounts are hidden in Poseidon commitments. Observers only see withdrawal amounts, not which deposits they came from.

Security Considerations

Trusted Setup: The ZK circuit for pool withdrawals requires a trusted setup. Use a multi-party computation ceremony for production deployments.
Front-Running: Deposits emit events with commitments. Ensure your wallet uses fresh randomness for each salt to prevent front-running attacks.
Merkle Root Updates: The Merkle root changes with every deposit and change-note insertion. Wallet UIs should display the current root and leaf count.
Gas Optimization: Incremental Merkle tree insertion costs O(depth) = O(20) Poseidon hashes per deposit, which is efficient for on-chain execution.

ZK Verifier

Groth16 proof verification

Settlement

Main commerce execution

Build docs developers (and LLMs) love