DEV Community

Tosh
Tosh

Posted on

Selective Disclosure Patterns in Compact: Proving Without Revealing

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

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

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

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

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

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

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

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

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

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

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:

  1. Range proofs: assert a private value satisfies x >= threshold or low <= x <= high without revealing x. Use for age gates, financial thresholds, score checks.

  2. 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.

  3. 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)