A core feature of blockchain technology is transparency. However, this can sometimes impose restrictions on the kind of applications developers can build, due to the privacy concerns of their potential users. If everything is completely public, then it becomes very easy for malicious actors to stage attacks against innocent people.
Midnight blockchain provides a solution for this in the Compact programming language by ensuring that everything defined in smart contracts is private by default and allows developers to selectively disclose information or proofs of information that should be public.
In this article, you will learn how Compact handles selective disclosure, when to use it, and when not to use it.
What is selective disclosure?
Selective disclosure is the ability to prove a property about private data without revealing the data itself. Instead of publishing "my balance is 5,000," a user can publish a cryptographic proof that says "my balance exceeds the required threshold", and the verifier learns exactly that fact; nothing more. The underlying value never leaves the user's local environment.
Essentially, it means you get to choose what to reveal about your data.
How does selective disclosure work in Compact?
Compact handles selective disclosure by introducing the disclose() method. This method tells the compiler that you, the developer, understand that you are about to reveal private information to the public ledger. Without this, Compact will throw an exception.
But how does Compact know what is private and what is public?
Understanding the concept of taint
Every value in Compact that comes from a witness (the private inputs your DApp provides) is considered tainted. Think of it this way: "tainted data is private data". Compact will not let tainted data cross over to the public ledger without explicit approval from you (i.e., using disclose()).
Taint is contagious: any value derived from a tainted value is also tainted, no matter how many arithmetic operations or type conversions happen in between.
There are exactly three places where tainted data can reach the outside world:
- Writing to an
export ledgerfield - Returning a value from an
export circuit - Passing a value to another smart contract
If tainted data reaches any of these points without explicit permission, the compiler refuses to build. You cannot accidentally publish private data. The only question is whether your "deliberate" disclosures are actually safe.
To see this in practice, here's what happens if you take Pattern 5's (see below) commit() circuit and remove the disclose() wrapper around the hash:
export circuit commit(value: Uint<32>, salt: Bytes<32>): [] {
const hash = persistentHash<Vector<2, Bytes<32>>>([
(value as Field) as Bytes<32>,
salt
]);
commitment = hash; // missing disclose
}
The compiler will refuse to build and produce one error per tainted source flowing into the disclosure:
Exception: code.compact line 13 char 14:
potential witness-value disclosure must be declared but is not:
witness value potentially disclosed:
the value of parameter value of exported circuit commit at line 8 char 23
nature of the disclosure:
ledger operation might disclose a hash of the witness value
via this path through the program:
the argument to persistentHash at line 9 char 16
the binding of hash at line 9 char 9
the right-hand side of = at line 13 char 14
Exception: code.compact line 13 char 14:
potential witness-value disclosure must be declared but is not:
witness value potentially disclosed:
the value of parameter salt of exported circuit commit at line 8 char 40
nature of the disclosure:
ledger operation might disclose a hash of the witness value
via this path through the program:
the argument to persistentHash at line 9 char 16
the binding of hash at line 9 char 9
the right-hand side of = at line 13 char 14
Notice what each error's path trace is telling you: value and salt start out as ordinary circuit parameters. Nothing about them looks "private" on the surface. But the moment they're passed into persistentHash, the result (hash) inherits their taint. That taint then survives the const binding and rides along until hash is assigned to the ledger field commitment, at which point the compiler stops you.
This is what "taint is contagious" means. The compiler doesn't just check the values you write to the ledger; it tracks every step a tainted value travels through: hashing, binding, or reassignment, and still catches it on the other side.
Wrapping the assignment in disclose() (as the original Pattern 5 code does) tells the compiler these two specific values are safe to expose as part of the commitment hash.
An exception: transientCommit vs transientHash
Not every function in Compact's standard library propagates taint. Both transientCommit and transientHash are standard library functions.
However, the compiler treats transientCommit(e) as if it does not contain witness data, even if e itself is tainted. On the other hand, transientHash(e) is still treated as tainted, just like persistentHash.
transientCommit is considered to disguise its input well enough that no disclose() is needed at all.
The practical takeaway: if you're hashing a witness value and only need a transient commitment (not stored long-term, or not needed for cross-transaction binding), transientCommit may let you skip disclose() entirely. If you need a persistent, on-chain-verifiable commitment — as in Pattern 2 and Pattern 5 — persistentHash is still tainted, and disclose() remains the explicit, deliberate signal that you intend to publish it.
Five ways to use disclose() in Compact
In practice, there are five common ways you will use the disclose() method. This is not an exhaustive list of how to use disclose(). However, it should give you a good head start.
1. Directly disclosing a value
This is the simplest usage of disclose() in Compact. You simply disclose information that is supposed to be public. The data isn't sensitive and should be on-chain. Here's an example where you want everyone to see the content of a message:
pragma language_version >= 0.20;
import CompactStandardLibrary;
witness getMessage(): Opaque<"string">; // this is private by default
export ledger message: Maybe<Opaque<"string">>; // this is public, on-chain state
export circuit post(): [] {
message = disclose(some<Opaque<"string">>(getMessage())); // use disclose() to make the value of getMessage() public
}
Never use this pattern for secret keys, credentials, balances, or any value the user has not explicitly chosen to make public
2. Disclosing the hash of a persistent value
This usage pattern applies when you need to put something on-chain to establish ownership or identity, but the underlying data is sensitive and must stay private. Instead of disclosing the raw value, you disclose its hash. Here's an example where only the organizer of a counter can increment it:
pragma language_version >= 0.23;
import CompactStandardLibrary;
witness secretKey(): Bytes<32>;
export ledger organizer: Bytes<32>;
export ledger restrictedCounter: Counter;
constructor() {
organizer = disclose(publicKey(secretKey())); // disclose the hash, not the secret key
}
export circuit increment(): [] {
assert(organizer == disclose(publicKey(secretKey())), "not authorized");
restrictedCounter.increment(1);
}
circuit publicKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:organizer:"), sk]);
}
In the example above, the secret key never touches the ledger. What gets stored is its hash — organizer.
When increment() is called, the ZK proof verifies inside the circuit that the caller knows a secret key that hashes to organizer. If it doesn't match, the assert fails, and nothing happens. The secret stays off-chain entirely, but its authority over the smart contract is enforced on-chain.
Note that because this hash is fixed for the lifetime of the smart contract, it acts as a traceable identifier. Anyone watching the chain can associate every authorized action with the same hash. You will learn how to address this in a later section.
3. Disclosing a derived fact without exposing the input
Sometimes, you only need to disclose the result of a computation on private data, not the data itself. The input stays private, but the outcome is made public. Here's an example that assigns a credit tier to a loan applicant without ever revealing their actual credit score:
pragma language_version >= 0.23;
import CompactStandardLibrary;
witness getCreditScore(): Uint<16>;
witness localSecretKey(): Bytes<32>;
export ledger applicantTier: Map<Bytes<32>, Uint<8>>;
export circuit submitCreditTier(): [] {
const score = getCreditScore();
const tier = disclose(
score >= 750 ? 1 as Uint<8> : score >= 600 ? 2 as Uint<8> : 3 as Uint<8>
);
const pubKey = disclose(persistentHash<Vector<2, Bytes<32>>>([pad(32, "loan:tier:"), localSecretKey()]));
applicantTier.insert(pubKey, tier);
}
The credit score comes in through a witness and is private by default. The circuit derives a tier from it and discloses only that. A lender who looks up the applicant's public key in applicantTier learns their tier, not their score. The exact number stays off-chain permanently.
NOTE: This code works safely because the tier boundaries are hardcoded inside the circuit. Unlike a pattern where the caller passes in a threshold as a public argument (which would allow repeated calls with different values to narrow down the private input), there is nothing here for a caller to vary or probe.
4. Conditional disclosure with assertions
This is useful in a scenario where a value should only go public if a private condition is satisfied first.
pragma language_version >= 0.23;
import CompactStandardLibrary;
witness getBalance(): Uint<64>;
witness getBid(): Uint<64>;
export ledger bids: Map<Bytes<32>, Uint<64>>;
export circuit placeBid(minBalance: Uint<64>, bidderKey: Bytes<32>): [] {
const balance = getBalance();
const bid = getBid();
assert(disclose(balance >= minBalance), "Insufficient balance");
bids.insert(disclose(bidderKey), disclose(bid));
}
In the example above, the user's balance is never disclosed. It stays private inside the ZK proof. What gets asserted on-chain is only whether it clears the minimum. If it doesn't, the circuit halts and nothing is recorded. If it does, the bid is disclosed and stored.
5. Disclosing a value after commitment
This is a pattern you will use when you need to lock in a private value during one transaction and either prove or reveal it in a later one. Examples include: sealed-bid auctions, on-chain voting, or any protocol with a commitment phase.
pragma language_version >= 0.23;
import CompactStandardLibrary;
export ledger commitment: Bytes<32>;
export ledger revealedValue: Uint<32>;
export circuit commit(value: Uint<32>, salt: Bytes<32>): [] {
const hash = persistentHash<Vector<2, Bytes<32>>>([
(value as Field) as Bytes<32>,
salt
]);
commitment = disclose(hash);
}
export circuit reveal(value: Uint<32>, salt: Bytes<32>): [] {
const computedHash = persistentHash<Vector<2, Bytes<32>>>([
(value as Field) as Bytes<32>,
salt
]);
assert(disclose(computedHash == commitment), "Commitment mismatch");
revealedValue = disclose(value);
}
In the code above, commit() locks the value on-chain as a hash, so the raw value never touches the ledger. reveal() is where disclose() does its real work: the value goes public only after it has been proven to match the original commitment.
You can use this in an auction smart contract where everyone places their bids, which can be revealed when bidding ends.
Domain-separated hashing: preventing cross-context tracking
Here's a problem you might have overlooked. Suppose you're using the second pattern above consistently: always disclosing hashes, and never raw values. You still have a tracking problem if you're using the same hash in multiple places.
// VULNERABLE: same public key used in two different circuits
export circuit registerForEvent(): [] {
registrations.insert(disclose(publicKey(secretKey())));
}
export circuit submitFeedback(): [] {
feedback.insert(disclose(publicKey(secretKey())));
}
If you deploy code like the one above, your user will have the same public key across your entire app. An observer can see that the same hash appears in both registrations and feedback and link the user's activities together. This defeats a significant part of what selective disclosure is supposed to achieve.
The solution to this is called domain separation. It is a practice where you include a unique purpose string in every hash you compute. The same secret key produces a completely different output for each domain, and those outputs have no mathematical relationship to each other.
Here's how Midnight's official bulletin board smart contract does it:
export circuit publicKey(sk: Bytes<32>, sequence: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>([pad(32, "bboard:pk:"), sequence, sk]);
}
In the snippet above, three things prevent an observer from linking a user's activities:
- A domain string (
"bboard:pk:") that namespaces this hash to this specific smart contract and purpose - A sequence counter so the same user's identity changes each round and can't be tracked across posts
- The secret key itself.
With this pattern, an observer sees different hashes for every post and across every smart contract.
Separate your domains within a single app
For multi-property applications (such as an app that requires proving age to one party, income to another), give each property its own domain:
circuit ageKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:age:"), sk]);
}
circuit incomeKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:income:"), sk]);
}
A verifier who sees the output of ageKey cannot correlate it to the output of incomeKey, even knowing both derive from the same user. The domain strings make them cryptographically unlinkable.
NOTE: Keep a project-level list of every domain string you use to avoid collision between contexts
Knowing what to disclose and what to keep private in Compact
The five patterns discussed above show you how you can use disclose(). However, knowing which values should never reach the public ledger is entirely different.
The default in Compact is privacy. You should only give that up when there is a clear reason to do so. A useful way to think about it: if removing a value from the ledger would break the smart contract's ability to function, it probably needs to be public. If the smart contract can enforce its rules without it, it should stay private.
Generally safe to disclose
The following will generally not compromise your DApp's privacy:
- Hashes of private values, when used to establish identity or prove ownership. The hash proves the commitment without revealing the input
- Derived facts (such as a tier, a boolean result, a computed score) when the computation is fixed inside the circuit, and callers cannot vary the inputs to probe the private data
- Values the user has explicitly chosen to make public, like a message or a public bid
Generally unsafe to disclose
The following have the potential to compromise your DApp's privacy:
- Raw witness values such as secret keys, credentials, balances, or any input the user provided privately
- Intermediate computed values that carry enough information to reconstruct the original input
- Any value derived from a secret key, even after arithmetic or type conversion. Taint survives transformation
The compiler will stop you from accidentally disclosing tainted data, but it cannot stop you from deliberately disclosing something you shouldn't. The judgment of whether a disclosure is appropriate always belongs to you.
One practical rule: place disclose() as close to the point of disclosure as possible, not at the point where the value first appears. Wrapping a witness value in disclose() the moment it is called untaints it for the rest of the circuit, which means the compiler stops protecting it from that point forward. Disclose late, not early.
Privacy audit checklist
Before you deploy your smart contract, here is a checklist you should run through. Each section targets a specific class of privacy risk. There are some risks the compiler cannot catch for you, and you need to look closely to find them.
Does your smart contract disclose only what it must?
Go to every point where tainted data crosses into public state:
- Your
export ledgerwrites - The return values of your exported circuits,
- Every cross-contract call.
These are the three places the compiler watches. For each one: would the smart contract stop working if this value stayed private? If not, it should not be there.
Does every disclose() wrap the right thing?
The compiler accepts disclose() around anything: a hash, a raw secret key, an intermediate value that carries enough information to reconstruct the original input.
It cannot tell the difference. For every disclose() call, confirm you are wrapping a hash, a commitment output, or a derived fact, and not a value that is one step removed from a witness.
Are your domain strings doing real work?
Every persistentHash call should include a domain separator that is unique to its exact purpose. Write them all down in one place. If two circuits, two smart contracts, or two roles share a domain string, the identities they produce might be linkable. If a domain string is missing entirely, the hash is linkable to any other hash of the same input anywhere on the network.
What does your smart contract look like from the outside?
Forget what you know about the implementation. Look only at what appears on-chain:
- Ledger fields
- Circuit outputs
- Transaction ordering
What does an observer learn about a user from watching one transaction? From watching ten? From comparing two users side by side? If the answer is more than you intended, the smart contract has a privacy leak your compiler will never catch.
Where does Compact's protection end?
Compact's taint system stops at the TypeScript boundary. Everything beyond it is your responsibility. Check for the following:
- Are sensitive values flowing through circuit arguments instead of witness callbacks? Both are taint-tracked on-chain, but arguments are DApp-constructed and may leak through app-layer logs/RPC.
- Are your witness implementations validating their inputs, or trusting them blindly?
- Is anything leaking through your DApp layer (such as logs, indexer subscriptions, RPC calls) that your circuits protect on-chain?
- Constructor arguments are also witness data (see Pattern 2). Check whether any values passed to your constructor should be flowing through
disclose()before being stored in ledger fields.
If your smart contract passes this checklist, then you are likely doing a good job of maintaining privacy.
Next steps
The patterns in this article cover the most common ways disclose() appears in real Compact smart contracts, but the best way to deepen your intuition is to read production code. Start with these two official examples:
- Bulletin Board — the most concise example of domain-separated identity and direct value disclosure in a single readable smart contract.
-
ZK Loan Credit Scorer — shows how
disclose()is managed across multiple circuits in a more complex DApp. The README explicitly explains the reasoning behind each disclosure decision, which is worth reading alongside the code.
For reference, keep the explicit disclosure documentation and the smart contract security guide open as you build.
Top comments (0)