Skip to main content

Merkle Trees

Learn how Merkle trees enable efficient zero-knowledge membership proofs in ZK-Kit.

What is a Merkle Tree?

A Merkle tree is a binary tree where:

  • Leaves contain data
  • Internal nodes contain hashes of their children
  • Root represents the entire tree state
Tree Example:
Root (H0)
/ \
H1 H2
/ \ / \
H3 H4 H5 H6
/ \ / \ / \ / \
L1 L2 L3 L4 L5 L6 L7 L8

Where:
- L1-L8 are leaves (actual data)
- H3 = hash(L1, L2)
- H1 = hash(H3, H4)
- Root = hash(H1, H2)

Why Merkle Trees for ZK?

1. Efficient Proofs

Prove membership with O(log n) data:

// For 1 million members:
// - Without Merkle tree: Send all 1M values
// - With Merkle tree: Send only 20 hashes (log₂ 1M ≈ 20)

import { IMT } from "@zk-kit/imt"
import { poseidon2 } from "poseidon-lite"

const tree = new IMT(poseidon2, 20, 0, 2) // 2^20 = 1M capacity

// Add 1 million members
for (let i = 0; i < 1000000; i++) {
tree.insert(BigInt(i))
}

// Proof contains only 20 siblings
const proof = tree.createProof(42)
console.log(proof.siblings.length) // 20

2. Privacy-Preserving

Proofs don't reveal which member:

// Three users in tree
tree.insert(alice) // index 0
tree.insert(bob) // index 1
tree.insert(charlie) // index 2

// Alice creates proof
const aliceProof = tree.createProof(0)

// Bob creates proof
const bobProof = tree.createProof(1)

// Proofs look similar - can't tell who created which
console.log(aliceProof.root === bobProof.root) // true (same tree)
// But siblings are different - no way to link to user

3. Verifiable

Anyone can verify without trusted party:

// Verifier only needs:
// 1. Public root
// 2. The proof

const isValid = tree.verifyProof(proof)
// No need to trust anyone - math proves it

How Merkle Proofs Work

Creating a Proof

import { IMT } from "@zk-kit/imt"
import { poseidon2 } from "poseidon-lite"

// Build tree
const tree = new IMT(poseidon2, 3, 0, 2) // depth 3 = 8 leaves

tree.insert(BigInt(1)) // L1
tree.insert(BigInt(2)) // L2
tree.insert(BigInt(3)) // L3
tree.insert(BigInt(4)) // L4

// Generate proof for L1 (index 0)
const proof = tree.createProof(0)

console.log(proof)
// {
// root: 12345n, // Tree root
// leaf: 1n, // The leaf (L1)
// siblings: [2n, H2], // Sibling at each level
// pathIndices: [0, 0] // Path: left, left
// }

Verifying a Proof

// Verification algorithm:
// 1. Start with leaf
// 2. Hash with sibling to get parent
// 3. Repeat up the tree
// 4. Check if final hash equals root

function verifyProofManually(proof: any): boolean {
let currentHash = proof.leaf

for (let i = 0; i < proof.siblings.length; i++) {
const sibling = proof.siblings[i]
const isLeft = proof.pathIndices[i] === 0

if (isLeft) {
currentHash = poseidon2([currentHash, sibling])
} else {
currentHash = poseidon2([sibling, currentHash])
}
}

return currentHash === proof.root
}

// Or use built-in
const isValid = tree.verifyProof(proof)

Merkle Tree Selection Guide

Question: I need to use a Merkle Tree to prove the inclusion or exclusion of data elements within a set. Which type should I use?

TypeLibraryMain FeatureUsed By
Incremental@zk-kit/imtIdeal for frequent additions and efficient updatesSemaphore V3, Worldcoin
Lean Incremental@zk-kit/lean-imtMemory-efficient, dynamic depthSemaphore V4, Zupass
Sparse@zk-kit/smtProof of non-membershipIden3

Performance Benchmarks

8 leafs

OperationFastestSlowest
InsertIMTLeanIMT
DeleteIMT ~ SparseMTIMT ~ SparseMT
UpdateLeanIMTIMT
Generate proofLeanIMTSparseMT
Verify proofIMTSparseMT

128 leafs

OperationFastestSlowest
InsertIMTLeanIMT
DeleteSparseMTIMT
UpdateLeanIMTIMT
Generate proofLeanIMTIMT
Verify proofSparseMTIMT

1024 leafs

OperationFastestSlowest
InsertSparseMTLeanIMT
DeleteSparseMTIMT
UpdateLeanIMTIMT
Generate proofLeanIMTIMT
Verify proofSparseMTIMT

Selection Criteria from Benchmarks:

  • IMT: Best performance for medium and small size insert operations
  • LeanIMT: Best performance for all tree sizes for update and generate proof operations
  • SparseMT: Best for larger data insert, delete, and verify proof operations

Types of Merkle Trees in ZK-Kit

1. IMT (Incremental Merkle Tree)

Best for: Most use cases, small to medium insert operations

import { IMT } from "@zk-kit/imt"

const tree = new IMT(poseidon2, 16, 0, 2)

// Features:
// - Fixed depth (set at creation)
// - Incremental updates
// - Full tree in memory
// - Fast operations for small-medium datasets

// Use when:
// ✓ You know max size
// ✓ Memory not constrained
// ✓ Need fast insert operations (small-medium size)
// ✓ Building like Semaphore V3 or Worldcoin

2. LeanIMT

Best for: Memory-constrained environments, all sizes for updates and proofs

import { LeanIMT } from "@zk-kit/lean-imt"

const tree = new LeanIMT((a, b) => poseidon2([a, b]))

// Features:
// - Dynamic depth
// - Minimal memory (only leaves + proofs)
// - Best for update and proof generation
// - No zero values stored

// Use when:
// ✓ Memory is limited
// ✓ Size unknown upfront
// ✓ Need fast updates and proof generation
// ✓ Building like Semaphore V4 or Zupass

3. SMT (Sparse Merkle Tree)

Best for: Key-value storage, large datasets, non-membership proofs

import { SMT } from "@zk-kit/smt"

const tree = new SMT(hash, true)

// Features:
// - Key-value pairs
// - Non-membership proofs
// - Sparse (most nodes empty)
// - Best for large data operations
// - Update/delete keys

// Use when:
// ✓ Need key-value storage
// ✓ Need to prove absence
// ✓ Large datasets
// ✓ Frequent updates/deletes
// ✓ Building like Iden3 identity systems

Tree Operations

Insert

// Add a leaf
const index = tree.insert(BigInt(42))
console.log(`Inserted at index ${index}`)

// Tree automatically:
// 1. Places leaf at next available position
// 2. Updates all parent hashes
// 3. Updates root

Update

// Change an existing leaf
tree.update(0, BigInt(99))

// Updates:
// 1. The leaf value
// 2. All parent hashes
// 3. Root hash

Delete

// Remove a leaf (set to zero)
tree.delete(0)

// Sets leaf to zero value
// Updates all parent hashes

Create Proof

// Generate membership proof
const proof = tree.createProof(index)

// Proof contains path from leaf to root
// Size: O(log n)

Verify Proof

// Verify proof is valid
const isValid = tree.verifyProof(proof)

// Returns true if:
// - Path is valid
// - Hashes are correct
// - Root matches

Tree Depth Selection

Choose depth based on expected size:

// depth = 16: 2^16 = 65,536 leaves
const smallTree = new IMT(poseidon2, 16, 0, 2)

// depth = 20: 2^20 = 1,048,576 leaves
const mediumTree = new IMT(poseidon2, 20, 0, 2)

// depth = 24: 2^24 = 16,777,216 leaves
const largeTree = new IMT(poseidon2, 24, 0, 2)

// Trade-offs:
// - Larger depth = more capacity
// - Larger depth = larger proofs
// - Larger depth = slower operations

Tree Arity

Binary (2) vs Quinary (5):

// Binary tree (arity = 2)
const binaryTree = new IMT(poseidon2, 20, 0, 2)
// - Proof size: 20 siblings
// - Verification: 20 hashes
// - More efficient for most cases

// Quinary tree (arity = 5)
const quinaryTree = new IMT(poseidon5, 9, 0, 5)
// - Proof size: 9 siblings (but 4 per level)
// - Verification: 9 * 4 = 36 hashes
// - Better for some circuit designs

Real-World Example: Access Control

import { IMT } from "@zk-kit/imt"
import { poseidon2 } from "poseidon-lite"

class AccessControl {
private tree: IMT
private users = new Map<string, number>()

constructor(maxUsers: number = 10000) {
const depth = Math.ceil(Math.log2(maxUsers))
this.tree = new IMT(poseidon2, depth, 0, 2)
}

// Add authorized user
addUser(userId: string, commitment: bigint): void {
const index = this.tree.insert(commitment)
this.users.set(userId, index)
console.log(`User ${userId} added at index ${index}`)
}

// User generates access proof
getAccessProof(userId: string): any {
const index = this.users.get(userId)
if (index === undefined) {
throw new Error("User not found")
}
return this.tree.createProof(index)
}

// Verify access (doesn't reveal which user)
verifyAccess(proof: any): boolean {
return this.tree.verifyProof(proof)
}

// Get current state root (publish this)
getRoot(): bigint {
return this.tree.root as bigint
}

// Update user commitment
updateUser(userId: string, newCommitment: bigint): void {
const index = this.users.get(userId)
if (index === undefined) {
throw new Error("User not found")
}
this.tree.update(index, newCommitment)
}

// Revoke user access
revokeUser(userId: string): void {
const index = this.users.get(userId)
if (index !== undefined) {
this.tree.delete(index)
this.users.delete(userId)
}
}
}

// Usage
const access = new AccessControl(1000)

// Add users
access.addUser("alice", poseidon2([BigInt("alice-secret")]))
access.addUser("bob", poseidon2([BigInt("bob-secret")]))

// Publish root
console.log("Current root:", access.getRoot())

// Alice proves access
const aliceProof = access.getAccessProof("alice")
console.log("Alice access:", access.verifyAccess(aliceProof))

Advanced Patterns

Batch Insertion

// Insert multiple leaves efficiently
const leaves = [1n, 2n, 3n, 4n, 5n]
const tree = new IMT(poseidon2, 16, 0, 2, leaves)

// More efficient than individual inserts

Historical Roots

class VersionedTree {
private tree: IMT
private roots: bigint[] = []

constructor() {
this.tree = new IMT(poseidon2, 16, 0, 2)
this.saveRoot()
}

insert(leaf: bigint): number {
const index = this.tree.insert(leaf)
this.saveRoot()
return index
}

private saveRoot(): void {
this.roots.push(this.tree.root as bigint)
}

// Verify against historical root
verifyHistorical(proof: any, rootIndex: number): boolean {
const historicalRoot = this.roots[rootIndex]
return proof.root === historicalRoot
}
}

Merkle Mountain Ranges

For append-only logs:

// Efficient for blockchain-like structures
// Where you only append, never update

Performance Optimization

Caching

// Cache frequently accessed proofs
const proofCache = new Map<number, any>()

function getCachedProof(tree: IMT, index: number): any {
if (!proofCache.has(index)) {
proofCache.set(index, tree.createProof(index))
}
return proofCache.get(index)
}

Parallel Verification

// Verify multiple proofs in parallel
const proofs = [proof1, proof2, proof3]

const results = await Promise.all(
proofs.map(proof => tree.verifyProof(proof))
)

Common Patterns

Anonymous Voting

// Voters in tree, votes separate
const voters = new IMT(poseidon2, 16, 0, 2)
const votes = new Map<string, string>()

// Vote with proof
function vote(proof: any, nullifier: string, choice: string) {
if (voters.verifyProof(proof) && !votes.has(nullifier)) {
votes.set(nullifier, choice)
}
}

Private Airdrops

// Eligible addresses in tree
const eligible = new IMT(poseidon2, 20, 0, 2)

// Claim anonymously
function claim(proof: any, nullifier: string, recipient: string) {
if (eligible.verifyProof(proof) && !claimed.has(nullifier)) {
transfer(recipient, amount)
claimed.add(nullifier)
}
}

Next Steps

Resources