Selective Disclosure Patterns in Compact: Proving Without Revealing
The phrase "I can prove X without showing you X" sounds like a magic trick. It isn't — it's just math. Zero-knowledge proofs let you establish facts about private data without exposing the data itself, and Midnight's Compact language gives you the tools to build this into your contracts directly.
This article covers three core selective disclosure patterns: range proofs, membership proofs, and commitment schemes. Each one shows up constantly in real privacy-preserving applications. By the end, you'll understand how to implement all three in Compact and when to reach for each.
What Selective Disclosure Actually Means
"Selective disclosure" means sharing the minimum information necessary to establish trust, and nothing more. A few examples of what this looks like in practice:
- A user proves their age is over 18 for an age-gated service, without revealing their actual date of birth
- An employee proves they work at a company, without revealing their name or role
- A voter proves they're on the eligible voter list, without revealing which voter they are
- A borrower proves their credit score is above a threshold, without revealing the score itself
The traditional alternative to all of these is sharing the raw credential — your ID, your employee record, your credit report. The privacy cost is enormous. Selective disclosure lets you extract only the fact you need, proven cryptographically, with nothing else attached.
ZK proofs make this possible because a proof can convince a verifier that "some private input satisfies these constraints" without revealing the input. The constraints define the fact; the input is never seen.
Pattern 1: Range Proofs
A range proof demonstrates that a value falls within a certain interval. The classic use case: proving age.
The Problem
A typical KYC flow asks for your birthdate. The service doesn't need your exact birthday to verify you're over 18 — it needs the single bit: age >= 18. But most systems collect the full date because that's the simplest implementation. ZK range proofs let you do better.
How It Works
In Compact, you hold your age (or birthdate) as a private witness. The circuit asserts the private value satisfies the range condition. The verifier receives a proof that this assertion holds — without seeing the value.
contract AgeVerification {
ledger verifiedUsers: Set<Bytes<32>>; // nullifier set for verified accounts
circuit proveAgeOver18(
witness birthYear: Uint<16>, // private: actual birth year
witness birthMonth: Uint<8>, // private: actual birth month
witness credentialSecret: Bytes<32>, // private: secret binding to identity
public currentYear: Uint<16>, // public: current year (from contract call)
public currentMonth: Uint<8>, // public: current month
public userNullifier: Bytes<32> // public: H(credentialSecret) — proves uniqueness
) {
// Verify nullifier is correctly derived from credential
assert userNullifier == hash(credentialSecret) : "invalid nullifier";
// Ensure user hasn't already verified (one verification per credential)
assert !verifiedUsers.contains(userNullifier) : "already verified";
// Range check: ensure birthYear is a plausible human birth year
assert birthYear >= 1900 : "implausible birth year";
assert birthYear <= currentYear : "birth year in the future";
// Calculate age in years (simplified — ignores month for brevity)
const ageYears: Uint<16> = currentYear - birthYear;
// THE KEY ASSERTION: prove age >= 18 without revealing birthYear
assert ageYears >= 18 : "must be 18 or older";
// If birth year matches current year minus 18, also check month
if ageYears == 18 {
assert birthMonth <= currentMonth : "not yet 18 this year";
}
}
}
What the verifier learns: the user is over 18, their nullifier (so they can't reuse the credential), and the current date used for the check. What stays private: the actual birth year and month, the credential secret, the user's identity.
Range Proofs for Financial Thresholds
The same pattern applies to financial checks — credit scores, balance thresholds, income verification:
contract CreditCheck {
ledger creditBureauRoot: Bytes<32>; // root of valid credit score commitments
circuit proveScoreAbove700(
witness creditScore: Uint<16>, // private: actual credit score (300-850)
witness scoreSecret: Bytes<32>, // private: randomness in commitment
witness merkleProof: MerkleProof, // private: proof score is in registry
public scoreCommitment: Bytes<32>, // public: H(creditScore, scoreSecret)
public applicantNullifier: Bytes<32> // public: prevents reuse of old scores
) {
// Verify score is a valid credit score value
assert creditScore >= 300 : "invalid credit score lower bound";
assert creditScore <= 850 : "invalid credit score upper bound";
// Verify commitment: binds public commitment to private score
assert scoreCommitment == hash(creditScore, scoreSecret) : "invalid commitment";
// Verify the commitment is registered with the credit bureau
assert merkleProof.verify(creditBureauRoot, scoreCommitment)
: "commitment not in bureau registry";
// THE KEY ASSERTION: prove score >= 700
assert creditScore >= 700 : "credit score below threshold";
}
}
The lender learns: the applicant has a score ≥ 700, and it's registered with the credit bureau. The applicant doesn't reveal their actual score, which could expose them to discrimination or rate manipulation if known precisely.
Generalizing Range Proofs
For configurable thresholds (rather than hardcoded 18 or 700), pass the threshold as a public input:
circuit proveValueAboveThreshold(
witness privateValue: Uint<64>,
public threshold: Uint<64>,
public commitment: Bytes<32>,
witness valueSecret: Bytes<32>
) {
// Bind private value to public commitment
assert commitment == hash(privateValue, valueSecret) : "invalid commitment";
// Range check
assert privateValue >= threshold : "value below threshold";
}
The threshold is public (both parties agree on what's being proven), but the value itself stays private.
Pattern 2: Membership Proofs
A membership proof shows that a private value belongs to a public set, without revealing which element it is.
The Problem
Allowlists are everywhere: approved jurisdictions, whitelisted addresses, authorized operators. Standard allowlists require checking an on-chain list against your address — which means your address is visible. Membership proofs let you prove "I'm on this list" without revealing who you are on the list.
Merkle-Based Membership
The standard implementation uses a merkle tree. The set is committed to as a merkle root on-chain. Each member holds a merkle proof (path from their leaf to the root).
contract AllowlistGate {
// The full allowlist is not on-chain — only its root
ledger allowlistRoot: Bytes<32>;
ledger usedNullifiers: Set<Bytes<32>>; // prevents reuse of membership proofs
circuit proveAllowlistMembership(
witness memberSecret: Bytes<32>, // private: secret associated with membership
witness memberData: Bytes<256>, // private: member's actual data (address, etc.)
witness leafIndex: Uint<32>, // private: position in merkle tree
witness merkleProof: MerkleProof, // private: sibling hashes to root
public nullifier: Bytes<32>, // public: H(memberSecret) — proves uniqueness
public action: Bytes<256> // public: what the member is authorized to do
) {
// Derive commitment from member data and secret
const memberCommitment: Bytes<32> = hash(memberData, memberSecret);
// Verify membership: prove commitment is in the allowlist tree
assert merkleProof.verify(allowlistRoot, leafIndex, memberCommitment)
: "not in allowlist";
// Verify nullifier is correctly derived (prevents forged nullifiers)
assert nullifier == hash(memberSecret, action) : "invalid nullifier";
// Prevent reuse of the same nullifier
assert !usedNullifiers.contains(nullifier) : "nullifier already used";
}
}
What's public: the allowlist root (a commitment to the full set), the nullifier (a unique token that prevents replaying the proof), and the specific action being authorized. What's private: which leaf, which member, the member's actual data.
Set Non-Membership
Sometimes you need the reverse: prove you're not on a list (e.g., a sanctions list). This is harder because you can't use a simple merkle path — there's no leaf to point to.
One approach: prove membership in a "clean" set that's disjoint from the sanctions list:
contract SanctionsCheck {
ledger cleanAddressRoot: Bytes<32>; // root of addresses known to be clean
ledger sanctionsRoot: Bytes<32>; // root of sanctioned addresses (for reference)
circuit proveNotSanctioned(
witness addressSecret: Bytes<32>, // private: proves ownership of address
witness addressData: Bytes<256>, // private: the actual address
witness merkleProof: MerkleProof, // private: proof of inclusion in clean set
public addressCommitment: Bytes<32>, // public: H(addressData, addressSecret)
public nullifier: Bytes<32> // public: H(addressSecret)
) {
// Verify address commitment is correct
assert addressCommitment == hash(addressData, addressSecret) : "invalid commitment";
// Prove the address is in the clean set
// (The clean set is maintained by a trusted party and excludes sanctioned addresses)
assert merkleProof.verify(cleanAddressRoot, addressCommitment)
: "address not in clean set";
// Verify nullifier
assert nullifier == hash(addressSecret) : "invalid nullifier";
}
}
This relies on a trusted party maintaining the clean set, which is a reasonable compromise for regulatory compliance. The alternative — proving non-membership in the sanctions list cryptographically — requires accumulators or more complex data structures.
Signed Credential Membership
Another variant: membership proven by a signature from a trusted issuer, rather than a merkle tree. Useful for credentials that are issued individually rather than as a batch:
contract IssuedCredential {
ledger issuerPublicKey: Bytes<64>; // public: the issuer's verification key
ledger revokedCredentials: Set<Bytes<32>>; // public: revoked credential nullifiers
circuit proveValidCredential(
witness credentialData: Bytes<512>, // private: the credential contents
witness credentialSignature: Bytes<64>, // private: issuer's signature
witness holderSecret: Bytes<32>, // private: holder's secret
public credentialType: Uint<16>, // public: type of credential
public nullifier: Bytes<32> // public: H(holderSecret, credentialType)
) {
// Verify the issuer signed this credential
assert verifySignature(issuerPublicKey, credentialData, credentialSignature)
: "invalid credential signature";
// Verify the credential is of the expected type
const credType: Uint<16> = credentialData.slice(0, 2) as Uint<16>;
assert credType == credentialType : "wrong credential type";
// Verify the holder secret is embedded in the credential (binds credential to holder)
const embeddedHolder: Bytes<32> = credentialData.slice(10, 42) as Bytes<32>;
assert embeddedHolder == hash(holderSecret) : "not your credential";
// Verify not revoked
assert !revokedCredentials.contains(nullifier) : "credential revoked";
// Verify nullifier
assert nullifier == hash(holderSecret, credentialType) : "invalid nullifier";
}
}
Pattern 3: Commitment Schemes
A commitment scheme lets you commit to a value now and reveal it later, with a cryptographic guarantee that you can't change your mind between committing and revealing. The classic use case is sealed-bid auctions.
Commit-Then-Reveal
Phase 1 (commit): You publish a hash of your value. You're bound to that value but no one knows what it is.
Phase 2 (reveal): You publish the preimage. Everyone can verify it matches your commitment.
contract SealedBidAuction {
ledger commitPhaseEnd: Uint<64>; // block height when commits close
ledger revealPhaseEnd: Uint<64>; // block height when reveals close
// Maps bidder commitment → bid details (hidden until reveal)
ledger commitments: Map<Bytes<32>, Bytes<32>>; // bidderNullifier → commitment
// Revealed bids
ledger revealedBids: Map<Bytes<32>, Uint<64>>; // bidderNullifier → amount
circuit commitBid(
witness bidAmount: Uint<64>, // private: the actual bid
witness bidNonce: Bytes<32>, // private: randomness (prevents guessing)
witness bidderSecret: Bytes<32>, // private: proves bidder identity
public commitment: Bytes<32>, // public: H(bidAmount, bidNonce)
public bidderNullifier: Bytes<32> // public: H(bidderSecret)
) {
// Verify commitment is correctly formed
assert commitment == hash(bidAmount, bidNonce) : "invalid commitment";
// Verify bidder nullifier
assert bidderNullifier == hash(bidderSecret) : "invalid bidder nullifier";
// Verify commit phase is still open (checked off-chain; contract enforces in transition)
// The contract state machine handles phase enforcement
}
circuit revealBid(
witness bidAmount: Uint<64>, // private: the actual bid (now being revealed)
witness bidNonce: Bytes<32>, // private: the nonce used in commitment
public bidderNullifier: Bytes<32>, // public: matches the commit phase nullifier
public revealedAmount: Uint<64> // public: the bid being revealed
) {
// Verify the revealed amount matches what was committed
const storedCommitment: Bytes<32> = commitments.get(bidderNullifier);
assert hash(bidAmount, bidNonce) == storedCommitment : "revealed value doesn't match commitment";
// Verify revealed amount matches the private witness
assert revealedAmount == bidAmount : "amount mismatch";
// Now revealedAmount is public and verified to match the original commitment
}
}
The commit phase: commitment is on-chain, binding the bidder to an amount. No one knows what the amount is. The reveal phase: revealedAmount is now public, and the proof verifies it matches the original commitment. There's no way to change your bid between phases — the commitment hash prevents it.
Commitments for Private Voting
Commitment schemes are also central to private voting. The idea: voters commit to their votes during a voting period, then reveals are used to tally after the period closes. This prevents last-minute manipulation (where knowing the current tally affects your vote):
contract CommitRevealVote {
ledger commitPhaseOpen: Bool;
ledger revealPhaseOpen: Bool;
ledger eligibleVotersRoot: Bytes<32>;
ledger commitments: Map<Bytes<32>, Bytes<32>>; // nullifier → vote commitment
ledger tally: Map<Uint<8>, Uint<32>>; // option → count
circuit commitVote(
witness voteChoice: Uint<8>, // private: the actual vote
witness voteNonce: Bytes<32>, // private: randomness
witness voterSecret: Bytes<32>, // private: voter identity
witness eligibilityProof: MerkleProof,// private: proves voter is eligible
witness voterIndex: Uint<32>, // private: index in eligibility tree
public voteCommitment: Bytes<32>, // public: H(voteChoice, voteNonce)
public voterNullifier: Bytes<32> // public: H(voterSecret)
) {
// Prove voter is eligible
const voterLeaf: Bytes<32> = hash(voterSecret);
assert eligibilityProof.verify(eligibleVotersRoot, voterIndex, voterLeaf)
: "not an eligible voter";
// Prove commitment is correctly formed
assert voteCommitment == hash(voteChoice, voteNonce) : "invalid commitment";
// Prove nullifier is correctly derived
assert voterNullifier == hash(voterSecret) : "invalid nullifier";
// Vote choice is valid
assert voteChoice < 5 : "invalid vote option";
}
circuit revealVote(
witness voteChoice: Uint<8>, // private: the vote being revealed
witness voteNonce: Bytes<32>, // private: the original nonce
public voterNullifier: Bytes<32>, // public: matches commit phase
public revealedChoice: Uint<8> // public: the choice to add to tally
) {
// Verify revealed vote matches the commitment
const storedCommitment: Bytes<32> = commitments.get(voterNullifier);
assert hash(voteChoice, voteNonce) == storedCommitment : "vote mismatch";
// Verify public revealed choice matches private witness
assert revealedChoice == voteChoice : "choice mismatch";
}
}
The Nonce in Commitments
Note that every commitment in the examples above includes a random nonce: hash(value, nonce) rather than just hash(value). This is critical. Without the nonce, an adversary can precompute hash(value) for every possible value and break the hiding property.
For a vote from 5 options, that's 5 precomputations. For a credit score from 300-850, that's 551. For a bid amount with reasonable precision, maybe 10,000 possibilities. All of these are trivial to brute force.
The nonce makes the commitment information-theoretically hiding: even with unlimited computation, you can't learn the value without the nonce.
// WRONG: brute-forceable commitment
assert badCommitment == hash(voteChoice) : "bad";
// RIGHT: hiding commitment with nonce
assert goodCommitment == hash(voteChoice, voteNonce) : "good";
Combining Patterns
Real contracts often combine all three patterns. Here's a KYC compliance example that uses range proofs for age, membership proofs for jurisdiction, and commitments for the compliance attestation:
contract KYCCompliance {
ledger approvedJurisdictionsRoot: Bytes<32>; // merkle root of approved countries
ledger kycIssuerKey: Bytes<64>; // KYC provider's public key
ledger usedAttestation: Set<Bytes<32>>; // prevents attestation reuse
circuit proveKYCCompliant(
witness birthYear: Uint<16>, // private: for age check
witness jurisdictionCode: Uint<16>, // private: for membership check
witness kycCredential: Bytes<512>, // private: issued KYC credential
witness kycSignature: Bytes<64>, // private: issuer signature
witness userSecret: Bytes<32>, // private: user identity secret
witness jurisdictionMerkleProof: MerkleProof,
witness jurisdictionIndex: Uint<32>,
public currentYear: Uint<16>, // public: for age calculation
public attestationNullifier: Bytes<32>, // public: prevents reuse
public serviceIdentifier: Bytes<32> // public: which service this is for
) {
// 1. RANGE PROOF: prove age >= 18
const age: Uint<16> = currentYear - birthYear;
assert age >= 18 : "must be 18 or older";
assert birthYear >= 1900 : "invalid birth year";
// 2. MEMBERSHIP PROOF: prove jurisdiction is approved
const jurisdictionLeaf: Bytes<32> = hash(jurisdictionCode);
assert jurisdictionMerkleProof.verify(
approvedJurisdictionsRoot,
jurisdictionIndex,
jurisdictionLeaf
) : "jurisdiction not approved";
// 3. COMMITMENT: verify KYC credential is valid and belongs to this user
assert verifySignature(kycIssuerKey, kycCredential, kycSignature)
: "invalid KYC credential";
// Verify credential contains this user's birth year and jurisdiction
const credBirthYear: Uint<16> = kycCredential.slice(0, 2) as Uint<16>;
const credJurisdiction: Uint<16> = kycCredential.slice(2, 4) as Uint<16>;
assert credBirthYear == birthYear : "credential birth year mismatch";
assert credJurisdiction == jurisdictionCode : "credential jurisdiction mismatch";
// Verify attestation nullifier (service-specific, prevents cross-service reuse)
assert attestationNullifier == hash(userSecret, serviceIdentifier)
: "invalid attestation nullifier";
assert !usedAttestation.contains(attestationNullifier) : "attestation already used";
}
}
This circuit proves three things simultaneously: the user is of legal age, from an approved jurisdiction, and holds a valid KYC credential — without revealing their exact age, their country, or any identifying information beyond the nullifier.
Use Cases Summary
| Use Case | Primary Pattern | What's Proven | What Stays Private |
|---|---|---|---|
| Age verification | Range proof | age ≥ 18 | actual age, identity |
| Credit check | Range proof | score ≥ 700 | actual score |
| Allowlist access | Membership proof | "I'm on the list" | which member |
| Jurisdiction check | Membership proof | country is approved | which country |
| Sealed bid auction | Commitment | bid is unchanged | bid amount (until reveal) |
| Private voting | Commitment + Membership | eligible voter, valid vote | voter identity, vote choice |
| KYC compliance | All three | age + jurisdiction + credential | all personal details |
Summary
Selective disclosure in Compact comes down to three building blocks:
Range proofs: assert a private value satisfies
x >= thresholdorlow <= x <= highwithout revealing x. Use for age gates, financial thresholds, score checks.Membership proofs: verify that a private value is in a committed set (merkle root, signed registry) without revealing which element. Use for allowlists, jurisdiction checks, credential types.
Commitment schemes: bind a value cryptographically before revealing it, with a nonce to prevent brute force. Use for auctions, voting, any protocol requiring a commit-then-reveal phase.
The common thread is: ZK proofs let you assert properties about private witnesses without revealing them. Design your circuits by thinking about what property needs to be verified (the assertion), not what data needs to be shared. The less data you share, the better the privacy guarantees — and the cleaner your protocol design.
Top comments (0)