惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

G
GRAHAM CLULEY
V
V2EX
WordPress大学
WordPress大学
博客园 - Franky
Last Week in AI
Last Week in AI
博客园 - 司徒正美
有赞技术团队
有赞技术团队
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
博客园 - 【当耐特】
V
Visual Studio Blog
C
CERT Recently Published Vulnerability Notes
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Jina AI
Jina AI
Attack and Defense Labs
Attack and Defense Labs
腾讯CDC
The Hacker News
The Hacker News
Hugging Face - Blog
Hugging Face - Blog
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
J
Java Code Geeks
人人都是产品经理
人人都是产品经理
阮一峰的网络日志
阮一峰的网络日志
T
Tailwind CSS Blog
S
SegmentFault 最新的问题
大猫的无限游戏
大猫的无限游戏
小众软件
小众软件
A
Arctic Wolf
量子位
博客园 - 聂微东
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
N
News and Events Feed by Topic
雷峰网
雷峰网
博客园_首页
Google Online Security Blog
Google Online Security Blog
Spread Privacy
Spread Privacy
罗磊的独立博客
H
Hacker News: Front Page
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
月光博客
月光博客
TaoSecurity Blog
TaoSecurity Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
博客园 - 三生石上(FineUI控件)
宝玉的分享
宝玉的分享
IT之家
IT之家
The Cloudflare Blog
爱范儿
爱范儿
博客园 - 叶小钗
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Apple Machine Learning Research
Apple Machine Learning Research
酷 壳 – CoolShell
酷 壳 – CoolShell

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Building a compliance attestation system with selective disclosure on Midnight
Harrie · 2026-06-18 · via DEV Community

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