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
- Merkle Proof: Proves commitment exists in tree
- Nullifier Hash: Unique per spend, prevents double-spend
- New Commitment: UTXO change for remaining value
- 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
| Component | Gas Cost | Optimization |
|---|---|---|
| Deposit | ~150,000 | Batch inserts |
| Withdraw | ~300,000 | Efficient circuits |
| Merkle Insert | ~50,000 | LeanIMT 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