Compact gives you two ways to store collections on-chain: Map and MerkleTree. They look superficially similar (both key-based, both ledger types) but solve completely different problems. Picking the wrong one will cost you.
This tutorial walks through both from scratch. You'll build a Map-based registry, a Merkle-tree allowlist with depth-20 path verification, and a hybrid contract that combines both. All contracts are verified against Compact compiler v0.31.0.
Prerequisites: Midnight toolchain installed, basic familiarity with Compact circuits and ledger declarations.
The core trade-off
A Map<K, V> stores key-value pairs directly on-chain. Every entry is readable by anyone — it's public state. You get O(1) lookups, full CRUD operations, and simple iteration in TypeScript. The trade-off: the entire dataset is visible.
A MerkleTree<n, T> stores only its root hash on-chain. Individual entries aren't visible. Membership is proven through a cryptographic path: a witness that shows "this leaf exists in a tree with this root" without revealing which leaf or the rest of the tree. The trade-off: it's append-only, deletion is complicated, and path lookups are O(n) without index tracking.
Short version: use Map when you need mutable, readable data. Use MerkleTree when you need to prove membership without revealing the full set.
Maps in Compact
Declaration
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger registry: Map<Bytes<32>, Bytes<32>>;
export ledger balances: Map<Bytes<32>, Uint<128>>;
export ledger approvals: Map<Bytes<32>, Map<Bytes<32>, Boolean>>; // nested
Maps are ledger-state types. The key and value can be any Compact type, or another ledger-state type (which allows nesting). The only restriction: Opaque<"string"> and Opaque<"Uint8Array"> can't be keys.
The full Map API
// Check existence
registry.member(key) // Boolean
// Read
registry.lookup(key) // returns V (fails if missing — guard with member() first)
// Write
registry.insert(disclose(key), disclose(value)) // add or overwrite
registry.remove(disclose(key)) // delete
// Size
registry.size() // Uint<64>
registry.isEmpty() // Boolean
// Default insertion (for nested maps)
registry.insertDefault(disclose(key)) // inserts default<V> at key
registry.resetToDefault() // clear all entries
Why disclose() on write operations
Any circuit parameter going into a ledger write must be wrapped in disclose(). This applies to both keys and values. Without it, the compiler rejects the code with:
Exception: potential witness-value disclosure must be declared but is not
The rule covers all exported circuit parameters, not just witness data. The compiler can't statically prove where a value came from, so it treats all exported inputs as potentially private and requires explicit disclosure before they touch public ledger state.
// Fails
registry.insert(key, value);
// Correct
registry.insert(disclose(key), disclose(value));
Sealed maps
The sealed modifier locks a ledger field after the constructor runs. Nothing can change it afterward. Not through any circuit, not through any future transaction. Use it for configuration that should be set once at deployment:
sealed ledger adminKey: Bytes<32>;
export ledger config: Map<Bytes<32>, Bytes<32>>;
constructor(admin: Bytes<32>) {
adminKey = disclose(admin);
}
Any attempt to write to adminKey outside the constructor is a compile error. This is a stronger guarantee than access control logic: the constraint is in the language itself, not in the contract logic.
One syntax note: sealed takes the ledger keyword directly — sealed ledger name: Type. Adding export between them (sealed export ledger) is a parse error. Sealed fields are part of the ledger state and readable on-chain regardless of whether they carry the export modifier.
Merkle trees in Compact
Declaration
export ledger allowlist: MerkleTree<20, Bytes<32>>;
The first parameter is the tree depth. Depth 20 supports up to 2^20 = 1,048,576 leaves. The compiler enforces depth between 2 and 32 inclusive. The second parameter is the leaf type: any Compact type except Opaque<"string"> or Opaque<"Uint8Array"> (they can't be hashed by the standard library).
Inserting leaves
// Insert a leaf (appends at the next free slot)
allowlist.insert(disclose(leaf));
// Insert a pre-hashed value (skips leaf hashing)
allowlist.insertHash(disclose(hash));
// Insert at a specific index (useful when you track indices off-chain)
allowlist.insertIndex(disclose(leaf), disclose(index));
// Insert default value at index (logical "deletion" workaround)
allowlist.insertIndexDefault(disclose(index));
MerkleTrees are append-only. There's no .remove(). The pattern for "deleting" a leaf is inserting a sentinel default value at that index. This changes the root and invalidates any outstanding proofs for that slot.
Path verification
The proof model: the tree root lives on-chain. The membership path (leaf + sibling hashes + directions) stays off-chain, provided by a witness. The circuit reconstructs the root from the path and checks it against the on-chain root.
witness findLeaf(leaf: Bytes<32>): MerkleTreePath<20, Bytes<32>>;
export circuit verify(leaf: Bytes<32>): [] {
const path = findLeaf(leaf);
const computed = merkleTreePathRoot<20, Bytes<32>>(path);
assert(
allowlist.checkRoot(disclose(computed)),
"Leaf is not in the allowlist"
);
}
checkRoot(MerkleTreeDigest) is a method on the MerkleTree ledger type. It returns true if the given digest matches the tree's current root. This is cleaner than storing and comparing the root manually.
HistoricMerkleTree for concurrent inserts
Standard MerkleTree proofs validate against the current root. In a busy contract where multiple users are inserting simultaneously, a proof generated before an insert will fail after it. The root changed. This bites almost everyone who first deploys a real allowlist under concurrent load.
HistoricMerkleTree<n, T> keeps a history of past roots. A witness can prove membership against any past root, not just the latest one. This makes it resilient to concurrent insertions:
export ledger members: HistoricMerkleTree<20, Bytes<32>>;
witness getMemberPath(): MerkleTreePath<20, Bytes<32>>;
export circuit addMember(leaf: Bytes<32>): [] {
members.insert(disclose(leaf));
}
export circuit proveHistoricMembership(): [] {
const path = getMemberPath();
const computed = merkleTreePathRoot<20, Bytes<32>>(path);
assert(
members.checkRootInHistory(disclose(computed)),
"Leaf is not a historic member"
);
}
checkRootInHistory(digest) returns true if the digest matches any root the tree has ever had. Use HistoricMerkleTree for allowlists that see frequent insertions, or any pattern where proof generation and verification don't happen atomically.
Example 1: Map-based registry
A contract where addresses can register key-value entries, update them, and remove them.
registry.compact
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger registry: Map<Bytes<32>, Bytes<32>>;
export ledger entryCount: Counter;
export circuit register(key: Bytes<32>, value: Bytes<32>): [] {
assert(!registry.member(disclose(key)), "Key already registered");
registry.insert(disclose(key), disclose(value));
entryCount.increment(1);
}
export circuit update(key: Bytes<32>, newValue: Bytes<32>): [] {
assert(registry.member(disclose(key)), "Key not registered");
registry.insert(disclose(key), disclose(newValue));
}
export circuit deregister(key: Bytes<32>): [] {
assert(registry.member(disclose(key)), "Key not registered");
registry.remove(disclose(key));
}
export circuit isRegistered(key: Bytes<32>): Boolean {
return registry.member(disclose(key));
}
isRegistered only reads ledger state and calls no witnesses, but it still accesses the registry ledger field — which means it cannot be marked pure. In Compact, pure circuits have strict semantics: zero ledger access, zero witness calls, computation on input parameters only. This is stricter than Solidity's view functions. isRegistered is a regular exported circuit that reads without writing.
registry.test.ts
import { describe, it, expect } from 'vitest';
import {
createConstructorContext,
createCircuitContext,
sampleContractAddress,
} from '@midnight-ntwrk/compact-runtime';
import { Contract, ledger } from '../managed/registry/contract/index.js';
const coinPublicKey = new Uint8Array(32);
const contractAddress = sampleContractAddress();
function createSimulator() {
const contract = new Contract({});
const constructorCtx = createConstructorContext({}, coinPublicKey);
const { currentPrivateState, currentContractState, currentZswapLocalState } =
contract.initialState(constructorCtx);
const circuitContext = createCircuitContext(
contractAddress,
currentZswapLocalState,
currentContractState,
currentPrivateState,
);
return { contract, circuitContext };
}
const keyA = new Uint8Array(32).fill(1);
const valueA = new Uint8Array(32).fill(2);
const valueB = new Uint8Array(32).fill(3);
const keyB = new Uint8Array(32).fill(4);
describe('registry', () => {
it('registers a new entry and increments the counter', () => {
let { contract, circuitContext } = createSimulator();
({ context: circuitContext } = contract.impureCircuits.register(circuitContext, keyA, valueA));
const state = ledger(circuitContext.currentQueryContext.state);
expect(state.entryCount).toBe(1n);
expect(state.registry.member(keyA)).toBe(true);
});
it('rejects duplicate registration', () => {
let { contract, circuitContext } = createSimulator();
({ context: circuitContext } = contract.impureCircuits.register(circuitContext, keyA, valueA));
expect(() =>
contract.impureCircuits.register(circuitContext, keyA, valueA)
).toThrow();
});
it('updates an existing entry', () => {
let { contract, circuitContext } = createSimulator();
({ context: circuitContext } = contract.impureCircuits.register(circuitContext, keyA, valueA));
({ context: circuitContext } = contract.impureCircuits.update(circuitContext, keyA, valueB));
const state = ledger(circuitContext.currentQueryContext.state);
expect(state.registry.lookup(keyA)).toEqual(valueB);
});
it('rejects update for unregistered key', () => {
let { contract, circuitContext } = createSimulator();
expect(() =>
contract.impureCircuits.update(circuitContext, keyA, valueB)
).toThrow();
});
it('deregisters an entry', () => {
let { contract, circuitContext } = createSimulator();
({ context: circuitContext } = contract.impureCircuits.register(circuitContext, keyA, valueA));
({ context: circuitContext } = contract.impureCircuits.deregister(circuitContext, keyA));
const state = ledger(circuitContext.currentQueryContext.state);
expect(state.registry.member(keyA)).toBe(false);
});
it('reports membership correctly', () => {
let { contract, circuitContext } = createSimulator();
({ context: circuitContext } = contract.impureCircuits.register(circuitContext, keyA, valueA));
const { result: isA } = contract.pureCircuits.isRegistered(circuitContext, keyA);
const { result: isB } = contract.pureCircuits.isRegistered(circuitContext, keyB);
expect(isA).toBe(true);
expect(isB).toBe(false);
});
});
Example 2: Merkle-tree allowlist (depth 20)
A contract that manages a membership allowlist. Members can be added. Anyone can generate a ZK proof of membership without revealing which slot they occupy or who else is in the list.
allowlist.compact
pragma language_version >= 0.20;
import CompactStandardLibrary;
export ledger allowlist: MerkleTree<20, Bytes<32>>;
export ledger leafCount: Counter;
// Off-chain witness: given a leaf, return its Merkle path
witness findLeaf(leaf: Bytes<32>): MerkleTreePath<20, Bytes<32>>;
export circuit addToAllowlist(leaf: Bytes<32>): [] {
allowlist.insert(disclose(leaf));
leafCount.increment(1);
}
// Prove membership without revealing which leaf or its position
export circuit verifyMembership(leaf: Bytes<32>): [] {
const path = findLeaf(leaf);
const computed = merkleTreePathRoot<20, Bytes<32>>(path);
assert(
allowlist.checkRoot(disclose(computed)),
"Leaf is not in the allowlist"
);
}
The findLeaf witness runs entirely off-chain in TypeScript. It has access to the full tree state (through context.ledger.allowlist) and returns the path for the requested leaf. No path data touches the on-chain state: only the root comparison happens in the circuit.
allowlist.test.ts
import { describe, it, expect } from 'vitest';
import {
createConstructorContext,
createCircuitContext,
sampleContractAddress,
} from '@midnight-ntwrk/compact-runtime';
import { Contract, ledger } from '../managed/allowlist/contract/index.js';
const coinPublicKey = new Uint8Array(32);
const contractAddress = sampleContractAddress();
function createSimulator() {
const contract = new Contract({
findLeaf: (context: any, leaf: Uint8Array) => {
const path = context.ledger.allowlist.findPathForLeaf(leaf);
if (!path) throw new Error('Leaf not found in allowlist');
return [context.privateState, path];
},
});
const constructorCtx = createConstructorContext({}, coinPublicKey);
const { currentPrivateState, currentContractState, currentZswapLocalState } =
contract.initialState(constructorCtx);
const circuitContext = createCircuitContext(
contractAddress,
currentZswapLocalState,
currentContractState,
currentPrivateState,
);
return { contract, circuitContext };
}
const leafA = new Uint8Array(32).fill(1);
const leafB = new Uint8Array(32).fill(2);
const leafC = new Uint8Array(32).fill(3);
describe('allowlist', () => {
it('adds a leaf and increments the counter', () => {
let { contract, circuitContext } = createSimulator();
({ context: circuitContext } = contract.impureCircuits.addToAllowlist(circuitContext, leafA));
const state = ledger(circuitContext.currentQueryContext.state);
expect(state.leafCount).toBe(1n);
});
it('verifies a leaf that is in the allowlist', () => {
let { contract, circuitContext } = createSimulator();
({ context: circuitContext } = contract.impureCircuits.addToAllowlist(circuitContext, leafA));
expect(() =>
contract.impureCircuits.verifyMembership(circuitContext, leafA)
).not.toThrow();
});
it('rejects a leaf not in the allowlist', () => {
let { contract, circuitContext } = createSimulator();
({ context: circuitContext } = contract.impureCircuits.addToAllowlist(circuitContext, leafA));
expect(() =>
contract.impureCircuits.verifyMembership(circuitContext, leafB)
).toThrow();
});
it('verifies multiple members independently', () => {
let { contract, circuitContext } = createSimulator();
({ context: circuitContext } = contract.impureCircuits.addToAllowlist(circuitContext, leafA));
({ context: circuitContext } = contract.impureCircuits.addToAllowlist(circuitContext, leafB));
({ context: circuitContext } = contract.impureCircuits.addToAllowlist(circuitContext, leafC));
expect(() => contract.impureCircuits.verifyMembership(circuitContext, leafA)).not.toThrow();
expect(() => contract.impureCircuits.verifyMembership(circuitContext, leafB)).not.toThrow();
expect(() => contract.impureCircuits.verifyMembership(circuitContext, leafC)).not.toThrow();
});
});
Off-chain witness implementation
When you deploy this contract, you provide the findLeaf implementation in TypeScript. The compact runtime calls it during proof generation:
import type { WitnessContext } from '@midnight-ntwrk/compact-runtime';
type PrivateState = Record<string, never>;
export const witnesses = {
findLeaf: (
context: WitnessContext<Ledger, PrivateState>,
leaf: Uint8Array,
): [PrivateState, MerkleTreePath] => {
// findPathForLeaf does O(n) scan — use pathForLeaf(index, leaf) if you track indices
const path = context.ledger.allowlist.findPathForLeaf(leaf);
if (!path) throw new Error('Leaf not found');
return [context.privateState, path];
},
};
If your allowlist is large and you need O(1) path lookups, track leaf indices in your application state and use context.ledger.allowlist.pathForLeaf(index, leaf) instead.
Hybrid pattern: Map + MerkleTree together
Neither structure handles every use case on its own. A common pattern in production contracts is to use a Map for O(1) reads and mutations, while maintaining a MerkleTree for ZK membership proofs. This gives you fast on-chain lookups and privacy-preserving membership proofs from the same dataset.
Here's an identity registry that stores profile data in a Map (readable, mutable) and provides ZK membership proofs via a MerkleTree (private, unlinkable):
identity-registry.compact
pragma language_version >= 0.20;
import CompactStandardLibrary;
// Map: full profile data — readable by anyone on-chain
export ledger profiles: Map<Bytes<32>, Bytes<32>>;
// MerkleTree: membership set — prove you're registered without revealing your key
export ledger memberTree: MerkleTree<20, Bytes<32>>;
export ledger memberCount: Counter;
// Sealed admin key — set at deployment, immutable afterward
sealed ledger adminKey: Bytes<32>;
witness getMemberPath(identity: Bytes<32>): MerkleTreePath<20, Bytes<32>>;
constructor(admin: Bytes<32>) {
adminKey = disclose(admin);
}
// Register: add profile data to Map AND add identity hash to MerkleTree
export circuit registerIdentity(identity: Bytes<32>, profileData: Bytes<32>): [] {
assert(!profiles.member(disclose(identity)), "Identity already registered");
profiles.insert(disclose(identity), disclose(profileData));
memberTree.insert(disclose(identity));
memberCount.increment(1);
}
// Read profile — public, anyone can query (not pure: reads ledger field)
export circuit getProfile(identity: Bytes<32>): Bytes<32> {
assert(profiles.member(disclose(identity)), "Identity not found");
return profiles.lookup(disclose(identity));
}
// Update profile data — only the identity holder can update (they know their key)
export circuit updateProfile(identity: Bytes<32>, newData: Bytes<32>): [] {
assert(profiles.member(disclose(identity)), "Identity not found");
profiles.insert(disclose(identity), disclose(newData));
}
// Prove membership without revealing which identity or correlating to profile
export circuit proveMembership(identity: Bytes<32>): [] {
const path = getMemberPath(identity);
const computed = merkleTreePathRoot<20, Bytes<32>>(path);
assert(
memberTree.checkRoot(disclose(computed)),
"Identity is not a registered member"
);
}
The separation matters here. An on-chain observer can read any profile from the Map, but they can't link a membership proof to a specific profile entry. The MerkleTree proof reveals nothing about which identity was used. Two views of the same dataset, each useful in different contexts.
Choosing the right structure
| Need | Use |
|---|---|
| Store user balances, settings, or metadata | Map |
| O(1) lookups by key | Map |
| Full CRUD (create, read, update, delete) | Map |
| Iterate all entries in TypeScript | Map |
| Prove membership without revealing identity | MerkleTree |
| Large sets where full on-chain storage is expensive | MerkleTree |
| Allowlists, credential sets, nullifier sets | MerkleTree |
| Resilience to concurrent insertions | HistoricMerkleTree |
| Both readable data AND ZK membership proofs |
Map + MerkleTree
|
| Immutable configuration set at deployment |
sealed ledger field |
The most common mistake is using MerkleTree for data you actually need to read. It only stores the root, so you can't retrieve individual entries from the chain. The tree data lives off-chain with the contract operator.
Common pitfalls
1. lookup() on a missing key panics
lookup(key) doesn't return a Maybe — it panics at proof generation if the key doesn't exist. Always guard with member() first:
// Unsafe
const val = registry.lookup(disclose(key));
// Safe
assert(registry.member(disclose(key)), "Key does not exist");
const val = registry.lookup(disclose(key));
2. Forgetting disclose() on writes
Every write to a Map or MerkleTree from an exported circuit requires disclose() on the keys and values. The compiler error is:
potential witness-value disclosure must be declared but is not
This applies even to plain circuit parameters, not just witness data. The fix: wrap any value at the point it hits the ledger.
3. Opaque types can't be Map keys or MerkleTree leaves
Opaque<"string"> and Opaque<"Uint8Array"> are not hashable by the standard library. If your data is opaque, hash it first with persistentHash and use the resulting Bytes<32> as the key:
const keyHash = persistentHash<Opaque<"string">>(rawKey);
registry.insert(disclose(keyHash), disclose(value));
4. MerkleTree deletion doesn't work the way you expect
There's no .remove() on MerkleTree. The standard workaround is inserting a sentinel default value at the target index, which invalidates any existing proof for that slot:
// "Delete" leaf at known index by overwriting with default
allowlist.insertIndexDefault(disclose(index));
Track leaf indices off-chain if you need to delete. If you need true deletion semantics, consider using a Map<Bytes<32>, Boolean> with a tombstone pattern instead.
5. Marking a ledger-reading circuit as pure
pure circuits in Compact can't access ledger fields at all — not even for reads. The compiler catches it immediately:
circuit isRegistered is marked pure but is actually impure because it accesses ledger field registry
This is stricter than Solidity's view functions. If your circuit reads from a Map or MerkleTree ledger field, it must be a regular export circuit. Reserve pure for circuits that only do computation on their input parameters with no external state.
6. checkRoot() requires disclose() on the computed digest
checkRoot and checkRootInHistory are ledger operations. The compiler requires disclose() on any witness-derived value passed into a ledger call:
potential witness-value disclosure must be declared but is not:
ledger operation might disclose a hash of the witness value
The fix is one line:
// Fails
assert(allowlist.checkRoot(computed), "...");
// Correct
assert(allowlist.checkRoot(disclose(computed)), "...");
This looks counterintuitive — you're not "publishing" the computed root, you're just telling the compiler: "I know this value derived from a witness is passing into a ledger operation, and that's intentional." The actual membership proof remains private.
7. sealed export ledger is a parse error
The sealed modifier must be followed directly by ledger. No export between them:
// Parse error
sealed export ledger adminKey: Bytes<32>;
// Correct
sealed ledger adminKey: Bytes<32>;
Sealed fields are still part of on-chain state and readable regardless of whether they carry the export modifier.
8. Using MerkleTree for data you need to read back
The on-chain MerkleTree ledger type stores only the root. You cannot read individual leaves from the chain. If you need to enumerate members or look up entries, use Map. If you need both, combine them as shown in the hybrid example.
Compile and test
Create a project directory with the three contracts above. Compile each:
compact compile registry.compact managed/registry
compact compile allowlist.compact managed/allowlist
compact compile identity-registry.compact managed/identity-registry
Install test dependencies and run:
npm install --save-dev vitest @midnight-ntwrk/compact-runtime
npx vitest run
All three contracts in this article were compiled and verified against Compact compiler v0.31.0 via GitHub Actions. You can inspect the passing workflow run and download the compiled artifacts at: https://github.com/IamHarrie-Labs/compact-maps-merkle-guide
Resources
- Compact Language Reference — Map, MerkleTree, sealed modifier full spec
-
Standard Library Exports —
merkleTreePathRoot,MerkleTreePath,MerkleTreeDigest - Bulletin Board Tutorial — canonical reference for witness patterns
- Compact Release Notes — compiler changelog
All Compact examples in this article were compiled and verified against Compact compiler v0.31.0. Source: IamHarrie-Labs/compact-maps-merkle-guide
Top comments (0)