Ledger State
This note explains the ledger, Midnight’s public state layer, and how it relates to private state.
Docs: Ledger ADT · Ledger State · Smart Contract Security Examples: 04.01 Commitment Pattern
Intuition First
The ledger is Midnight’s public world. Every node on the network stores it. Everyone can read it.
Private data (witnesses) lives on the user’s local machine and never touches the chain. The two are connected by disclose(), a compile-time annotation that marks intentional disclosure.
The key insight: privacy is the default, not opt-in. Private data stays private unless you explicitly mark it for disclosure.
The Two Worlds
| Property | export ledger | Private state (witnesses) |
|---|---|---|
| Where it lives | Every network node | User’s local storage |
| Who can read it | Everyone | Only the owner |
| On-chain representation | Plaintext value | Nothing (commitment or nothing) |
| How it’s updated | Via ZK proof | Never touches the chain |
| Update mechanism | Ledger assignment | Witness callbacks |
Ledger State Updates
The ledger update happens atomically with the proof. Either the proof is valid and the state changes, or it isn’t and nothing changes.
Declaring Ledger Fields
ledger val: Field; // basic field
export ledger cnt: Counter; // exported, readable
sealed ledger config: Uint<32>; // write-once
export sealed ledger mapping: Map<Boolean, Field>; // exported + sealed
| Modifier | Meaning |
|---|---|
export | Readable from TypeScript (your DApp) |
sealed | Writeable only during initialization |
ledgerwithout modifiers = basic, non-exported fieldexport ledger= readable by your DAppsealed ledger= writeable during initialization onlyexport sealed ledger= both
All ledger fields initialize to their type’s default (zero, empty, first variant, etc.). The constructor can override them.
Sealed vs. Unsealed Fields
sealed is the strongest guarantee you can make about a ledger field: it can never change after deployment.
A sealed field can only be written during contract initialization, either directly in the constructor body, or in helper circuits the constructor calls. Once deployment completes, sealed fields are frozen. Any exported circuit that attempts to write a sealed field is a compile-time error, not a runtime one.
sealed ledger config: Uint<32>;
export sealed ledger authority: Bytes<32>;
circuit initAuthority(sk: Bytes<32>): [] {
authority = disclose(persistentHash<Vector<2, Bytes<32>>>([pad(32, "domain"), sk]));
}
constructor(configValue: Uint<32>) {
config = disclose(configValue); // valid: inside constructor
initAuthority(localSecretKey()); // valid: helper called by constructor
}
export circuit update(): [] {
config = 10; // ❌ compile error: sealed field
}
When to use sealed:
| Use case | Why sealed fits |
|---|---|
| Contract parameters (fee rates, caps) | Should never drift after deployment |
| Initial authority / owner key | Prevents admin key rotation attacks |
| Fixed metadata (collection name, token symbol) | Correctness invariant |
| Domain separators used in hashing | Must be stable for commitment verification |
When not to use sealed:
| Use case | Why unsealed fits |
|---|---|
| Balances, counters, state flags | Must change during operation |
| Accumulated data (nullifier sets, Merkle trees) | Must grow |
| Anything governance can update | Change is the point |
The difference between a sealed and unsealed field in circuit security terms: with unsealed fields, you need to verify state hasn’t been tampered before acting on it. With sealed fields, the compiler guarantees it for you.
The disclose() Boundary
disclose() is a compile-time annotation, not encryption. It tells the compiler: “I am intentionally disclosing witness data.”
// Without disclose(): compiler error
export circuit record(): [] {
stored = getSecret(); // error: potential witness disclosure
}
// With disclose(): compiles
export circuit record(): [] {
stored = disclose(getSecret()); // ok: declared
}
The compiler tracks witness data through every operation, arithmetic, type conversions, function calls. If witness data could reach the ledger without disclose(), you get a compiler error.
What this means: You cannot accidentally leak private data. The compiler enforces the privacy boundary.
Place disclose() strategically
Position disclose() as close to the disclosure point as possible. Disclosing earlier than necessary means more of your computation is considered public, which narrows what the ZK proof is actually hiding.
// ✅ Good: disclose at the point of use
export circuit store(flag: Boolean): [] {
const secret = getSecret();
const derived = computeValue(secret); // still private
result = disclose(flag) ? disclose(derived) : 0;
}
// ❌ Bad: early disclosure increases risk
export circuit store(flag: Boolean): [] {
const secret = disclose(getSecret()); // disclosed too early
const derived = computeValue(secret);
result = disclose(flag) ? derived : 0;
}
When to Use export ledger
Good candidates for export ledger:
| Candidate | Why it belongs on-chain |
|---|---|
| Global invariants (total supply, reserve balance) | Everyone needs to see them |
| State flags others react to | Needed for coordination |
| Commitments to private values (the hash, not the value) | Proves existence without revealing |
| Data your frontend needs to read directly | Otherwise you can’t display it |
Bad candidates:
| Candidate | Why it doesn’t belong on-chain |
|---|---|
| Per-user balances | Only one user cares |
| Personal data | Privacy violation |
| Any value belonging to only one user | No one else needs it |
Heuristic: If removing this field would break another user’s ability to interact with the contract, it belongs in export ledger.
The Commitment Pattern
If export ledger puts values on-chain as plaintext, and private state keeps values off-chain entirely, how do you verify something about private state?
Answer: commitments. Store the hash on-chain. Keep the value off-chain. Prove knowledge of the value without revealing it.
export ledger balanceCommitments: Map<Bytes<32>, Bytes<32>>;
export circuit commitBalance(value: Uint<64>): [] {
const nonce = freshNonce();
const commitment = persistentCommit<Uint<64>>(value, nonce);
balanceCommitments.insert(disclose(callerAddress()), disclose(commitment));
}
Critical: The nonce must never be reused. Two commitments with the same nonce and value are identical on-chain.
Commitment Tools
| Function | Output | Persists? | For ledger? | Witness-tainted? |
|---|---|---|---|---|
transientHash | Field | No | No | Yes |
transientCommit | Field | No | No | No |
persistentHash | Bytes<32> | Yes | Yes | Yes |
persistentCommit | Bytes<32> | Yes | Yes | No |
- Persistent: Survives contract upgrades. Use for long-term storage.
- Transient: Does not survive upgrades. Use for temporary computations.
- Commit (vs hash): Includes a random nonce. The nonce hides the input even if the value is known. Use when the value might be guessable.
Note:
persistentCommitresults do not requiredisclose(). The random salt provides enough hiding that the compiler considers the output safe for ledger storage.persistentHashresults are still witness-tainted and do requiredisclose().
Ledger-State Types
| Type | What it is |
|---|---|
T (any type) | A single Cell<T>, readable and writable |
Counter | Unsigned counter with atomic increment (low contention) |
Set<T> | Unbounded set of unique values |
Map<K, V> | Unbounded key-value mapping |
List<T> | Unbounded ordered list (pushFront/popFront) |
MerkleTree<n, T> | Bounded Merkle tree of depth n (2 ≤ n ≤ 32) |
HistoricMerkleTree<n, T> | Like MerkleTree but retains past roots |
Kernel | Built-in operations (block time, tokens, address) |
Coin Operations
Several ADTs support direct coin insertion, bypassing the normal value path. This is how shielded tokens flow into ledger state:
| ADT | Operation | What it does |
|---|---|---|
Cell<T> | cell.writeCoin(coin, recipient) | Write a QualifiedShieldedCoinInfo directly |
Set<T> | set.insertCoin(coin, recipient) | Insert a shielded coin into the set |
Map<K, V> | map.insertCoin(k, coin, recipient) | Insert a shielded coin as a map value |
List<T> | list.pushFrontCoin(coin, recipient) | Push a shielded coin to the front |
Coin operations are used by token contracts to manage shielded assets without decomposing them into raw values.
Choosing the Right Type
| Use case | ADT |
|---|---|
| Single mutable value | ledger f: T (Cell) |
| Monotonically growing counter (low contention) | Counter |
| Membership tracking | Set<T> |
| Per-key storage | Map<K, V> |
| Ordered queue | List<T> |
| ZK membership proofs (current root only) | MerkleTree<n, T> |
| ZK membership proofs (any past root) | HistoricMerkleTree<n, T> |
| Block time, tokens, contract address | Kernel |
Kernel Operations
The Kernel type provides built-in blockchain operations. Full reference:
| Operation | What it does |
|---|---|
kern.self() | Returns this contract’s address |
kern.balance() | Native token balance |
kern.balanceGreaterThan(amount) | Check balance exceeds threshold |
kern.balanceLessThan(amount) | Check balance below threshold |
kern.blockTimeGreaterThan(time) | Check block time exceeds value |
kern.blockTimeLessThan(time) | Check block time below value |
kern.checkpoint() | Create an atomic execution boundary |
kern.claimContractCall(addr) | Verify a cross-contract call |
kern.claimUnshieldedCoinSpend(...) | Claim an unshielded coin spend |
kern.claimZswapCoinReceive(...) | Claim a Zswap coin receipt |
kern.claimZswapCoinSpend(...) | Claim a Zswap coin spend |
kern.claimZswapNullifier(...) | Claim a Zswap nullifier |
kern.incUnshieldedInputs(n) | Increment unshielded input counter |
kern.incUnshieldedOutputs(n) | Increment unshielded output counter |
kern.mintShielded(...) | Mint shielded tokens |
kern.mintUnshielded(...) | Mint unshielded tokens |
Common Mistakes
-
Treating
export ledgeras encrypted. It isn’t. Everything inexport ledgeris plaintext and readable by everyone. If you need privacy, use the commitment pattern. -
Not using
sealedfor fields that should never change. If a field is a deployment parameter or a fixed authority key, mark itsealed. The compiler then enforces the guarantee, you don’t have to. -
Forgetting that witnesses never touch the chain. Witness data stays local. Only the proof goes on-chain. You cannot store witness data directly, you must
disclose()it first. -
Reusing nonces. Two commitments with the same nonce and value are identical on-chain. Always use a fresh nonce. Nonce reuse across different commitment values can also link commitments, breaking privacy.
-
Putting per-user data in the ledger. If only one user cares about a value, it shouldn’t be in the ledger. It’s a privacy leak.
-
Using
transientHashfor ledger storage. Transient values don’t survive contract upgrades. UsepersistentHashorpersistentCommitfor anything that lives inexport ledger. -
Disclosing witness data earlier than needed. The earlier you call
disclose(), the more of your computation is treated as public. Keep values private as long as possible and disclose at the boundary.
Comparison Layer
| Concept | Solidity | Rust | Compact |
|---|---|---|---|
| Public state | uint256 publicVar | storage fields | export ledger f: T |
| Immutable state | immutable | const | sealed ledger f: T |
| Private state | private uint256 (still on-chain) | u64 in memory | witness (stays local) |
| State updates | direct assignment | storage.write() | via ZK proof |
| Reading state | Contract.state() | direct read | ledger(state) |
Quick Recap
- The ledger is public on-chain state. Everyone can read it.
- Private data (witnesses) stays local. Only the proof goes on-chain.
disclose()marks intentional disclosure. It’s a compile-time annotation, not encryption. Place it as close to the disclosure point as possible.sealedfields are immutable after deployment. The compiler enforces this, any exported circuit that writes a sealed field is a compile error.- Use
sealedfor contract parameters, authority keys, and fixed configuration values. - Store commitments on-chain. Keep values off-chain. Prove knowledge without revealing.
- Always use a fresh nonce for commitments. Reuse = privacy leak and potential linkability.
transient*doesn’t survive upgrades.persistent*does.persistentCommitdoes not requiredisclose(). The random nonce provides sufficient hiding.
Cross-Links
- Previous: Data Types The complete type system
- Next: Witnesses Private input mechanism
- See also: Explicit Disclosure The
disclose()boundary in depth - See also: Ledger ADTs Map, Set, MerkleTree details
- See also: Security and Best Practices Privacy patterns
- Examples: 04.01 Commitment Pattern