DEV Community

Cover image for Working with Maps and Merkle Trees in Compact
Harrie
Harrie

Posted on

Working with Maps and Merkle Trees in Compact

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>>;
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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"
    );
}
Enter fullscreen mode Exit fullscreen mode

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"
    );
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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"
    );
}
Enter fullscreen mode Exit fullscreen mode

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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];
  },
};
Enter fullscreen mode Exit fullscreen mode

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"
    );
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The fix is one line:

// Fails
assert(allowlist.checkRoot(computed), "...");

// Correct
assert(allowlist.checkRoot(disclose(computed)), "...");
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Install test dependencies and run:

npm install --save-dev vitest @midnight-ntwrk/compact-runtime
npx vitest run
Enter fullscreen mode Exit fullscreen mode

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


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)