Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

Propertyexport ledgerPrivate state (witnesses)
Where it livesEvery network nodeUser’s local storage
Who can read itEveryoneOnly the owner
On-chain representationPlaintext valueNothing (commitment or nothing)
How it’s updatedVia ZK proofNever touches the chain
Update mechanismLedger assignmentWitness 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
ModifierMeaning
exportReadable from TypeScript (your DApp)
sealedWriteable only during initialization
  • ledger without modifiers = basic, non-exported field
  • export ledger = readable by your DApp
  • sealed ledger = writeable during initialization only
  • export 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 caseWhy sealed fits
Contract parameters (fee rates, caps)Should never drift after deployment
Initial authority / owner keyPrevents admin key rotation attacks
Fixed metadata (collection name, token symbol)Correctness invariant
Domain separators used in hashingMust be stable for commitment verification

When not to use sealed:

Use caseWhy unsealed fits
Balances, counters, state flagsMust change during operation
Accumulated data (nullifier sets, Merkle trees)Must grow
Anything governance can updateChange 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:

CandidateWhy it belongs on-chain
Global invariants (total supply, reserve balance)Everyone needs to see them
State flags others react toNeeded for coordination
Commitments to private values (the hash, not the value)Proves existence without revealing
Data your frontend needs to read directlyOtherwise you can’t display it

Bad candidates:

CandidateWhy it doesn’t belong on-chain
Per-user balancesOnly one user cares
Personal dataPrivacy violation
Any value belonging to only one userNo 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

FunctionOutputPersists?For ledger?Witness-tainted?
transientHashFieldNoNoYes
transientCommitFieldNoNoNo
persistentHashBytes<32>YesYesYes
persistentCommitBytes<32>YesYesNo
  • 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: persistentCommit results do not require disclose(). The random salt provides enough hiding that the compiler considers the output safe for ledger storage. persistentHash results are still witness-tainted and do require disclose().


Ledger-State Types

TypeWhat it is
T (any type)A single Cell<T>, readable and writable
CounterUnsigned 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
KernelBuilt-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:

ADTOperationWhat 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 caseADT
Single mutable valueledger f: T (Cell)
Monotonically growing counter (low contention)Counter
Membership trackingSet<T>
Per-key storageMap<K, V>
Ordered queueList<T>
ZK membership proofs (current root only)MerkleTree<n, T>
ZK membership proofs (any past root)HistoricMerkleTree<n, T>
Block time, tokens, contract addressKernel

Kernel Operations

The Kernel type provides built-in blockchain operations. Full reference:

OperationWhat 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

  1. Treating export ledger as encrypted. It isn’t. Everything in export ledger is plaintext and readable by everyone. If you need privacy, use the commitment pattern.

  2. Not using sealed for fields that should never change. If a field is a deployment parameter or a fixed authority key, mark it sealed. The compiler then enforces the guarantee, you don’t have to.

  3. 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.

  4. 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.

  5. 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.

  6. Using transientHash for ledger storage. Transient values don’t survive contract upgrades. Use persistentHash or persistentCommit for anything that lives in export ledger.

  7. 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

ConceptSolidityRustCompact
Public stateuint256 publicVarstorage fieldsexport ledger f: T
Immutable stateimmutableconstsealed ledger f: T
Private stateprivate uint256 (still on-chain)u64 in memorywitness (stays local)
State updatesdirect assignmentstorage.write()via ZK proof
Reading stateContract.state()direct readledger(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.
  • sealed fields are immutable after deployment. The compiler enforces this, any exported circuit that writes a sealed field is a compile error.
  • Use sealed for 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.
  • persistentCommit does not require disclose(). The random nonce provides sufficient hiding.