Building Shielded Pools on Kusama

Shielded pools enable private asset transfers using zero-knowledge proofs. This guide walks through building a complete shielded pool on Kusama Asset Hub.

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                    Shielded Pool                             │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  Merkle Tree (LeanIMT)                                │   │
│  │  - Tracks all commitments                             │   │
│  │  - Enables membership proofs                          │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │  Deposit    │  │  Withdraw   │  │  Transfer (UTXO)    │  │
│  │  - Add      │  │  - Spend    │  │  - Change output    │  │
│  │  commitment │  │  nullifier  │  │  commitment         │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
└─────────────────────────────────────────────────────────────┘
                           ▲
                           │
              ┌────────────┴────────────┐
              │   ZK Circuit (Circom)   │
              │   - Prove ownership     │
              │   - Valid merkle path   │
              │   - No double spend     │
              └─────────────────────────┘

Key Concepts

Commitments

A commitment hides the value and recipient:

commitment = Poseidon(value, asset, Poseidon(nullifier, secret))

Nullifiers

Prevent double-spending:

nullifierHash = Poseidon(nullifier, secret)

Merkle Tree

LeanIMT (Incremental Binary Merkle Tree):

  • Efficient insertion: O(log n)
  • Dynamic depth
  • Gas optimized

Circuit Implementation

withdraw.circom

pragma circom 2.0.0;

include "circomlib/poseidon.circom";
include "circomlib/merkle_tree.circom";

template Withdraw(treeDepth) {
    // Public inputs
    signal input root;              // Merkle root
    signal input withdrawnValue;    // Amount to withdraw
    signal input context;           // Replay protection (recipient, asset)
    
    // Private inputs
    signal input nullifier;
    signal input secret;
    signal input merkleProofPath[treeDepth];
    signal input merkleProofIndices[treeDepth];
    signal input newCommitmentSecret;
    
    // Outputs
    signal output newCommitmentHash;
    signal output existingNullifierHash;
    
    // Compute nullifier hash
    component nullifierHash = Poseidon(2);
    nullifierHash.inputs[0] <== nullifier;
    nullifierHash.inputs[1] <== secret;
    existingNullifierHash <== nullifierHash.out;
    
    // Verify Merkle membership
    component merkleCheck = MerkleTreeCheck(treeDepth);
    merkleCheck.f <== existingNullifierHash;
    merkleCheck.root <== root;
    for (var i = 0; i < treeDepth; i++) {
        merkleCheck.siblings[i] <== merkleProofPath[i];
        merkleCheck.index[i] <== merkleProofIndices[i];
    }
    
    // Create new commitment for change
    component newCommitment = Poseidon(2);
    newCommitment.inputs[0] <== withdrawnValue;
    newCommitment.inputs[1] <== newCommitmentSecret;
    newCommitmentHash <== newCommitment.out;
    
    // Context binding (prevent front-running)
    // context = keccak256(recipient, asset) mod p
    // This is computed off-chain and verified as public input
}

component main {public [root, withdrawnValue, context]} = Withdraw(20);

Circuit Constraints

  1. Merkle Proof: Proves commitment exists in tree
  2. Nullifier Hash: Unique per spend, prevents double-spend
  3. New Commitment: UTXO change for remaining value
  4. Context Binding: Binds proof to specific recipient/asset

Smart Contract Implementation

The complete shielded pool contract implementation is available in the Kusama Shield Shielded Pool Contract.

The contract implements:

  • Deposit functionality with Merkle tree insertion
  • Withdraw functionality with Groth16 proof verification
  • Nullifier tracking to prevent double-spending
  • LeanIMT (Incremental Merkle Tree) for efficient state management

Off-Chain Components

Commitment Generation (TypeScript)

import { poseidon } from 'circomlibjs';

function generateCommitment(
    value: bigint,
    asset: bigint,
    nullifier: bigint,
    secret: bigint
): bigint {
    const inner = poseidon([nullifier, secret]);
    const commitment = poseidon([value, poseidon([asset, inner])]);
    return commitment;
}

function generateNullifierHash(
    nullifier: bigint,
    secret: bigint
): bigint {
    return poseidon([nullifier, secret]);
}

Proof Generation

import { groth16 } from 'snarkjs';

async function generateWithdrawProof(
    nullifier: string,
    secret: string,
    merkleProof: string[],
    merkleIndices: number[],
    root: string,
    withdrawnValue: string,
    recipient: string,
    asset: string
) {
    // Compute context
    const context = computeContext(recipient, asset);
    
    // Generate witness
    const witness = await circuit.calculateWitness({
        nullifier,
        secret,
        merkleProofPath: merkleProof,
        merkleProofIndices: merkleIndices,
        root,
        withdrawnValue,
        context,
        newCommitmentSecret: generateRandomSecret()
    });
    
    // Generate proof
    const { proof, publicSignals } = await groth16.prove(
        'circuit_final.zkey',
        witness
    );
    
    return { proof, publicSignals };
}

function computeContext(recipient: string, asset: string): string {
    const hash = ethers.keccak256(
        ethers.solidityPacked(['address', 'address'], [recipient, asset])
    );
    return (BigInt(hash) % SNARK_SCALAR_FIELD).toString();
}

Merkle Tree Management

import { LeanIMT } from '@zk-kit/lean-imt';
import { poseidon } from 'circomlibjs';

class PoolTree {
    private tree: LeanIMT;
    
    constructor() {
        this.tree = new LeanIMT((a, b) => poseidon([a, b]));
    }
    
    insert(commitment: bigint) {
        this.tree.insert(commitment);
    }
    
    generateProof(commitment: bigint) {
        const { root, siblings, index } = this.tree.generateProof(commitment);
        return {
            root: root.toString(),
            siblings: siblings.map(s => s.toString()),
            indices: index.toString(2).padStart(20, '0').split('').map(Number)
        };
    }
    
    getRoot(): bigint {
        return this.tree.root;
    }
}

Deployment Steps

1. Deploy Verifier

# Generate verifier from circuit
snarkjs zkey export solidityverifier circuit_final.zkey verifier.sol

# Deploy to Asset Hub
forge create src/verifier.sol:Groth16Verifier \
  --rpc-url https://testnet-passet-hub-eth-rpc.polkadot.io \
  --private-key $PRIVATE_KEY

2. Deploy Pool Contract

# Deploy with verifier address
forge create src/shielded_pool.sol:FixedIlop \
  --constructor-args <VERIFIER_ADDRESS> \
  --rpc-url https://testnet-passet-hub-eth-rpc.polkadot.io \
  --private-key $PRIVATE_KEY

3. Fund Pool

// Deposit initial liquidity
const tx = await contract.deposit(
    TOKEN_ADDRESS,  // or address(0) for native
    ethers.parseEther('1000'),
    initialCommitment
);
await tx.wait();

Usage Flow

Deposit

// 1. Generate commitment
const commitment = generateCommitment(value, asset, nullifier, secret);

// 2. Call deposit
await contract.deposit(asset, amount, commitment);

// 3. Track commitment in local tree
poolTree.insert(commitment);

Withdraw

// 1. Find commitment in tree
const { root, siblings, indices } = poolTree.generateProof(commitment);

// 2. Generate proof
const { proof, publicSignals } = await generateWithdrawProof(
    nullifier, secret, siblings, indices, root, amount, recipient, asset
);

// 3. Call withdraw
await contract.withdraw(
    proof.a, proof.b, proof.c, publicSignals,
    asset, recipient
);

Security Considerations

1. Nullifier Management

  • Never reuse nullifiers
  • Store nullifiers securely
  • Use cryptographically random secrets

2. Merkle Root Validity

  • Accept historical roots (for UX)
  • Set expiration if needed
  • Monitor for invalid proofs

3. Front-Running Protection

  • Context binding prevents recipient changes
  • Consider commit-reveal for large withdrawals
  • Use private RPC endpoints

4. Escrow Safety

  • Reentrancy guards
  • Checks-effects-interactions pattern
  • Emergency withdrawal mechanism

Gas Optimization

ComponentGas CostOptimization
Deposit~150,000Batch inserts
Withdraw~300,000Efficient circuits
Merkle Insert~50,000LeanIMT structure

Testing

describe('ShieldedPool', () => {
    it('should deposit and withdraw privately', async () => {
        // Deposit
        const commitment = generateCommitment(...);
        await pool.deposit(asset, amount, commitment);
        
        // Generate proof
        const proof = await generateWithdrawProof(...);
        
        // Withdraw
        await pool.withdraw(..., recipient, asset);
        
        // Verify balance changed
        expect(await ethers.provider.getBalance(recipient))
            .to.equal(initialBalance + amount);
    });
});

Resources


Previous: Asset Hub Integration | Next: Circom Guide