A financial platform needs to verify that a user is over 21, based in the US, and holds a developer certification before granting access. The user has all three credentials. None of them want to publish raw personal data on-chain, and the platform shouldn't have to store or process any of it.
That's the compliance attestation problem. A trusted authority certifies properties about users. Users selectively prove those properties to third parties. No raw data ever hits the public ledger.
This tutorial builds that system end to end using Compact's HistoricMerkleTree, domain-separated hashing with persistentHash, and per-property nullifier maps. All contracts compile against the latest Compact compiler. Verified CI run: IamHarrie-Labs/compact-compliance-attestation
Prerequisites: Midnight toolchain installed, basic familiarity with Compact circuits and ledger declarations.
The three roles
Compliance attestation involves three participants:
| Role | Responsibility |
|---|---|
| Authority | Certifies properties about users; inserts commitment leaves into the tree |
| User | Holds private credentials; generates ZK proofs for specific properties |
| Verifier | Calls the smart contract circuit to confirm a user holds a valid proof |
The authority never reveals the raw data (exact age, home address, certificate number). The user never reveals which credential they're proving or how many they hold. The verifier gets a binary result: this user has (or hasn't) the required property.
Why HistoricMerkleTree
The attestation tree is a live registry. Authorities add new user commitments regularly. In a standard MerkleTree<n, T>, every insertion changes the root. A user who generates a Merkle proof at time T1 will find that proof invalid by time T2 if any insertion happened in between — not because their credential is wrong, but because the tree moved.
HistoricMerkleTree<n, T> keeps a record of every root the tree has ever held. Its checkRoot method accepts any historic root, not just the current one. A proof generated before ten subsequent insertions still passes. This is the correct choice for any registry that sees ongoing updates.
// Standard MerkleTree — proofs break on concurrent inserts
export ledger unstableTree: MerkleTree<10, Bytes<32>>;
// HistoricMerkleTree — proofs remain valid across all future insertions
export ledger commitmentTree: HistoricMerkleTree<10, Bytes<32>>;
The API is identical. Both use insert(leaf) and checkRoot(root). The difference is entirely in what checkRoot validates.
The contract
Data structures
pragma language_version >= 0.23;
import CompactStandardLibrary;
export ledger commitmentTree: HistoricMerkleTree<10, Bytes<32>>;
export ledger ageNullifiers: Map<Bytes<32>, Boolean>;
export ledger residencyNullifiers: Map<Bytes<32>, Boolean>;
export ledger certNullifiers: Map<Bytes<32>, Boolean>;
export ledger attestationCount: Counter;
witness getSecret(): Bytes<32>;
witness getNonce(): Bytes<32>;
witness findCommitmentPath(commitment: Bytes<32>): MerkleTreePath<10, Bytes<32>>;
Four things to note here.
HistoricMerkleTree<10, Bytes<32>> — depth 10 supports 1,024 commitment leaves. Adjust the depth for larger registries (depth 20 supports over a million). The leaf type is Bytes<32>: the output of persistentHash, which accepts Vector<n, Bytes<32>>.
Three separate nullifier maps — each property gets its own Map<Bytes<32>, Boolean>. This is not optional. A single shared map would mean that spending an age nullifier could interfere with a residency proof if domain separation ever failed. Separate maps enforce per-property replay prevention at the storage layer.
Map<Bytes<32>, Boolean> as a set — Compact has no Set type. The idiomatic replacement is a map from key to Boolean. Call member(key) to check existence; insert(key, true) to mark a key as seen.
The findCommitmentPath witness takes the public commitment as a parameter. The off-chain implementation uses this to locate the correct Merkle path. Passing the commitment explicitly ensures the path is for the right leaf — a critical security property covered in the pitfalls section.
Authority registration
export circuit registerAttestation(commitment: Bytes<32>): [] {
commitmentTree.insert(disclose(commitment));
attestationCount.increment(1);
}
The authority computes the commitment entirely off-chain:
// Off-chain: authority computes commitment before calling registerAttestation
const commitment = persistentHash([
pad(32, "attest:age-21:v1"), // domain tag encodes property + version
userSecret, // user's private credential secret
userNonce // unique nonce per attestation
]);
await contract.registerAttestation(commitment);
Raw property values — the user's actual age, address, certification ID — are never passed to the contract. Only the hash reaches the chain.
Selective proof circuits
Each of the three circuits follows the same four-step pattern: derive commitment from private inputs, verify preimage knowledge, check tree membership, spend nullifier. The domain tags are what make the three circuits independent.
export circuit proveAgeOver21(commitment: Bytes<32>): [] {
const secret = getSecret();
const nonce = getNonce();
// Step 1: Re-derive commitment from private inputs.
// "attest:age-21:v1" is specific to this property and version.
// A residency commitment hashes under a different tag and cannot
// satisfy this circuit, even with the same (secret, nonce).
const recomputed = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "attest:age-21:v1"),
secret,
nonce
]);
// Step 2: Preimage binding.
// This assertion creates a ZK constraint: the proof is only valid if
// (secret, nonce) hash to the claimed commitment. Without it, any caller
// who observes a commitment in the public tree can produce a passing proof
// without knowing the underlying secret.
assert(recomputed == disclose(commitment), "Private inputs do not match commitment");
// Step 3: Tree membership.
// findCommitmentPath uses the public commitment to locate the correct path.
// merkleTreePathRoot computes the root from that path (embedding commitment
// as the leaf). checkRoot accepts any historic root — proofs stay valid
// after future insertions.
const path = findCommitmentPath(disclose(commitment));
const root = merkleTreePathRoot<10, Bytes<32>>(path);
assert(commitmentTree.checkRoot(disclose(root)), "Commitment not in attestation tree");
// Step 4: Replay prevention with domain-separated nullifier.
// "nullify:age:v1" differs from "attest:age-21:v1", so the nullifier hash
// is completely unrelated to the commitment hash. An observer cannot tell
// which commitment produced which nullifier.
const nullifier = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "nullify:age:v1"),
secret,
nonce
]);
assert(!ageNullifiers.member(disclose(nullifier)), "Age proof already consumed");
ageNullifiers.insert(disclose(nullifier), disclose(true));
}
The residency and certification circuits are structurally identical. Only the domain tags change:
export circuit proveUSResidency(commitment: Bytes<32>): [] {
const secret = getSecret();
const nonce = getNonce();
const recomputed = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "attest:residency-us:v1"),
secret,
nonce
]);
assert(recomputed == disclose(commitment), "Private inputs do not match commitment");
const path = findCommitmentPath(disclose(commitment));
const root = merkleTreePathRoot<10, Bytes<32>>(path);
assert(commitmentTree.checkRoot(disclose(root)), "Commitment not in attestation tree");
const nullifier = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "nullify:residency:v1"),
secret,
nonce
]);
assert(!residencyNullifiers.member(disclose(nullifier)), "Residency proof already consumed");
residencyNullifiers.insert(disclose(nullifier), disclose(true));
}
export circuit proveCertification(commitment: Bytes<32>): [] {
const secret = getSecret();
const nonce = getNonce();
const recomputed = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "attest:cert-dev:v1"),
secret,
nonce
]);
assert(recomputed == disclose(commitment), "Private inputs do not match commitment");
const path = findCommitmentPath(disclose(commitment));
const root = merkleTreePathRoot<10, Bytes<32>>(path);
assert(commitmentTree.checkRoot(disclose(root)), "Commitment not in attestation tree");
const nullifier = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "nullify:cert:v1"),
secret,
nonce
]);
assert(!certNullifiers.member(disclose(nullifier)), "Certification proof already consumed");
certNullifiers.insert(disclose(nullifier), disclose(true));
}
How cross-property unlinkability works
Suppose a user holds all three attestations and submits one proof for each. An on-chain observer sees three transactions. Can they tell the proofs came from the same user?
They see:
- An age commitment:
persistentHash(["attest:age-21:v1", secret, nonce]) - A residency commitment:
persistentHash(["attest:residency-us:v1", secret, nonce]) - A cert commitment:
persistentHash(["attest:cert-dev:v1", secret, nonce])
These are three completely different 32-byte values. Without knowing secret and nonce, there is no mathematical way to determine they came from the same inputs. The same applies to the nullifiers: persistentHash(["nullify:age:v1", ...]), persistentHash(["nullify:residency:v1", ...]), persistentHash(["nullify:cert:v1", ...]) — three different values, unlinkable by construction.
This is the purpose of domain separation. Two hashes are only correlatable if they share a prefix AND the rest of their inputs. Different domain tags guarantee different outputs for all inputs.
The version suffix in each tag (v1) is deliberate. If a contract is upgraded and the circuit logic changes, incrementing to v2 ensures the new deployment's commitments and nullifiers occupy a completely separate hash space from the old one. A v1 nullifier cannot conflict with a v2 nullifier.
Off-chain witness implementation
The findCommitmentPath witness runs in TypeScript during proof generation. It has access to the full ledger state and uses the provided commitment to find its path:
import type { WitnessContext } from '@midnight-ntwrk/compact-runtime';
type PrivateState = {
secret: Uint8Array;
nonce: Uint8Array;
};
export const witnesses = {
getSecret: (
context: WitnessContext<Ledger, PrivateState>,
): [PrivateState, Uint8Array] => {
return [context.privateState, context.privateState.secret];
},
getNonce: (
context: WitnessContext<Ledger, PrivateState>,
): [PrivateState, Uint8Array] => {
return [context.privateState, context.privateState.nonce];
},
findCommitmentPath: (
context: WitnessContext<Ledger, PrivateState>,
commitment: Uint8Array,
): [PrivateState, MerkleTreePath] => {
const path = context.ledger.commitmentTree.findPathForLeaf(commitment);
if (!path) throw new Error('Commitment not found in attestation tree');
return [context.privateState, path];
},
};
findPathForLeaf performs an O(n) scan of the tree. For large registries, track leaf indices at insertion time and use pathForLeaf(index, commitment) instead for O(1) path retrieval.
Common pitfalls
1. Same domain for commitment and nullifier
// ❌ Both commitment and nullifier use the same tag
const commitment = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "attest:age:v1"),
secret, nonce
]);
const nullifier = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "attest:age:v1"), // ← same tag
secret, nonce
]);
When the tags match, commitment == nullifier for all inputs. The first time a user submits a proof, the commitment value is inserted into ageNullifiers. Any subsequent proof for the same commitment fails — the nullifier is already spent. The user is permanently locked out after a single use.
// ✅ Distinct tags for commitment and nullifier
const commitment = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "attest:age-21:v1"), secret, nonce
]);
const nullifier = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "nullify:age:v1"), secret, nonce // ← different tag
]);
2. No domain separation between properties
// ❌ Both age and residency circuits use the same tag
// proveAge:
const ageCommitment = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "attest:property:v1"), secret, nonce // ← generic tag
]);
// proveResidency:
const residencyCommitment = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "attest:property:v1"), secret, nonce // ← same tag
]);
ageCommitment == residencyCommitment for the same (secret, nonce). Proving age inserts a nullifier that also blocks the residency proof. After the user proves age, proveUSResidency rejects them — their nullifier is already spent under the shared tag.
More critically, the authority's age commitment is identical to a residency commitment for the same user. They're indistinguishable on-chain.
// ✅ Each property uses a unique tag
const ageCommitment = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "attest:age-21:v1"), secret, nonce
]);
const residencyCommitment = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "attest:residency-us:v1"), secret, nonce
]);
3. MerkleTree instead of HistoricMerkleTree for a live registry
// ❌ Standard MerkleTree — proofs invalidate on any insertion
export ledger badTree: MerkleTree<10, Bytes<32>>;
export circuit prove(commitment: Bytes<32>): [] {
const path = findPath(disclose(commitment));
const root = merkleTreePathRoot<10, Bytes<32>>(path);
assert(badTree.checkRoot(disclose(root)), "Not in tree");
// ← fails if ANY insertion happened after the user computed their path
}
In a registry where the authority adds new attestations regularly, this circuit will fail for any user whose proof was computed before the latest batch of insertions. The root changed; their proof is stale.
// ✅ HistoricMerkleTree — proofs remain valid across all future insertions
export ledger commitmentTree: HistoricMerkleTree<10, Bytes<32>>;
// checkRoot accepts any root the tree has ever held
4. Missing preimage binding (the silent vulnerability)
This is the most subtle pitfall, and the one most likely to appear in a contract that otherwise looks correct.
// ❌ No constraint ties (secret, nonce) to the commitment
export circuit proveNoBinding(commitment: Bytes<32>): [] {
const secret = getSecret();
const nonce = getNonce();
// secret and nonce are loaded from witnesses but never compared to commitment.
// The ZK circuit has no constraint linking these private values to the
// public commitment parameter.
const path = findCommitmentPath(disclose(commitment));
const root = merkleTreePathRoot<10, Bytes<32>>(path);
assert(commitmentTree.checkRoot(disclose(root)), "Not in tree");
const nullifier = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "nullify:age:v1"), secret, nonce
]);
assert(!ageNullifiers.member(disclose(nullifier)), "Already spent");
ageNullifiers.insert(disclose(nullifier), disclose(true));
}
The attack: a caller who does NOT hold a valid attestation observes any valid commitment in the public tree (the tree is on-chain; all commitments are visible). They supply that commitment as the public commitment parameter. They provide a valid Merkle path for it (also computable from public state). The path check passes. They supply any (secret, nonce) pair they happen to own, derive a nullifier from it, and spend it. The circuit accepts.
The result: someone has just "proven" a property without holding an authority-issued attestation for it.
// ✅ Preimage binding — add this before the path lookup
const recomputed = persistentHash<Vector<3, Bytes<32>>>([
pad(32, "attest:age-21:v1"),
secret,
nonce
]);
assert(recomputed == disclose(commitment), "Private inputs do not match commitment");
This one assertion closes the vulnerability. The ZK circuit now requires a (secret, nonce) pair that hashes to the specific commitment being claimed.
5. Set<T> doesn't exist in Compact
// ❌ Compile error — no Set type
export ledger ageNullifiers: Set<Bytes<32>>;
// ✅ Use Map<K, Boolean> as an idiomatic set
export ledger ageNullifiers: Map<Bytes<32>, Boolean>;
// Check membership
assert(!ageNullifiers.member(disclose(nullifier)), "Already spent");
// Mark as seen
ageNullifiers.insert(disclose(nullifier), disclose(true));
6. Calling lookup without a member guard
lookup(key) panics at proof generation if the key is not in the map. This applies to any Map in Compact — including nullifier maps if you use them for anything other than a simple insert-and-check pattern. Always guard with member first:
// ❌ Panics if key absent
const val = myMap.lookup(disclose(key));
// ✅ Guard first
assert(myMap.member(disclose(key)), "Key not found");
const val = myMap.lookup(disclose(key));
7. disclose() missing on write operations
Every write to a Map or MerkleTree from an exported circuit requires disclose() on the keys and values. The compiler message is:
potential witness-value disclosure must be declared but is not
// ❌ Missing disclose — compile error
ageNullifiers.insert(nullifier, true);
// ✅ Both key and value disclosed
ageNullifiers.insert(disclose(nullifier), disclose(true));
The same requirement applies to MerkleTree.insert and HistoricMerkleTree.insert.
8. Domain tags without pad(32, ...)
Domain tags must be Bytes<32>. Using a raw string literal without pad produces an incorrectly sized byte sequence and a type error:
// ❌ Type error — string literal is not Bytes<32>
persistentHash<Vector<3, Bytes<32>>>([
"attest:age-21:v1",
secret,
nonce
]);
// ✅ pad left-pads the string to exactly 32 bytes
persistentHash<Vector<3, Bytes<32>>>([
pad(32, "attest:age-21:v1"),
secret,
nonce
]);
Domain tag design reference
{protocol}:{property}:{threshold-or-variant}:v{version}
| Tag | Meaning |
|---|---|
"attest:age-21:v1" |
Authority certifies user is 21 or older (v1) |
"attest:age-18:v1" |
Authority certifies user is 18 or older (v1) |
"attest:residency-us:v1" |
Authority certifies US residency (v1) |
"attest:cert-dev:v1" |
Authority certifies developer certification (v1) |
"nullify:age:v1" |
Nullifier for any age attestation (v1) |
"nullify:residency:v1" |
Nullifier for residency attestation (v1) |
"nullify:cert:v1" |
Nullifier for certification attestation (v1) |
Keep commitment tags and nullifier tags in different namespaces (attest: vs nullify:). Never share a tag between a commitment circuit and a nullifier circuit. Include the version suffix — if you redeploy with changed logic, a version bump isolates the new deployment's hash space from the old one.
Compiler-verified source
Both contracts in this article — compliance_attestation.compact and attestation_patterns.compact — compile against the latest Compact compiler. The full source and CI run are at:
IamHarrie-Labs/compact-compliance-attestation
Resources
-
Compact Language Reference —
HistoricMerkleTree,MerkleTreePath,merkleTreePathRoot -
Standard Library Exports —
persistentHash,pad,MerkleTreePath - Bulletin Board Tutorial — canonical Midnight witness pattern reference
- Midnight Developer Forum
- Compact Release Notes
Top comments (0)