Where we left off
In the previous article we refined the signature of a small Compact circuit:
witness birthYear(): Uint<16>;
export circuit proveOver18(currentYear: Uint<16>): [] {
// logic comes later
}And we closed with a question to sit with: this signature has a hole. Inputs are private by default, so how would the verifier know currentYear was not invented by the user? And more fundamentally, a witness can supply any birth year, so what makes it true?
This echoes is. Consensus guarantees agreement, not truth. A proof guarantees that the statement holds for the witness, not that the witness is honest.
Today we answer that question completely. We will name the hole precisely, explain why Midnight cannot simply "check" the values for us, and then rebuild the circuit so that lying stops being possible. Every claim below is backed by the Midnight documentation, and the final contract was compiled with the Compact toolchain to make sure it is real code, not a sketch.
First, the words
If you are new to this, five terms are enough to follow everything.
Ledger. The public part of a Midnight smart contract. Anyone can read it. In Compact you declare it with ledger. (Writing a contract)
Circuit. A function in a Compact contract. When you call one, it runs on your own machine and produces a zero-knowledge proof that it was run correctly. The network checks the proof, not your data. (Writing a contract)
Witness. A function that is declared in Compact but implemented in the app's TypeScript code, on the user's device. It is how a circuit gets private data such as a secret key or, in our case, a birth date. The network never sees what a witness returns. (Manage private state with witnesses)
Prover and verifier. The prover is the person generating the proof (the user). The verifiers are the network nodes that check it. (Zero-knowledge proofs)
Private by default. In Compact, every circuit argument and every witness value is treated as private unless the developer explicitly marks it with disclose(). The compiler refuses to compile a contract that would leak private data by accident. (Smart contract security)
That last point is where our hole begins. Privacy is doing its job perfectly. The problem is what privacy cannot do.
The hole, stated precisely
Let us write the obvious version of the circuit so the problem has a shape.
pragma language_version >= 0.16;
import CompactStandardLibrary;
export ledger adultsVerified: Counter;
witness birthYear(): Uint<16>;
export circuit proveOver18(currentYear: Uint<16>): [] {
assert(currentYear >= birthYear() + 18, "Under 18");
adultsVerified.increment(1);
}This compiles. Run it with birthYear() = 2000 and currentYear = 2026 and you get a valid proof that 2026 >= 2000 + 18. The network will accept it. That is not the bug. The bug is that both numbers were chosen by the same person, and nothing in the contract checks either of them against reality.
There are actually two separate problems hiding in this one line.
Problem 1: the user holds the clock. currentYear is a circuit argument. The caller types it in. A sixteen-year-old born in 2010 can pass currentYear = 2030 and the proof is perfectly valid: 2030 is indeed at least 2010 plus 18. The verifier learns nothing that would reveal the trick, because the argument is private.
Problem 2: the user holds the birth certificate. birthYear() is a witness. The documentation is blunt about what that means:
"It's important to note that each user could reasonably use a different implementation for the
witness, so its results cannot be trusted inherently by the contract." (Writing a contract)
And more directly, in the security guide:
"A malicious prover controls their own frontend and supplies every witness value [...] so the only thing constraining a lying prover is the set of
assertstatements in your circuit. Anything you do not constrain, the prover chooses." (Security and best practices)
So a user can edit the TypeScript on their own device to return 1990 instead of 2010. The proof will still be valid, because the proof only says: for the values I used, the computation came out true.

Why Midnight cannot just check
The natural reaction is: "Fine, then let the network check the birth year." But that is exactly what a privacy blockchain refuses to do, on purpose.
The whole point of Midnight is that validators verify a computation without seeing its inputs. The documentation describes the transaction flow this way: users compute on private data locally, a proof is generated, and "network validators verify the proof using the zk-SNARK verification algorithm" without the inputs ever being exposed. (What is Midnight?)
If the network could see your birth date to check it, you would have no privacy. If it cannot see your birth date, it cannot check it. There is no third option inside the proof itself.
This is where the Week 1 lesson comes back with full force. Consensus is "about how nodes agree on the public ledger state." (Consensus) Thousands of nodes agreeing that your proof is valid tells you the math was done right. It tells you nothing about whether the numbers fed into the math were real. The same limit is well known outside Midnight: smart contracts "cannot, by default, access information stored outside the blockchain network," which is why the wider blockchain world talks about the oracle problem. (ethereum.org on oracles)
A proof is a guarantee about consistency: given these inputs, this output follows. It is not a guarantee about truth: these inputs describe the real world. To get truth, we have to connect each input to something outside the user's control that the circuit can check.
That is the entire fix. Two inputs, two anchors.
Fix 1: take the clock away from the user
Midnight gives contracts access to block time: the timestamp of the block that includes your transaction, set by the block producer within protocol-enforced bounds. (Security and best practices, Block-time predicates)
You do not get to read the clock directly. The documentation says plainly: "There is no raw block-time accessor." What you get are four yes/no questions, each taking a number of seconds since 1 January 1970:
Function | Returns true when |
|---|---|
| block time is before |
| block time is at or before |
| block time is after |
| block time is at or after |
The node answers these questions itself, against the real block. The user cannot influence the answer.
So here is the trick, and it is worth reading twice because it is simple once it clicks:
The user still passes a time, call it
asOf.The circuit asks the chain:
blockTimeGte(asOf). In words: "Is it really at leastasOfright now?" If the user claims a future date, the node says no and the transaction fails.The circuit then checks the age rule against
asOf.
Can the user cheat by claiming an earlier time? Yes, and it does not help them. Claiming it is 2015 makes them younger, not older. The only direction of lying that would benefit a cheater is forward in time, and that is exactly the direction the chain blocks.
export circuit proveOver18(asOf: Uint<64>): [] {
assert(blockTimeGte(disclose(asOf)), "asOf is in the future");
// ... the rest of the check uses asOf
}Two details to be honest about.
Why disclose() on asOf? Because the node has to see the number to answer the question. When I compiled this without disclose(), the Compact compiler refused with: "the call to standard-library circuit blockTimeGte might disclose the lower bound of the time being checked." The compiler is telling us, correctly, that whatever you pass to a block-time predicate becomes public. That is fine for asOf, which is just "roughly now." It is the reason we do not pass birthTime + 18 years to the predicate, even though that would be mathematically shorter: it would publish the birth date.
Why seconds instead of years? Because block time is counted in seconds, and because years are too coarse for an age rule. Someone born in December 2008 would count as 18 in January 2026 if we only compared years. Using seconds since 1970 (a Uint<64>) fixes both at once. Eighteen years at 365.25 days is 568,036,800 seconds.
Fix 2: anchor the birth date to someone the contract trusts
The clock was the easy half. The hard half is: how does a contract trust a birth date it is not allowed to see?
The answer Midnight's own examples use is attestation. Someone the contract trusts (a government registry, a bank, an identity provider) signs the data in advance. The user keeps the signed data privately. Inside the circuit, the contract verifies the signature. If the user edits the birth date, the signature no longer matches and the proof cannot be built.
This is exactly the design of the official ZK Loan example, where a bank-style provider signs a credit profile and the circuit checks that signature:
"The witness's data cannot be trusted on its own. The witness runs on your machine, which means you could feed in any credit score you want. [...] That is why the attestation solution exists. A trusted provider (a bank, credit bureau, or scoring service) signs your real data with a cryptographic signature. The smart contract then verifies that signature within the ZK circuit. If the data was tampered with, the signature check fails, and the transaction reverts." (ZK Loan tutorial, smart contract)
The Compact standard library provides the verification function. jubjubSchnorrVerify takes a message (a list of field elements), a signature, and the issuer's public key, and returns true if the signature is valid. The documentation adds: "To actually enforce that a signature is valid in a Compact circuit, use an assert that the result is true." (Compact standard library, jubjubSchnorrVerify)

There is one more hole to close before this is airtight. If the issuer only signs the birth date, the credential is a bearer document: anyone who copies the file from an adult's phone can use it. The ZK Loan example solves this by including the user's identity in the signed message, so the attestation is bound to one specific person. (ZK Loan tutorial, smart contract)
How do you have an identity without revealing who you are? Midnight's documented pattern is a derived identity: hash a secret that only you hold, together with a fixed label, and use the hash as your pseudonym. Only someone who knows the secret can reproduce it, and the hash reveals nothing about the secret. (Security and best practices, Authenticating a caller with a derived identity)
So the issuer signs two things: your birth date, and your pseudonym. In the circuit, you recompute the pseudonym from your secret. If the credential was signed for someone else's pseudonym, the signature check fails.
The whole thing, assembled
Here is the complete contract. It was compiled with Compact toolchains 0.34.0 and 0.35.0 and produces a prover key and a verifier key for proveOver18.
pragma language_version >= 0.16;
import CompactStandardLibrary;
// Public key of the issuer we trust (for example an identity authority).
// Set once at deployment and never changed.
export sealed ledger issuerKey: JubjubPoint;
export ledger adultsVerified: Counter;
constructor(issuer: JubjubPoint) {
issuerKey = disclose(issuer);
}
// Everything the user holds privately on their own device.
struct Credential {
birthTime: Uint<64>; // birth date, seconds since 1 Jan 1970
signature: JubjubSchnorrSignature; // issuer's signature over the credential
}
witness credential(): Credential;
witness secretKey(): Bytes<32>;
// 18 years, counted in seconds (365.25 days per year).
circuit eighteenYears(): Uint<64> {
return 568036800;
}
// A pseudonym derived from the user's secret. Only someone who knows the
// secret can reproduce it.
circuit myIdentity(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "ageproof:identity:v1"), sk]);
}
export circuit proveOver18(asOf: Uint<64>): [] {
// 1. The clock. The chain itself checks that `asOf` is not in the future.
assert(blockTimeGte(disclose(asOf)), "asOf is in the future");
// 2. The credential. It must be signed by the issuer, for this very user.
const cred = credential();
const me = myIdentity(secretKey());
const msg: Vector<2, Field> = [cred.birthTime as Field, degradeToTransient(me)];
assert(jubjubSchnorrVerify<2>(msg, cred.signature, issuerKey), "Credential not issued by trusted issuer");
// 3. The rule. Checked privately; the birth date never leaves the device.
assert(asOf >= cred.birthTime + eighteenYears(), "Under 18");
adultsVerified.increment(1);
}Walking through the pieces that are new:
sealed ledger issuerKey. A sealed field can only be set by the constructor and is immutable afterwards; the compiler enforces it. The documentation recommends sealed fields for "configuration values, contract parameters, or any data that should remain constant after deployment." The issuer's key is exactly that. (Smart contract security, Sealed vs. unsealed ledger fields)persistentHashwithpad(32, "ageproof:identity:v1"). This is the derived-identity pattern with a domain separator, so the same secret used in another app produces an unrelated pseudonym. (Smart contract security, Use domain separation)degradeToTransient(me). The signature function wants field elements, andpersistentHashreturns 32 bytes.degradeToTransientis the standard library's documented way to turn a persistent hash into a field element. (Compact standard library, degradeToTransient)The final
assert. Compact lets you use private data in anassertwithoutdisclose(), because asserting a property of private data is the whole point of a zero-knowledge circuit. The network learns that the condition held, nothing about the values. (Writing a contract, Confidentiality)
On the app side, the two witnesses are a few lines of TypeScript. They read from the user's private state and hand the values to the circuit, following the shape the documentation describes (each witness returns the updated private state and the value). (Manage private state with witnesses)
export const witnesses = {
credential: ({ privateState }) => [privateState, privateState.credential],
secretKey: ({ privateState }) => [privateState, privateState.secretKey],
};And yes, the user can still edit this file. They can return any credential they like. The difference now is that an edited credential fails the signature check, a borrowed credential fails the identity check, and a future date fails the block-time check. Lying is still possible. Lying successfully is not.
What the world sees, and what it does not
This is worth spelling out, because "private" is often used loosely.
Public, visible to anyone reading the chain:
That
proveOver18on this contract was called, and when. The documentation is explicit that the entry point, contract address, and block timing are always visible. (Security and best practices, On-chain visibility)The
asOfvalue, because it was disclosed to the block-time predicate.The counter went up by one.
Private, never leaves the user's device:
The birth date.
The issuer's signature.
The user's secret, and therefore the pseudonym derived from it. Neither is disclosed anywhere in the circuit.
The glossary describes this outcome exactly: selective disclosure lets a user prove "being above a certain age, without revealing the underlying data, such as their exact date of birth." (Glossary, Selective disclosure)
One practical note. Generating the proof requires the private values, and whichever proof server does the proving sees them in the clear. Running the proof server locally keeps them on your own machine, which the documentation calls "the safe default." (Security and best practices, Proving and private data)
The principle to carry with you
If you remember one sentence from this article, make it the one from the Midnight security guide:
"A witness value you do not constrain is a value the prover chooses." (Security and best practices, Pre-deployment security checklist)
Every private input to a circuit needs an answer to the question: what stops the user from making this up? There are only a few kinds of answer, and all of them appear in Midnight's documentation:
The chain supplies it. Ledger state, block time, token balances. The node evaluates these, not the user.
A trusted party signed it. Attestation checked in-circuit, as in the ZK Loan example and in our contract.
It matches a commitment already on the ledger. The user proves they know the value behind a hash or Merkle leaf that someone trusted published earlier. (Keeping data private)
It is tied to a secret only the user holds. Derived identities, so a credential cannot be borrowed.
If a private input has none of these, the contract is not checking it. It is taking the user's word for it, and dressing that word up in a very convincing proof.
Honest limits
Good engineering means saying what the fix does not do.
Trust moved, it did not disappear. The contract now trusts the issuer. If the issuer signs a false birth date, the proof will be valid and wrong. Zero-knowledge proofs let you choose who to trust and how little they get to see; they do not remove the need to trust someone about the real world. That is what the oracle problem is, and no blockchain has abolished it.
Block time is coarse. The documentation advises treating a time gate "as accurate to the scale of blocks, not seconds." For an 18-year threshold that is irrelevant. For a rule measured in minutes it would matter. (Security and best practices, Block-time predicates)
Toolchain versions. jubjubSchnorrVerify is documented in the current standard library reference. In my own tests the contract above compiles with Compact toolchains 0.34.0 and 0.35.0, and fails on 0.31.1 with unbound identifier jubjubSchnorrVerify. At the time of writing, the compatibility matrix lists toolchain 0.31.1 for the public Preview, Preprod, and Mainnet networks, and notes that 0.34.0 and 0.35.0 target a newer ledger version and cannot yet be deployed there. (Compatibility matrix) The ZK Loan tutorial shows the same signature check written by hand as a Compact module for the older toolchain, so the pattern is usable today either way.
Recovery. The user's secret lives only on their device. If they lose it, the credential bound to their pseudonym becomes useless and the issuer has to issue a new one. The documentation says to decide a recovery model before deployment. (Security and best practices, Rotating an owner key)
Closing
We started with a circuit that could be fooled by a teenager with a text editor. We ended with one where every private input is pinned to something the user does not control: the chain's clock, an issuer's signature, and a secret only the user holds.
Nothing about the proof system changed. What changed is that we stopped asking the proof to do a job it was never designed for. A proof shows that a computation was done correctly. Deciding which inputs deserve to be believed is the developer's job, and Midnight gives you the tools to do it without giving up privacy.
Next time, we build the issuer side: how an attestation service actually signs a credential, and how the app stores it safely on the user's device.
Sources
All Midnight sources are the official documentation at docs.midnight.network, read in October 2026.
Writing a contract (witnesses, confidentiality rules, proof of in-circuit computation)
Smart contract security (security model, sealed fields, witness security, domain separation)
Security and best practices (threat model, block-time predicates, derived identity, on-chain visibility, checklist)
Compact standard library reference (
blockTimeGte,jubjubSchnorrVerify,degradeToTransient,persistentHash)Glossary (witness, selective disclosure)
ethereum.org: Oracles (the oracle problem, as general blockchain background)
The two contracts in this article are included alongside it as contracts/age-check-v1-naive.compact and contracts/age-check-v2-anchored.compact. Both were compiled successfully with the Compact toolchain (v1 with 0.31.1, 0.34.0 and 0.35.0; v2 with 0.34.0 and 0.35.0).

Replies
Discussion
Join the discussion
Sign in with Google to reply. You stay on this article while you sign in.
No replies yet
Sign in with Google to start the discussion.