DEV Community

What Midnight Actually Gives You: Privacy, Ledger, and Time in Compact

I'm sure a lot of people know this already, but just to reiterate: Midnight is a privacy-preserving blockchain, and its smart-contract language, Compact, is built around a "private by default" model. That model is powerful, but it's easy to misread.

The same handful of misconceptions kept coming upin my own code, in questions from other builders, and in projects I looked at:

  • "I marked the field sealed, so it's hidden on-chain." It isn't.
  • "I'll read the current block height in my circuit and store it." You can't.
  • "My contract discloses everything, but it's on Midnight, so it's private." It isn't.
  • "I shipped a contract, so I shipped a dApp." Usually not the whole story.

None of these are exotic edge cases. They come from treating a Compact language feature as if it were a privacy feature, or treating on-chain state as if it were private. This is my attempt to draw those lines clearly, builder to builder, so you can start with an accurate mental model instead of learning it the hard way like I did.

It's deliberately conceptual, and it links to the official docs as the source of truth rather than restating every API always trust those over me.


The mental model: public ledger vs private computation

Every Compact contract operates across three distinct contexts. Getting these straight is the single most important thing for building correct, private applications.

Context Where it runs Visibility
Public ledger On-chain Visible to everyone (via the indexer)
Zero-knowledge circuit On-chain verification Logic is proven correct; private inputs are not revealed
Local / off-chain computation User's machine Never leaves the user unless explicitly disclosed

The flow looks like this:

  private inputs            circuit (ZK)               ledger (public)
  ────────────────          ───────────────            ────────────────
  private state       →     prove correctness    →     commitments,
  circuit params            without revealing          hashes, public
  witness values            the secret inputs          results
Enter fullscreen mode Exit fullscreen mode

What stays private: private inputs (private state, private circuit parameters, witness-returned values) and anything derived from them until you explicitly disclose() them.

What is always public: every ledger field. If a value lives in ledger state, anyone can read it through the indexer, regardless of how it got there.

Privacy on Midnight does not come from "putting data on a private chain." It comes from a combination of tools:

  1. Private state & private circuit parameters — data kept off the public ledger, managed by Midnight.js in your frontend.
  2. Witnesses — off-chain logic the circuit can call (more on these below they're not just "the private state accessor").
  3. Commitments / hashes — publishing a binding fingerprint of a value instead of the value itself.
  4. Zero-knowledge proofs — proving a statement about private data is true without revealing the data.

If a contract calls disclose() on everything and stores raw values in ledger fields, it is not demonstrating Midnight's purpose it's a public smart contract that happens to run on Midnight.

Rule of thumb: Ask "what can an observer read from the ledger?" If the sensitive value is in there in the clear, it is public, full stop.


Witness ≠ private state

This is a nuance I originally got wrong,so it's worth stating plainly: witnesses are not the same thing as private state, and witnesses are not only for reading secrets.

Private state is managed by Midnight.js in your frontend (e.g. via levelPrivateStateProvider). You can pass private data into circuits as parameters and keep it off the public ledger without any witness functions at all. The private party tutorial demonstrates exactly this it's compiled with VacantWitnesses and passes private data in through circuit parameters. See also example-private-party.

Witness functions are off-chain implementations the circuit invokes. Reading from private state (secret keys, moves, salts) is the most common demo, but it only scratches the surface. Witnesses can also:

  • Offload computation Compact can't express natively e.g. division in the calculator example: implement it in TypeScript, then enforce correctness with on-chain asserts.
  • Extend what's practical to do in-circuit.
  • Modify private state as part of circuit execution.

So throughout the rest of this article, read witnesses as one tool among several not a synonym for "private data." Private state, private parameters, witnesses, and commitments all sit on the private side of the boundary; the ledger stays public.


sealed is about mutability, not privacy

This is the most common conceptual error I see.

The misconception

"Mark the ownership field as sealed to make the owner completely invisible on-chain."

This is wrong, and it's a security-relevant kind of wrong. A developer following that advice could ship a contract where they believe ownership is hidden, when in fact the owner is fully public.

What sealed actually does

sealed makes a ledger field write-once at initialization. After the constructor (and any helper circuits it calls) runs, no exported circuit can modify a sealed field. The compiler enforces this at compile time.

sealed ledger config: Uint<32>;

constructor(x: Uint<16>) {
  config = 2 * x;      // OK: set during initialization
}

export circuit modify(): [] {
  config = 10;         // Compilation error: sealed field
}
Enter fullscreen mode Exit fullscreen mode
  • Unsealed (default): exported circuits can modify the field.
  • Sealed: set only during initialization; immutable afterward.

That's the entire feature. It affects mutability, not visibility. A sealed field is exactly as public as an unsealed one every ledger field is readable through the indexer.

Two orthogonal axes

Mutable Immutable
Public default ledger field sealed ledger field
Private private state commitment stored once

Privacy and mutability are independent. sealed moves you along the mutability axis only.

If you actually want private ownership

Don't store the owner's identity in the clear. Instead:

  1. Derive a DApp-specific identity in-circuit from a secret using persistentHash with a domain separator.
  2. Store the resulting hash (or a commitment) as the authority.
  3. On privileged calls, re-derive from the caller's secret and assert equality.
witness localSecretKey(): Bytes<32>;

circuit ownerId(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:owner:v1"), sk]);
}
Enter fullscreen mode Exit fullscreen mode

The stored ownerId is public, but it reveals nothing about the secret behind it. That's the privacy not the sealed keyword.

Reference: Sealed vs. unsealed ledger fields


Block time ≠ block height ≠ now()

The second recurring trap is about time, and it has two layers.

What Compact exposes

Compact's standard library gives you exactly four block-time primitives — all comparators, returning a Boolean:

Function True when
blockTimeLt(t) current block time < t
blockTimeLte(t) current block time t
blockTimeGt(t) current block time > t
blockTimeGte(t) current block time t

t is a Uint<64> seconds since the Unix epoch. Use these for deadlines, phase gates, vesting, auctions.

export circuit endAuction(deadline: Uint<64>): [] {
  assert(blockTimeGte(disclose(deadline)), "Auction not over yet");
  auctionActive = disclose(false);
}
Enter fullscreen mode Exit fullscreen mode

What Compact does NOT expose

There is no readable value for the current time or block position inside a circuit:

  • No now()
  • No blockTimestamp()
  • No blockHeight()

You can compare against the current block time, but you cannot read it as a value and assign it to a ledger field.

The consequences

Two instructions that sound reasonable are actually impossible as written:

  1. "Store the current block height in the circuit context." There is no block-height primitive in-circuit. Block height exists in transaction metadata off-chain (e.g. result.public.blockHeight from the SDK / indexer after finalization)not as a value your contract can read during execution.

  2. "Record when a post was created by writing the current time into a ledger field." The contract can't read "now," so it can't snapshot it. If you pass a timestamp in from the frontend and just store it, that value is user-controlled and not a trustworthy record of when the transaction landed.

What to do instead

Goal Correct approach
"When was this posted?" (authoritative) Off-chain: result.public.blockHeight / block timestamp from the indexer after finalization
On-chain ordering A ledger Counter (sequence / id), not wall-clock time
Deadlines / expiry Store a threshold and enforce it with blockTimeGte / blockTimeLt

For an expiry feature ("post becomes removable after 30 minutes"), the workable single-transaction pattern is to store an expiresAt deadline at creation and check it at takedown:

export ledger expiresAt: Uint<64>;

export circuit post(msg: Opaque<"string">, deadline: Uint<64>): [] {
  assert(blockTimeLt(disclose(deadline)), "Expiry must be in the future");
  expiresAt = disclose(deadline);
  // ... store message ...
}

export circuit takeDown(): [] {
  assert(blockTimeGte(expiresAt), "Not expired yet");
  // ... clear message ...
}
Enter fullscreen mode Exit fullscreen mode

Note the honest caveat: because the contract can't read "now," it can't prove expiresAt == postTime + 30min. The poster commits to a deadline; the contract only checks it was in the future at post time and has passed at takedown. If exact "post instant + duration" enforcement matters, that needs a readable in-circuit block-time primitive, which does not exist today.

Reference: Compact standard library exports (search blockTime)


What "a working Midnight dApp" actually means

When I started, I assumed "the contract" was basically the project. It wasn't. A contract is necessary but it's usually only a slice of a working dApp, and I lost time realizing that late.

What I found a Midnight application actually needs to hang together:

  1. The Compact contract — using real privacy (private state, commitments, witnesses where appropriate), not everything disclosed.
  2. Witness implementations (TypeScript) — where the circuit declares them.
  3. SDK glue — providers for the indexer, proof server, private state, network id, and wallet.
  4. A prove → balance → submit path — either via headless Wallet SDK or the DApp Connector (Lace).
  5. Something a person can run — a CLI or UI you (or a friend) can actually follow from the README.

The gut-check I now run on my own projects before I call something "done":

  • Does it build from a fresh clone following only my README?
  • Does the contract show at least one real privacy feature?
  • Is it actually mine, not a trivial rename of example-counter / example-bboard?
  • Does it use real SDK APIs, not function names I halfremembered?

A contract that compiles felt like the finish line to me at first. It's really the starting line the dApp is that contract plus the path that lets someone else use it.


Failure modes you'll probably hit (and what they mean)

These are things I (and builders around me) actually ran into. Knowing them in advance saves hours.

Version matrix skew

Symptoms like Could not deserialize Ledger Event or Failed to decode ledger event payload during wallet sync are usually version mismatches, not contract bugs. Pin every component compact-runtime, ledger, all midnight-js-*, the wallet SDK packages, and the proof server to the same row of the compatibility matrix, and update them together, not piecemeal.

DUST regeneration limits concurrency

DUST regenerates from registered NIGHT at a bounded rate. If you fire many transactions from a single wallet in parallel, you'll hit Wallet.InsufficientFunds: could not balance dustregeneration, not CPU, is the binding constraint. Patterns that work: serialize submissions per wallet, pre-warm DUST before load, or use multiple wallets for parallel lanes.

Prove and submit are coupled

The high-level submitCallTx / callTx helpers fuse build → prove → balance → submit. If you need to parallelize proving and pipeline submission, drop to the lower-level pipeline (createUnprovenCallTxproofProvider.proveTxwalletProvider.balanceTxmidnightProvider.submitTx). Even then, keep balance+submit serialized per wallet because of DUST.

Stale state after an interrupted run

If a transaction lands on-chain but your client dies mid-flight, local private state can get ahead of the chain, and retries fail on stale-state asserts. You do not need a full redeploy. Re-read on-chain state as the source of truth (findDeployedContract + queryContractState / getPublicStates), and reset local private state to match before retrying.

First sync can be slow

First-time wallet sync on public testnets can take a long time minutes on Preview, potentially much longer on Preprod and memory scales with history. Budget for it; a long first sync is often normal, not a hang.


A short, honest summary

  • The ledger is public. Privacy comes from keeping data in private state / private inputs, plus commitments and proofs not from where the data lives.
  • Witness ≠ private state. Private state can be managed with zero witnesses; witnesses can also offload computation Compact can't do natively.
  • sealed means immutable, not invisible. It's a mutability control; every ledger field is still readable.
  • You can compare against block time, but you can't read it. No now(), no blockHeight() in-circuit. Get authoritative time/height from off-chain tx metadata; use blockTime* for enforcement and a Counter for ordering.
  • A dApp is more than a contract. It's the contract plus the witnesses, SDK wiring, and a runnable path.
  • Most "bugs" early on are version skew or DUST/timing constraints not contract logic.

Build with the public/private boundary in mind from the first line of Compact you write, and most of these traps disappear.


References

Top comments (0)