enabling infinite horizontal scaling without ledger bloat.
- Linear Proof Scaling: Depth-20 trees balance capacity (~1M members) with proof generation time. Each additional depth level doubles capacity but increases witness size and prover time linearly.
- Context-Bound Nullifiers: The
hash(secret || context) construction creates unique, single-use nullifiers per feature, eliminating cross-operation replay attacks while preserving anonymity.
- Sweet Spot: Depth-20 with Poseidon hashing provides optimal throughput for governance, airdrops, and feature gating without requiring circuit recompilation.
Core Solution
The architecture comprises a Compact on-chain verifier, a sparse Merkle tree generator, and a CLI-driven proof pipeline. State management, witness handling, and path verification are implemented as follows:
State & Witnesses
export ledger merkle_root: Bytes<32>;
export ledger admin_commitment: Bytes<32>;
export ledger used_nullifiers: Set<Bytes<32>>;
witness getSecret(): Bytes<32>;
witness getContext(): Bytes<32>;
witness getSiblings(): Vector<20, Bytes<32>>;
witness getPathIndices(): Vector<20, Boolean>;
witness getAdminSecret(): Bytes<32>;
Path Recomputation & Membership Verification
circuit hashLevelNode(is_right: Boolean, current: Bytes<32>, sibling: Bytes<32>): Bytes<32> {
if (is_right) {
return persistentHash<Vector<3, Bytes<32>>>([
pad(32, "zk-allowlist:node:v1"),
sibling,
current
]);
} else {
return persistentHash<Vector<3, Bytes<32>>>([
pad(32, "zk-allowlist:node:v1"),
current,
sibling
]);
}
}
circuit isMember(): (Bytes<32>, Bytes<32>) {
let secret = getSecret();
let context = getContext();
let leaf = poseidonHash(secret);
let computed_root = leaf;
let siblings = getSiblings();
let indices = getPathIndices();
for (i in 0..20) {
computed_root = hashLevelNode(indices[i], computed_root, siblings[i]);
}
assert(computed_root == merkle_root.read(), "Invalid membership proof");
let nullifier = persistentHash<Vector<2, Bytes<32>>>([secret, context]);
assert(not used_nullifiers.contains(nullifier), "Nullifier already used");
(computed_root, nullifier)
}
Admin Root Management
export circuit setRoot(new_root: Bytes<32>): [] {
let admin_secret = getAdminSecret();
let commitment = poseidonHash(admin_secret);
assert(commitment == admin_commitment.read(), "Not authorized");
merkle_root.write(disclose(new_root));
}
Sparse Merkle Tree Implementation (TypeScript)
export class MerkleTree {
readonly depth: number;
private leaves: HashHex[] = [];
private layers: Map<number, Map<number, HashHex>> = new Map();
private zeroHashes: HashHex[];
constructor(depth: number = 20) {
this.depth = depth;
this.zeroHashes = computeZeroHashes(depth);
for (let i = 0; i <= depth; i++) {
this.layers.set(i, new Map());
}
}
insertLeaf(leafHash: HashHex): number {
const leafIndex = this.leaves.length;
this.leaves.push(leafHash);
this.setNode(0, leafIndex, leafHash);
let currentIndex = leafIndex;
for (let level = 0; level < this.depth; level++) {
const parentIndex = Math.floor(currentIndex / 2);
const leftChild = this.getNode(level, parentIndex * 2);
const rightChild = this.getNode(level, parentIndex * 2 + 1);
const parentHash = hashNode(leftChild, rightChild);
this.setNode(level + 1, parentIndex, parentHash);
currentIndex = parentIndex;
}
return leafIndex;
}
}
Complete Deployment & Usage Flow
Step 1: Admin Sets Up the Contract
ADMIN_SECRET=$(openssl rand -hex 32)
ADMIN_COMMITMENT=$(echo -n $ADMIN_SECRET | poseidon-hash)
compact deploy --ledger admin_commitment=$ADMIN_COMMITMENT
Step 2: Add Members Off-Chain
midnight-allowlist add-member --secret "alice-secret-123"
ROOT=$(midnight-allowlist get-root)
Step 3: Push Root On-Chain
compact call setRoot --arg new_root=$ROOT --witness admin_secret=$ADMIN_SECRET
Step 4: Member Generates and Submits Proof
PROOF=$(midnight-allowlist generate-proof --secret "alice-secret-123" --context "voting-round-1")
compact call proveMembership --proof $PROOF
Pitfall Guide
- Zero Hash Inconsistency: Sparse trees rely on pre-computed zero hashes for empty branches. If the off-chain
computeZeroHashes function uses a different domain separator or hash order than the Compact circuit, path verification will deterministically fail. Always mirror the exact padding and hashing logic (pad(32, "zk-allowlist:node:v1")) in both environments.
- Context Binding Omission: Failing to bind the nullifier to a specific context string (
hash(secret || context)) allows malicious actors to replay a valid proof across multiple dApp features (e.g., using a voting proof to claim an airdrop). Always enforce distinct, immutable context identifiers per use-case.
- Depth vs. Proof Generation Trade-off: While increasing tree depth linearly expands capacity, it also increases witness vector size and prover computation time. Depth-20 is optimal for ~1M members; exceeding depth-24 typically requires circuit optimization or batched proof aggregation to maintain sub-second UX.
- Path Index/Direction Mismatch: The
is_right boolean in hashLevelNode dictates sibling placement. Off-chain proof generators must output indices in the exact same left/right ordering expected by the on-chain verifier. A single flipped index breaks the entire path recomputation.
- Nullifier State Synchronization: The
used_nullifiers set must be updated atomically upon successful verification. If the dApp frontend or indexer fails to track consumed nullifiers, users may inadvertently attempt duplicate submissions, triggering assertion failures and wasting transaction fees.
- Admin Secret Compromise & Commitment Drift: The
admin_commitment is a one-way hash. If the admin secret is lost, root updates become permanently impossible. Conversely, deploying with a mismatched commitment will cause setRoot assertions to fail. Maintain secure secret management and verify commitment derivation before deployment.
Deliverables
- π Architecture Blueprint: A complete system diagram mapping off-chain TypeScript tooling, sparse Merkle generation, witness serialization, and on-chain Compact verification circuits. Includes data flow for root updates, proof generation, and nullifier lifecycle management.
- β
Implementation Checklist: Step-by-step validation guide covering environment setup, Poseidon hash alignment, depth-20 capacity planning, context string standardization, nullifier tracking integration, and production deployment verification.
- βοΈ Configuration Templates: Ready-to-use
compact deployment manifests, TypeScript Merkle tree initialization configs, CLI wrapper scripts for batch member addition, and frontend proof submission hooks with error handling for assertion failures and nullifier collisions.