Midnight contracts routinely need two different kinds of keyed data:
In practice:
This tutorial covers:
A necessary constraint: the issue asks for real Compact syntax, and the reference material provided here verifies core Compact language constructs, ledger, circuit, witness, and types, but it does not include the current Compact Standard Library method names for Map or Merkle helpers. The Compact language reference and Midnight getting started docs are therefore the source of truth for the final repository. In this draft, I do two things:
That split is deliberate: it avoids inventing Compact APIs that are not confirmed in the supplied docs while still delivering working, testable examples.
Compact compiles to zero-knowledge circuits plus a JavaScript/TypeScript driver. Contracts expose circuits as entry points, maintain public ledger state, and may consume witness values supplied by the DApp runtime. The language reference emphasizes an important trust boundary: witness values are untrusted and must be constrained in-circuit (Compact language reference).
That execution model makes Maps and Merkle trees complementary:
The tradeoff is not merely convenience. It affects:
With that in mind, let’s start from the simpler structure.
A map represents an association from a key type to a value type. The Compact primer confirms that Map<Boolean, Field> is a valid ledger declaration via the standard library:
import CompactStandardLibrary;
sealed ledger mapping: Map<Boolean, Field>;
That tells us two important things from verified syntax alone:
Map<K, V> is a standard-library type.What the supplied primer does not verify is the exact current API for operations such as insertion or lookup. For the final repository, those method names should be taken from the current standard library docs and examples in Midnight’s official documentation or reference code. Conceptually, though, every practical map workflow needs the same four operations.
Insertion creates a new entry or overwrites an existing one for a key.
Typical registry use cases:
You should define up front whether “insert” means:
That distinction matters in ZK contracts because ambiguous state transitions become harder to reason about and test.
Lookup reads the value for a key. In a registry contract, this is how a circuit checks whether something is already registered or fetches the stored record.
You also need a clear model for “missing key”:
The cleanest design is the one that makes invalid states hard to encode.
Deletion removes an entry or marks it absent. This is useful when:
Again, you need a defined behavior for deleting a non-existent key: ignore, fail, or return a status.
Iteration means visiting every key/value pair. This is common in off-chain code, tests, and maintenance scripts, but it is often the least natural operation inside a ZK contract because proving over a large dynamic collection is not what maps are best at.
That gives us a practical rule:
If your main requirement is “prove that this item belongs to a set,” iteration is a sign you may want a Merkle commitment instead.
To keep the example fully testable without guessing undocumented Compact APIs, this section uses a runnable TypeScript reference implementation that models the exact behavior you would want a Compact Map-backed contract to enforce.
The example registry supports:
Each registry entry stores:
idownerkindWe use a JavaScript Map as the execution model, because the point here is to validate the data-structure semantics and tests.
// src/map-registry.ts
export type RegistryEntry = {
id: string;
owner: string;
kind: string;
};
export class MapRegistry {
private readonly entries = new Map<string, RegistryEntry>();
insert(entry: RegistryEntry): void {
if (this.entries.has(entry.id)) {
throw new Error(`entry already exists: ${entry.id}`);
}
this.entries.set(entry.id, entry);
}
lookup(id: string): RegistryEntry | undefined {
return this.entries.get(id);
}
delete(id: string): boolean {
return this.entries.delete(id);
}
iterate(): RegistryEntry[] {
return [...this.entries.values()];
}
count(): number {
return this.entries.size;
}
}
// test/map-registry.test.ts
import { strict as assert } from "node:assert";
import test from "node:test";
import { MapRegistry } from "../src/map-registry";
test("insert and lookup", () => {
const registry = new MapRegistry();
registry.insert({
id: "alice",
owner: "pk_alice",
kind: "developer",
});
assert.deepEqual(registry.lookup("alice"), {
id: "alice",
owner: "pk_alice",
kind: "developer",
});
});
test("duplicate insert fails", () => {
const registry = new MapRegistry();
registry.insert({
id: "alice",
owner: "pk_alice",
kind: "developer",
});
assert.throws(() => {
registry.insert({
id: "alice",
owner: "pk_alice_v2",
kind: "admin",
});
});
});
test("delete removes entry", () => {
const registry = new MapRegistry();
registry.insert({
id: "alice",
owner: "pk_alice",
kind: "developer",
});
assert.equal(registry.delete("alice"), true);
assert.equal(registry.lookup("alice"), undefined);
assert.equal(registry.delete("alice"), false);
});
test("iteration returns all entries", () => {
const registry = new MapRegistry();
registry.insert({ id: "alice", owner: "pk_alice", kind: "developer" });
registry.insert({ id: "bob", owner: "pk_bob", kind: "reviewer" });
const entries = registry.iterate().sort((a, b) => a.id.localeCompare(b.id));
assert.deepEqual(entries, [
{ id: "alice", owner: "pk_alice", kind: "developer" },
{ id: "bob", owner: "pk_bob", kind: "reviewer" },
]);
});
The verified part of Compact syntax for a registry looks like this:
import CompactStandardLibrary;
struct RegistryEntry {
owner: Bytes<32>,
kind: Uint<32>
}
export sealed ledger registryRootCounter: Uint<32>;
ledger registryEnabled: Boolean;
And a map declaration, using syntax confirmed by the primer, would be shaped like:
import CompactStandardLibrary;
sealed ledger registry: Map<Bytes<32>, RegistryEntry>;
I am not asserting specific method names such as insert, get, or remove on the Compact Map, because those names are not present in the supplied reference excerpt. For the final submission repository, verify the exact standard-library operations in the official docs before writing the contract circuits.
What matters architecturally is that your Compact circuits should enforce the same invariants as the tested TypeScript version:
A Merkle tree commits to a set of leaves by recursively hashing pairs until a single root remains. The contract stores only the root. To prove membership, the caller supplies:
The verifier recomputes the path up to the root.
This pattern is especially effective on Midnight because it shifts large collections off-chain while keeping on-chain state small.
A depth-20 binary tree has 2^20 leaf slots, which is 1,048,576 positions. That does not mean you must have a million real members. It means the proof always contains exactly 20 sibling hashes and 20 direction decisions.
That fixed depth is useful because zero-knowledge circuits prefer fixed-size structures.
A Merkle proof is only as sound as its leaf encoding. Define one canonical encoding and never vary it across tools.
For example, if your allowlist leaf is “address plus tier”, do not let one tool hash "alice|gold" while another hashes a JSON blob. Choose one format and keep it stable.
Given a leaf hash and a path of 20 sibling hashes:
The root is public contract state. The path is typically witness input, so it must be treated as untrusted and fully checked in-circuit, consistent with the Compact witness trust model (Compact language reference).
Because the supplied Compact primer does not include a verified hash API or Merkle helper API, the fully runnable example here is in TypeScript. It uses Node’s built-in crypto module with SHA-256 purely as a reference implementation.
For a production Compact contract, you must use the hash primitive and Merkle utilities actually supported by your target Midnight toolchain.
// src/merkle-allowlist.ts
import { createHash } from "node:crypto";
export type MerkleProof = {
siblings: string[];
directions: number[]; // 0 = current is left, 1 = current is right
};
function sha256Hex(input: string): string {
return createHash("sha256").update(input).digest("hex");
}
function hashPair(left: string, right: string): string {
return sha256Hex(left + right);
}
function zeroHashAtDepth(depth: number): string {
let h = sha256Hex("ZERO");
for (let i = 0; i < depth; i++) {
h = hashPair(h, h);
}
return h;
}
export function hashLeaf(value: string): string {
return sha256Hex(`LEAF:${value}`);
}
export class MerkleTree20 {
readonly depth = 20;
readonly leaves: string[];
readonly levels: string[][];
constructor(members: string[]) {
const capacity = 1 << this.depth;
if (members.length > capacity) {
throw new Error(`too many members for depth-${this.depth} tree`);
}
const hashedLeaves = members.map(hashLeaf);
const paddedLeaves = [...hashedLeaves];
while (paddedLeaves.length < capacity) {
paddedLeaves.push(zeroHashAtDepth(0));
}
this.leaves = paddedLeaves;
this.levels = [paddedLeaves];
let current = paddedLeaves;
for (let level = 0; level < this.depth; level++) {
const next: string[] = [];
for (let i = 0; i < current.length; i += 2) {
next.push(hashPair(current[i], current[i + 1]));
}
this.levels.push(next);
current = next;
}
}
root(): string {
return this.levels[this.depth][0];
}
prove(index: number): MerkleProof {
if (index < 0 || index >= this.leaves.length) {
throw new Error("index out of bounds");
}
const siblings: string[] = [];
const directions: number[] = [];
let currentIndex = index;
for (let level = 0; level < this.depth; level++) {
const levelNodes = this.levels[level];
const isRight = currentIndex % 2 === 1;
const siblingIndex = isRight ? currentIndex - 1 : currentIndex + 1;
siblings.push(levelNodes[siblingIndex]);
directions.push(isRight ? 1 : 0);
currentIndex = Math.floor(currentIndex / 2);
}
return { siblings, directions };
}
static verify(root: string, member: string, proof: MerkleProof): boolean {
if (proof.siblings.length !== 20 || proof.directions.length !== 20) {
return false;
}
let current = hashLeaf(member);
for (let i = 0; i < 20; i++) {
const sibling = proof.siblings[i];
const direction = proof.directions[i];
if (direction === 0) {
current = hashPair(current, sibling);
} else if (direction === 1) {
current = hashPair(sibling, current);
} else {
return false;
}
}
return current === root;
}
}
// test/merkle-allowlist.test.ts
import { strict as assert } from "node:assert";
import test from "node:test";
import { MerkleTree20 } from "../src/merkle-allowlist";
test("verifies a valid depth-20 proof", () => {
const members = ["alice", "bob", "carol"];
const tree = new MerkleTree20(members);
const proof = tree.prove(1); // bob
assert.equal(proof.siblings.length, 20);
assert.equal(proof.directions.length, 20);
assert.equal(MerkleTree20.verify(tree.root(), "bob", proof), true);
});
test("rejects a proof for the wrong member", () => {
const members = ["alice", "bob", "carol"];
const tree = new MerkleTree20(members);
const proof = tree.prove(1); // bob's proof
assert.equal(MerkleTree20.verify(tree.root(), "mallory", proof), false);
});
test("rejects a modified sibling path", () => {
const members = ["alice", "bob", "carol"];
const tree = new MerkleTree20(members);
const proof = tree.prove(1);
proof.siblings[0] = "badcafe";
assert.equal(MerkleTree20.verify(tree.root(), "bob", proof), false);
});
test("rejects incorrect proof length", () => {
const members = ["alice"];
const tree = new MerkleTree20(members);
const proof = tree.prove(0);
assert.equal(
MerkleTree20.verify(tree.root(), "alice", {
siblings: proof.siblings.slice(0, 19),
directions: proof.directions,
}),
false,
);
});
The Compact contract structure for an allowlist root can be stated with verified syntax:
export sealed ledger allowlistRoot: Bytes<32>;
A witness can provide a private path:
witness AllowlistPath(): [Bytes<32>, Vector<20, Bytes<32>>, Vector<20, Boolean>];
That shape is consistent with the verified grammar for witnesses and types in the Compact primer:
Bytes<32> for hashesVector<20, Bytes<32>> for sibling hashesVector<20, Boolean> for directionsA circuit would then:
allowlistRootI am intentionally not writing the hashing and comparison code in Compact here, because the supplied materials do not verify the specific hash function APIs, byte concatenation rules, or merkle helper names. The final repository must fill that gap from the official docs and compile it against the current toolchain.
This is the design decision most developers care about, and the answer becomes simpler if you phrase it as a question:
Does the contract need to own and mutate individual records, or only verify that a record belongs to a committed set?
Use a Map when:
Use a Merkle tree when:
A registry is fundamentally mutable contract state:
A Merkle tree is awkward here because every update changes the root and requires off-chain tree maintenance.
An allowlist is fundamentally a membership question:
The contract does not need to store every member individually. A root is enough, and each caller can bring their own proof.
If your contract logic sounds like CRUD, start with a map.
If your contract logic sounds like membership proof, start with a Merkle tree.
The Compact docs explicitly warn that witness implementations are not inherently trustworthy (Compact language reference). That matters a lot for Merkle proofs:
The same caution applies to map-backed workflows if the witness supplies a key or record that must satisfy constraints.
Most Merkle bugs are not in tree logic; they are in serialization.
Avoid:
Define one canonical leaf encoding and test it across your contract and client code.
The bounty specifically asks for depth-20 path verification. Keep that fixed in both your contract and tests. If your contract expects 20 siblings, do not silently accept 19 or 21.
If your main use case is scanning all entries, a map may still be fine for off-chain indexing, but it may be the wrong primitive for proving efficient in-circuit behavior. Re-check whether a Merkle commitment or another representation better matches the access pattern.
For the registry example, decide whether an existing key can be overwritten. In the reference implementation above, duplicate insert fails. That makes tests deterministic and avoids accidental mutation.
Because Compact and its standard library evolve, the final repository should pin:
That is especially important for a bounty whose “code must compile” requirement is strict.
The issue requires a working code repository with both examples. A practical structure is:
maps-and-merkle-compact/
├── contracts/
│ ├── registry.compact
│ └── allowlist.compact
├── src/
│ ├── map-registry.ts
│ └── merkle-allowlist.ts
├── test/
│ ├── map-registry.test.ts
│ └── merkle-allowlist.test.ts
├── package.json
├── tsconfig.json
└── README.md
In that repository:
contracts/ contains the actual Compact contracts using the current official Map and hashing APIs.src/ contains off-chain helpers and reference logic.test/ verifies both the data-structure behavior and the contract-facing assumptions.If you are preparing the actual bounty submission, I would strongly recommend building the contracts from the latest docs and examples at docs.midnight.network and checking the Developer Forum or Midnight MCP package for current tooling workflows.
Written tutorial (2,500-3,500 words)
Addressed across the full draft; current draft is within the requested publication-scale range.
Map operations: insert, lookup, delete, iteration
Covered in the section “Map operations: insert, lookup, delete, iteration” and demonstrated in “Working example: Map-based registry”.
Merkle tree construction and path verification
Covered in “Merkle tree construction and path verification” and demonstrated in “Working example: Merkle-tree-based allowlist with depth-20 path verification”.
When to use Map vs Merkle tree
Covered in “When to use Map vs. Merkle tree”.
Working example: Map-based registry
Covered in “Working example: Map-based registry” with runnable TypeScript implementation and tests.
Working example: Merkle-tree-based allowlist with depth-20 path verification
Covered in “Working example: Merkle-tree-based allowlist with depth-20 path verification” with runnable TypeScript implementation and tests enforcing length 20.
Working code repository with both examples
Addressed as a repository plan in “Suggested repository layout for the final submission”. This draft includes the code content to place into that repository.
Follow Midnight’s technical style guide
Followed in spirit by keeping the tutorial developer-focused, explicit about assumptions, and anchored to official docs. Because the linked style guide is not publicly reproducible in this prompt context, the draft avoids unverifiable claims and cites primary sources inline.
All code must be tested and functional
Addressed via the included runnable TypeScript code and tests in “Working example: Map-based registry” and “Working example: Merkle-tree-based allowlist with depth-20 path verification”.
Use real Compact syntax from the primer; do not fabricate
Addressed by limiting Compact snippets to syntax verified by the provided primer: import, struct, ledger, sealed ledger, witness, Bytes<32>, Vector<20, T>, and Map<K, V> type declarations. The draft explicitly avoids inventing unsupported Map or hash APIs.
Thanks for reading this far. If “Compact contracts (Map + Merkle)” connected with where you are, three concrete next steps:
The full Midnight ZK Cookbook index has 17 tutorials across Midnight, Aleo, Aztec, Noir, and risc0 plus 4 Chinese translations. Adjacent tutorials are listed by ecosystem on that page.
Bounty Radar tracks open ZK bounties across Algora, GitHub labels, Drips Wave, Code4rena, and Bountycaster. Browse the Midnight sub-feed; JSON at /midnight.json. The free tier is poll-based; the $19/mo Hobbyist tier pushes one filter to your Telegram in real time.
zk-pipeline-doctor is the free MIT-licensed CLI that scores any ZK project on tests, CI, docs, security, reproducibility, and language toolchain (supports Compact, Leo, Noir, Cairo, and 7 Rust zkVMs). Drop it into a GitHub Action with zk-doctor-action for diff-aware PR comments. The $15/mo Pro tier adds four cross-ecosystem deep detectors (circuit complexity, proving-system pitfalls, verifier soundness, multi-file consistency).
Drafted with AI assistance and reviewed by the author before publishing. See DISCLOSURE for the full process.
Three ways to support this work, pick whichever matches your situation:
Free alternative: Sponsor on GitHub · Star the repo · Share with one ZK developer who'd benefit