Sealed Ownership on Midnight: Why `sealed` Doesn't Mean Private (And What Does)
The Midnight bboard example stores the owner public key hash on the public ledger. Anyone with an indexer can track who owns which post. We set out to hide it — and learned that Compact's privacy model is more nuanced than a single keyword.
This post documents the implementation of ownership hiding with selective disclosure, including a compiler-enforced lesson about what sealed actually means in Compact 0.21.
The Goal
Take the bulletin board contract and make ownership invisible. Instead of exposing a 32-byte identifier that acts as a persistent fingerprint, provide a ZK circuit that answers one question: "Are you the owner?" — yes or no, nothing more.
This maps directly to GDPR Article 25 (privacy by design): minimize data exposure by default, verify only when necessary, disclose only what's needed.
The First Attempt: sealed ledger owner
The intuitive approach:
// Looks right — "sealed" should mean "private", no?
sealed ledger owner: Bytes<32>;
The compiler disagreed:
Exception: bboard.compact line 41 char 1:
exported circuits cannot modify sealed ledger fields
but post calls (directly or indirectly)
post, which modifies sealed field owner at line 43 char 3
This isn't a bug — it's a fundamental design choice. In Compact 0.21, sealed means immutable after the constructor, enforced via static call-graph analysis. The compiler traces every path from every exported circuit and rejects any that could write to a sealed field, even indirectly through helper circuits.
Think Solidity's immutable, not "encrypted" or "hidden."
The Three Visibility Axes of Compact
This discovery revealed that Compact controls privacy through three independent mechanisms, not one:
| Mechanism | What It Controls | Analogy |
|---|---|---|
export on ledger field | Whether the field appears in the generated TypeScript Ledger type | public vs private in OOP |
sealed on ledger field | Whether the field can be written after the constructor | immutable in Solidity |
disclose() on values | Whether witness-derived data crosses the ZK privacy boundary | Explicit declassification |
For our use case — a field that changes at runtime but should be invisible to DApp consumers — removing export is the correct lever:
// BEFORE: exported — visible in generated Ledger type
export ledger owner: Bytes<32>;
// AFTER: non-exported — hidden from generated Ledger type
ledger owner: Bytes<32>;
The Compact compiler excludes non-exported fields from the generated TypeScript Ledger type. The field still exists in the contract's internal state, fully accessible inside circuits for authorization and comparison. But the DApp layer — API, CLI, indexers reading the TypeScript types — cannot see it.
The revealOwnership Circuit
With ownership hidden, we need a way to prove it. The new circuit uses selective disclosure:
export circuit revealOwnership(): Boolean {
assert(state == State.OCCUPIED, "No post to verify ownership of");
return disclose(owner == publicKey(localSecretKey(), sequence as Field as Bytes<32>));
}
The key line is disclose(owner == publicKey(...)). The comparison happens inside the circuit where the non-exported owner is accessible. Only the boolean result crosses the privacy boundary via disclose(). The caller learns "yes, you are the owner" or "no, you're not" — never the key itself.
What Changed Across the Stack
The contract change ripples through every layer:
API — BBoardDerivedState no longer computes isOwner by comparing hashes client-side. It reports ownershipSealed: true — ownership cannot be determined from public data:
export type BBoardDerivedState = {
readonly state: State;
readonly sequence: bigint;
readonly message: string | undefined;
readonly ownershipSealed: true;
};
The state$ observable simplifies from a combineLatest (merging public + private state) to a single pipe on public state only. No private state needed — there's nothing to compare against.
CLI — The owner field displays <sealed>, and a new menu option calls revealOwnership for ZK-based verification:
Current owner is: '<sealed>'
...
7. Verify ownership (ZK proof)
Tests — 14 tests pass, including 5 new ones covering revealOwnership happy path, non-owner rejection, empty board assertion, owner absence from Ledger type, and functional equivalence of takeDown with hidden ownership.
Privacy Properties: Before vs After
| Property | Before | After |
|---|---|---|
| Owner key in Ledger type | Yes (exported) | No (non-exported) |
| Owner readable by DApps | Yes (ledger.owner) | No |
| Ownership verification | Hash comparison client-side | ZK proof (boolean only) |
| TakeDown authorization | Same internal logic | Unchanged — non-exported fields work in circuits |
| Information leaked via API | 32-byte identifier per post | Nothing |
Trade-offs
No passive ownership detection. The original design let any DApp check if a key owned the current post by reading the public ledger. Now this requires an active revealOwnership circuit call. This is the point — it prevents surveillance.
Circuit call costs gas. revealOwnership is an impure circuit (reads non-exported state), so on a real network it requires a transaction. Ownership verification is no longer "free" from the ledger API.
Defense in depth, not absolute. The owner hash still exists in the contract's internal state. A sufficiently advanced indexer with direct data store access could potentially extract it. Full ZK-level privacy would require Compact's future shielded state features. What we achieve today is API-level privacy — the DApp layer cannot see ownership, which is the layer that matters for GDPR compliance.
The GDPR Parallel
This implementation demonstrates three GDPR principles in code:
- Data minimization (Art. 5(1)(c)) — The public API exposes only what's necessary: board state, message, sequence. Ownership is hidden by default.
- Privacy by design (Art. 25(1)) — No configuration or opt-in needed. The contract is private from deployment.
- Purpose limitation (Art. 5(1)(b)) — Ownership data is accessible only for its intended purpose (authorization in
takeDown, voluntary proof inrevealOwnership), never broadcast.
The revealOwnership circuit is the ZK equivalent of showing a bouncer that you're over 21 without revealing your birth date. You prove a fact about yourself without revealing the underlying data.
Reproducing
cd contract
npm run compact # 3 circuits, zero errors
npx tsc --noEmit # TypeScript types pass
npx vitest run # 14/14 tests pass
PR: fredericosanntana/example-bboard#1
Full technical write-up: README-CAPSTONE.md
