Most membership systems have the same tradeoff: to prove you belong, you have to say who you are. A whitelist for a token sale reveals your address. A voter roll reveals your vote. A corporate access system reveals which rooms you entered and when.
Zero-knowledge proofs break this tradeoff. This tutorial builds a working allowlist contract in Midnight's Compact language where a member can prove they belong to an approved set without revealing which member they are. The same circuit handles allowlists, voter rolls, and gated access β they are all the same problem underneath.
What we're building
There are three roles in this system.
| Role | What they do | What they know publicly |
|---|---|---|
| Admin | Registers approved members by publishing leaf hashes | The set of leaf hashes |
| Member | Proves membership in zero knowledge | A nullifier (one-time) or nothing (reusable) |
| Verifier | Confirms the proof is valid | That the proof passed; nothing about the member |
The tree structure is an HistoricMerkleTree<20, Bytes<32>>. Depth 20 means the tree holds up to 1,048,576 members. Each leaf is a hash of three values: a domain tag, a secret, and a nonce. The admin inserts leaves; members prove their leaf is in the tree.
The leaf, the secret, and the nonce
Every member has a secret β a random 32-byte value they generate locally and never share. To register, the admin computes a leaf hash off-chain:
leaf = persistentHash(["member:leaf:v1", secret, nonce])
Only the leaf goes on-chain. The secret and nonce stay with the member. When the member later wants to prove membership, they supply their secret and nonce as private witnesses. The circuit re-derives the leaf and verifies it is in the tree. If they don't know the secret, they cannot produce a valid proof.
The nonce is there to allow multiple registrations with the same secret if needed, and to prevent a brute-force attack on the secret (even short secrets become unpredictable with a random nonce).
Why HistoricMerkleTree matters here
The most important design choice in this contract is using HistoricMerkleTree<20, Bytes<32>> rather than storing the root as a plain Bytes<32> ledger field.
Here is what breaks with a plain root field. Alice generates a Merkle path proving her leaf is in the tree at root R1. The admin then registers Bob. The tree root becomes R2. Alice's path still leads to R1 β but R1 is no longer on-chain. Her proof fails, not because her membership changed, but because the root moved underneath her. Every time any new member is registered, every existing member's path becomes stale. They all have to regenerate their paths from scratch.
HistoricMerkleTree keeps every root the tree has ever held. Its checkRoot method accepts any historic root, not just the current one. So when Alice generates a path to R1, that path stays valid through R2, R3, and every root after. This is what "admin root management" actually requires in a live registry.
The contract
pragma language_version >= 0.23;
import CompactStandardLibrary;
export ledger memberTree: HistoricMerkleTree<20, Bytes<32>>;
// Compact has no Set<T> type. Map<Bytes<32>, Boolean> is the standard pattern:
// a key being present means the nullifier has been spent.
export ledger usedNullifiers: Map<Bytes<32>, Boolean>;
export ledger memberCount: Counter;
witness getMemberSecret(): Bytes<32>;
witness getMemberNonce(): Bytes<32>;
witness getMemberPath(leaf: Bytes<32>): MerkleTreePath<20, Bytes<32>>;
Three circuits follow.
registerMember
export circuit registerMember(memberLeaf: Bytes<32>): [] {
memberTree.insert(disclose(memberLeaf));
memberCount.increment(1);
}
The admin calls this once per approved member. Only the leaf hash goes on-chain β no identity, no attribute, no secret. The HistoricMerkleTree handles the root automatically.
proveMembership
This is the one-time proof circuit. It spends a nullifier, so the same proof cannot be submitted twice. Use it for single-use allowlists and anonymous voting.
export circuit proveMembership(memberLeaf: Bytes<32>): [] {
const secret = getMemberSecret();
const nonce = getMemberNonce();
// Preimage binding: caller must know the secret behind this leaf.
const recomputed = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "member:leaf:v1"),
secret,
nonce
]);
assert(recomputed == disclose(memberLeaf), "Secret does not match leaf commitment");
// Path verification against any historic root.
const path = getMemberPath(disclose(memberLeaf));
const root = merkleTreePathRoot<20, Bytes<32>>(path);
assert(memberTree.checkRoot(disclose(root)), "Leaf not in member tree");
// Domain-separated nullifier.
const nullifier = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "member:nullifier:v1"),
secret,
nonce
]);
assert(!usedNullifiers.member(disclose(nullifier)), "Membership proof already consumed");
usedNullifiers.insert(disclose(nullifier), disclose(true));
}
The preimage binding assertion is the part most implementations skip. Without it, the circuit has no ZK constraint tying the private witnesses to the public leaf parameter. An attacker who observes any valid leaf in the on-chain tree can supply that leaf as the circuit parameter, provide a valid Merkle path for it (computable from public state), and supply any secret and nonce they control to produce a nullifier. The circuit accepts because nothing checks that their (secret, nonce) pair actually hashes to the claimed leaf. Adding the preimage binding assertion closes this hole completely.
Notice the domain tags. The leaf uses "member:leaf:v1" and the nullifier uses "member:nullifier:v1". They are deliberately different. If both used the same tag, the leaf hash and the nullifier hash would produce identical output for any (secret, nonce). The leaf value in the tree would be the same as the nullifier β which means the first proof would insert that leaf value into usedNullifiers, and the second proof would fail with "already consumed" on what looks like a replay check, but is actually a domain collision.
verifyMembership
The reusable circuit. It proves membership without spending a nullifier, so the same (secret, nonce) can be submitted any number of times.
export circuit verifyMembership(memberLeaf: Bytes<32>): [] {
const secret = getMemberSecret();
const nonce = getMemberNonce();
const recomputed = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "member:leaf:v1"),
secret,
nonce
]);
assert(recomputed == disclose(memberLeaf), "Secret does not match leaf commitment");
const path = getMemberPath(disclose(memberLeaf));
const root = merkleTreePathRoot<20, Bytes<32>>(path);
assert(memberTree.checkRoot(disclose(root)), "Leaf not in member tree");
// No nullifier β reusable by design.
}
Three use cases from the same circuits
Allowlist (single-use access)
Use proveMembership. The admin registers approved wallet addresses or identities off-chain by computing their leaves and calling registerMember. Each address gets one proof. When they use it, the nullifier is spent. They cannot reuse the proof.
If you need to support a rotating allowlist, add a second leaf for the same member with a new nonce. The old nullifier does not block the new one β they are derived from different nonces.
Anonymous voter roll
Use proveMembership with a per-election scoping approach. If you want the same member to be able to vote in different elections without their votes being linked, give each election a distinct nonce. The admin registers leaf_election1 = persistentHash(["member:leaf:v1", secret, nonce1]) and leaf_election2 = persistentHash(["member:leaf:v1", secret, nonce2]) separately. The resulting nullifiers are independent β spending one does not affect the other.
An alternative is to include an election identifier in the leaf domain tag itself, e.g. "member:leaf:election-2025-v1". Either approach works. The point is that the domain tag and nonce are your two tools for creating unlinkable, scoped nullifiers.
Gated access (continuous authentication)
Use verifyMembership. A member proves they belong to the set on every request. No nullifier is spent. The same proof can be submitted in every session. The on-chain check is stateless β the circuit verifies the path and returns; nothing is written to the ledger.
Off-chain witnesses in TypeScript
The three witness functions are implemented in TypeScript and run on the member's device. Here is a minimal structure:
import { MerkleTree, persistentHash, pad } from "@midnight-ntwrk/compact-runtime";
// The member's private state β never sent anywhere.
const memberSecret: Uint8Array = crypto.randomBytes(32);
const memberNonce: Uint8Array = crypto.randomBytes(32);
// Compute the leaf off-chain before calling registerMember.
function computeLeaf(secret: Uint8Array, nonce: Uint8Array): Uint8Array {
return persistentHash([
pad(32, "member:leaf:v1"),
secret,
nonce,
]);
}
// Build and maintain a local copy of the tree.
class MemberTreeWitness {
private tree = new MerkleTree(20);
insert(leaf: Uint8Array) {
this.tree.insert(leaf);
}
// The path witness β called by the Compact circuit.
getMemberPath(leaf: Uint8Array): MerkleTreePath {
return this.tree.getPath(leaf);
}
}
// Assemble the witness object for the prover.
const witnesses = {
getMemberSecret: () => memberSecret,
getMemberNonce: () => memberNonce,
getMemberPath: (leaf: Uint8Array) => treeWitness.getMemberPath(leaf),
};
The admin maintains their own tree locally and calls registerMember(leaf) on-chain for each new member. Members keep a local copy of the tree in sync with on-chain insertions β they only need the path for their own leaf, so they can also request it from a trusted indexer rather than tracking the full tree themselves.
Things that go wrong
Set<T> does not exist in Compact
Compact has no Set<T> type. The correct pattern for a nullifier store is Map<Bytes<32>, Boolean>. When checking membership, use .member(key). When recording a nullifier, use .insert(key, true). Writing Set<Bytes<32>> will fail to compile.
Missing preimage binding
// β secret and nonce are loaded but never checked against memberLeaf
export circuit dangerousProve(memberLeaf: Bytes<32>): [] {
const secret = getSecret();
const nonce = getNonce();
// No assertion linking secret+nonce to memberLeaf
const path = getPath(disclose(memberLeaf));
const root = merkleTreePathRoot<20, Bytes<32>>(path);
assert(memberTree.checkRoot(disclose(root)), "Not in tree");
// ...
}
// β
assert the preimage before touching the path
const recomputed = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "member:leaf:v1"), secret, nonce
]);
assert(recomputed == disclose(memberLeaf), "Secret does not match leaf");
Without preimage binding, the ZK proof has no constraint tying the private witnesses to the public leaf parameter. Any caller who can see the on-chain tree can pass any valid leaf and produce a nullifier with witnesses they control.
Same domain for leaf and nullifier
// β both hash with "member:leaf:v1" β nullifier == leaf for all inputs
const leaf = persistentHash([pad(32, "member:leaf:v1"), secret, nonce]);
const nullifier = persistentHash([pad(32, "member:leaf:v1"), secret, nonce]); // identical
// β
distinct domains
const leaf = persistentHash([pad(32, "member:leaf:v1"), secret, nonce]);
const nullifier = persistentHash([pad(32, "member:nullifier:v1"), secret, nonce]);
When domains match, the leaf and the nullifier are the same value. After the first use, the leaf value appears in usedNullifiers, which produces confusing behaviour: the circuit reports "already consumed" but the member has never actually submitted a proof before.
Domain tag without pad(32, ...)
// β raw string β length varies; different strings could share a prefix
persistentHash<Vector<3, Bytes<32>>>([
"member:leaf:v1",
secret,
nonce
]);
// β
pad to fixed length before hashing
persistentHash<Vector<3, Bytes<32>>>([
pad(32, "member:leaf:v1"),
secret,
nonce
]);
pad(32, ...) zero-pads the string to exactly 32 bytes, giving every domain tag a fixed, unambiguous encoding. Without it, a tag like "ab" and a tag like "a" followed by "b" in the next slot could produce the same hash output.
lookup without a member guard
// β panics if key is absent
const value = usedNullifiers.lookup(nullifier);
// β
check membership first
assert(!usedNullifiers.member(disclose(nullifier)), "Already used");
usedNullifiers.insert(disclose(nullifier), disclose(true));
lookup on a map returns the value at a key and panics if the key is absent. For nullifier maps the pattern is always .member(key) to check, then .insert(key, value) to write.
Missing disclose() on ledger operations
// β compiler error β private value cannot be written to ledger directly
memberTree.insert(memberLeaf);
// β
disclose brings the value into the public circuit context
memberTree.insert(disclose(memberLeaf));
Any value that passes into a ledger operation β .insert(), .write(), checkRoot() β must be wrapped in disclose(). This is how Compact enforces the public/private boundary. The compiler will catch it, but it's a common early stumble.
Predictable nullifier derived from public data
// β nullifier = H(domain, memberLeaf) β memberLeaf is a public circuit parameter
const nullifier = persistentHash<Vector<2, Bytes<32>>>([
pad(32, "member:nullifier:v1"),
memberLeaf // β visible in the mempool
]);
// β
bind the nullifier to private (secret, nonce)
const nullifier = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "member:nullifier:v1"),
secret,
nonce
]);
If the nullifier is derived from the public memberLeaf parameter, any observer watching the mempool can compute it before the transaction is confirmed. They submit a transaction that inserts that value into the nullifier map. If it lands first, the member's proof fails permanently β their slot is burned and there is no recovery path. The nullifier must be bound to something the observer cannot see, which is the private (secret, nonce) pair.
Using MerkleTree instead of HistoricMerkleTree
// β only validates the current root β stale on every new insertion
export ledger memberTree: MerkleTree<20, Bytes<32>>;
// β
validates any historic root
export ledger memberTree: HistoricMerkleTree<20, Bytes<32>>;
MerkleTree.checkRoot validates only the current root. In a live registry where the admin adds members regularly, any proof generated before the latest insertion becomes invalid. HistoricMerkleTree.checkRoot accepts any root the tree has ever held, so old proofs remain valid indefinitely.
Domain tag reference
| Usage | Tag |
|---|---|
| Leaf commitment | member:leaf:v1 |
| One-time nullifier | member:nullifier:v1 |
When adding additional proof types to the same contract, assign each type its own leaf domain and its own nullifier domain. Sharing a leaf domain across types means a proof intended for one context will be accepted by another. Sharing a nullifier domain means spending a proof in one context can block another context.
Repository and CI
Both contracts compile against the latest Compact compiler. The CI workflow installs the toolchain and compiles zk_allowlist.compact and allowlist_patterns.compact separately on every push.
Repository: IamHarrie-Labs/compact-zk-allowlist
United States
NORTH AMERICA
Related News
Corporativismo fascista e Taleb
7h ago
How I Built a Full Stack Laundry Management System Using Angular & Node.js
7h ago
SvGrid: a Svelte 5 native data grid (MIT core, headless + render component, MCP-ready)
10h ago
Turing's Last Cipher: The Lost Archive
10h ago
Why Most Custom AI Skills Never Run (And the One Fix)
10h ago