DEV Community

Cover image for Building a compliance attestation system with selective disclosure on Midnight
Harrie
Harrie

Posted on

Building a compliance attestation system with selective disclosure on Midnight

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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
Enter fullscreen mode Exit fullscreen mode
// ❌ Missing disclose — compile error
ageNullifiers.insert(nullifier, true);

// ✅ Both key and value disclosed
ageNullifiers.insert(disclose(nullifier), disclose(true));
Enter fullscreen mode Exit fullscreen mode

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

Domain tag design reference

{protocol}:{property}:{threshold-or-variant}:v{version}
Enter fullscreen mode Exit fullscreen mode
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

Top comments (0)