Skip to main content

Sparse Merkle Trees for ZK Circuits

Quick Start

Create a Poseidon-based sparse Merkle tree for ZK circuits:

var smt = new SparseMerkleBinaryTree<byte[]>(
new PoseidonSmtHasher(),
new ByteArrayToByteArrayConvertor(),
new IdentitySmtKeyHasher(256));

smt.Put(key, value);
var root = smt.ComputeRoot();

The root hash is circuit-compatible — use it directly in Circom/snarkjs proofs.

Why Sparse Merkle Trees for ZK?

Zero-knowledge circuits (Circom, Halo2, Noir) frequently need to prove membership or non-membership in a set. A sparse Merkle tree (SMT) is ideal because:

  1. Deterministic structure — the tree shape depends only on the keys, not insertion order
  2. Efficient proofs — proving membership requires only depth hashes (typically 140-256)
  3. Circuit-friendly hashing — Poseidon is ~100x cheaper than Keccak inside a ZK circuit
  4. Non-membership proofs — empty leaves have a known hash, so you can prove a key is absent

Use cases include privacy pools (nullifier sets), anonymous voting (ballot commitments), and rollup state roots.

Prerequisites

dotnet add package Nethereum.Merkle
dotnet add package Nethereum.Util

How It Works

SparseMerkleBinaryTree<T> is a binary tree where:

  • Each key is converted to a bit path (MSB-first or LSB-first depending on hasher)
  • Leaves store the value hash at the key's path
  • Internal nodes hash their two children
  • Empty subtrees have a fixed empty hash (no storage needed)

The ISmtHasher interface controls how leaves and nodes are hashed, with three built-in strategies:

HasherHash FunctionUse Case
PoseidonSmtHasherPoseidon (CircomT3 leaf, CircomT2 node)ZK circuits (Circom, Privacy Pools)
CelestiaSmtHasherSHA-256 with domain prefixesCelestia namespace compatibility
DefaultSmtHasherAny IHashProviderGeneric use (Keccak, SHA-256)

Example 1: Poseidon SMT for ZK Circuits

using Nethereum.Merkle.Sparse;
using Nethereum.Util.ByteArrayConvertors;

var smt = new SparseMerkleBinaryTree<byte[]>(
new PoseidonSmtHasher(),
new ByteArrayToByteArrayConvertor(),
new IdentitySmtKeyHasher(140)); // 140-bit paths

// Insert leaves
var key1 = new byte[18]; // 140 bits = ~18 bytes
key1[0] = 0x01;
var value1 = new byte[32];
value1[0] = 0xAA;

smt.Put(key1, value1);

// Compute root — this hash is Circom-compatible
var root = smt.ComputeRoot();

// The root can be used as a public input to a snarkjs proof
Console.WriteLine($"Root: {BitConverter.ToString(root)}");

Poseidon Hash Details

PoseidonSmtHasher uses two Poseidon presets:

  • Leaf hash: Poseidon(key, value, 1) using CircomT3 (3 inputs)
  • Node hash: Poseidon(leftChild, rightChild) using CircomT2 (2 inputs)

This matches the Circom SMT circuit conventions used by iden3 and Privacy Pools.

Example 2: Celestia-Compatible SMT

using Nethereum.Merkle.Sparse;
using Nethereum.Util.ByteArrayConvertors;

var smt = new SparseMerkleBinaryTree<byte[]>(
new CelestiaSmtHasher(),
new ByteArrayToByteArrayConvertor());

smt.Put(key, value);
var root = smt.ComputeRoot();

CelestiaSmtHasher uses domain-separated SHA-256:

  • Leaf: SHA256(0x00 || path || SHA256(value))
  • Node: SHA256(0x01 || leftHash || rightHash)

Example 3: Persistent Storage with Async API

For large trees that need to survive process restarts:

using Nethereum.Merkle.Sparse;
using Nethereum.Util.ByteArrayConvertors;

var storage = new InMemorySmtNodeStorage(); // Replace with DB-backed implementation

var smt = new SparseMerkleBinaryTree<byte[]>(
new PoseidonSmtHasher(),
new ByteArrayToByteArrayConvertor(),
new IdentitySmtKeyHasher(256),
storage: storage);

// Async API for storage-backed operations
await smt.PutAsync(key1, value1);
await smt.PutAsync(key2, value2);
var root = await smt.ComputeRootAsync();

// Persist to storage
await smt.FlushAsync();

// Later — load from storage in a new instance
var smt2 = new SparseMerkleBinaryTree<byte[]>(
new PoseidonSmtHasher(),
new ByteArrayToByteArrayConvertor(),
new IdentitySmtKeyHasher(256),
storage: storage);

await smt2.LoadRootAsync(root); // Lazy-loads nodes on demand
var value = await smt2.GetAsync(key1);

Example 4: Batch Operations

Insert many leaves efficiently:

var entries = new Dictionary<byte[], byte[]>
{
{ key1, value1 },
{ key2, value2 },
{ key3, value3 }
};

// Sync batch
smt.PutBatch(entries);

// Or async batch
await smt.PutBatchAsync(entries);

var root = smt.ComputeRoot();
Console.WriteLine($"Leaves: {smt.LeafCount}");

Key Path Strategies

The ISmtKeyHasher controls how keys map to bit paths in the tree:

ImplementationDescriptionTypical Use
IdentitySmtKeyHasher(n)Key bits used directly as pathPre-hashed keys, test vectors
Sha256SmtKeyHasherSHA256(key) → 256-bit pathVariable-length keys
// 256-bit tree with SHA-256 key hashing
var smt = new SparseMerkleBinaryTree<byte[]>(
new PoseidonSmtHasher(),
new ByteArrayToByteArrayConvertor(),
new Sha256SmtKeyHasher());

Node Serialization (SmtNodeCodec)

For custom storage implementations, SmtNodeCodec encodes/decodes nodes:

// Encode
byte[] encoded = SmtNodeCodec.EncodeLeaf(path, valueBytes);
byte[] branch = SmtNodeCodec.EncodeBranch(leftHash, rightHash);

// Decode
SmtNodeCodec.DecodeLeaf(encoded, out var path, out var value);
SmtNodeCodec.DecodeBranch(branch, 32, out var left, out var right);

// Type check
bool isLeaf = SmtNodeCodec.IsLeaf(data); // 0x00 prefix
bool isBranch = SmtNodeCodec.IsBranch(data); // 0x01 prefix