Designing Public vs. Private State in Compact: What Goes Where and Why
One of the first decisions you make when writing a Midnight contract is which state goes on-chain and which stays off-chain. This isn't just a privacy concern — it affects proof size, gas costs, user experience, and whether your protocol is even secure. Get it wrong in one direction and you leak data you promised to keep private. Get it wrong in the other direction and your contract becomes unusable because clients can't reconstruct enough state to generate proofs.
This guide walks through the decision framework and gives concrete examples of common patterns.
The Core Model: On-Chain vs. Off-Chain State
Midnight is not a typical blockchain where all state is public by default. It has two distinct kinds of state:
Public ledger state lives on-chain. Every node can read it. It's committed to by the block hash and verifiable by anyone. This includes things like: merkle roots, nullifier sets, nonces, aggregate totals, and anything your protocol needs to be publicly auditable.
Private state is held by individual users. It's not posted to the chain. The user's wallet or dapp stores it locally, and ZK proofs let users prove facts about their private state without revealing it. Token balances, credential data, private identity attributes — these stay with their owner.
The ZK proofs themselves are what bridge the two worlds. A user generates a proof that says "I have enough balance to spend X, and I'm spending it to address Y" — without revealing their actual balance, their history, or anything else about their account.
This architecture has a specific consequence: your public state needs to be sufficient for verifying proofs, but not so rich that it leaks private information.
Defining State in Compact
In Compact, state is declared with the ledger keyword for on-chain state and witness for private inputs to circuits:
contract PrivateToken {
// PUBLIC: on-chain, visible to everyone
ledger merkleRoot: Bytes<32>; // root of the commitment tree
ledger nullifierSet: Set<Bytes<32>>; // spent commitments
ledger totalSupply: Uint<64>; // aggregate supply (optional, may leak info)
// PRIVATE: these never appear on-chain
// They're passed as witnesses when generating proofs
circuit transfer(
witness senderBalance: Uint<64>, // private
witness senderSecret: Bytes<32>, // private
witness recipientAddress: Bytes<32>, // private
public amount: Uint<64>, // public: prover explicitly reveals this
public nullifier: Bytes<32> // public: needed for on-chain nullifier check
) {
// ...
}
}
The distinction is clear in the syntax: ledger declares persistent on-chain state; witness declares private inputs that exist only during proof generation.
Concrete Examples
Token Balances: Private
Individual token balances should almost always be private. Why? Because balances on a public ledger are immediately deanonymizing. If address A has 10,000 tokens and spends 7,000 to address B, an observer learns: A's balance history, the transaction amount, and can start building a graph of who transacts with whom. This is exactly the data that private blockchains are supposed to hide.
The pattern for private balances uses commitments:
// A commitment is H(amount, secret) — hides the amount but binds to it
// The merkle root on-chain contains these commitments, not raw balances
contract PrivateTokenBalance {
ledger commitmentRoot: Bytes<32>; // merkle root of commitment tree
ledger nullifierSet: Set<Bytes<32>>; // prevents double-spending
circuit spend(
witness balance: Uint<64>, // private: actual balance
witness secret: Bytes<32>, // private: commitment secret
witness merkleProof: MerkleProof, // private: proof of inclusion
public nullifier: Bytes<32>, // public: H(secret) — reveals nothing about balance
public recipient: Bytes<32>, // public: where funds go
public amount: Uint<64> // public: spending amount (revealed)
) {
// Verify the commitment exists in the tree
const commitment: Bytes<32> = hash(balance, secret);
assert merkleProof.verify(commitmentRoot, commitment) : "invalid merkle proof";
// Verify nullifier is correct (prevents reuse without revealing secret)
assert nullifier == hash(secret, 1) : "invalid nullifier";
// Verify sufficient balance
assert balance >= amount : "insufficient balance";
// Verify nullifier hasn't been used
assert !nullifierSet.contains(nullifier) : "already spent";
}
}
What's on-chain: the merkle root (proves a set of commitments exists), nullifiers (prevents double-spending), recipient and amount (the spending details). What stays private: the actual balance, the secret, the full transaction history.
Nonces: Public
Nonces need to be public because their purpose is preventing replay attacks — and that prevention only works if the network can verify a nonce has been used. A private nonce doesn't stop replays; it just means the attacker can replay it without anyone knowing.
contract NonceProtected {
ledger nonces: Map<Bytes<32>, Uint<64>>; // accountId -> nonce, fully public
circuit executeWithNonce(
witness accountSecret: Bytes<32>, // private: proves ownership
public accountId: Bytes<32>, // public: account identifier
public nonce: Uint<64>, // public: must match stored nonce
public action: Bytes<256> // public: the action being authorized
) {
// Verify account ownership
assert hash(accountSecret) == accountId : "invalid account secret";
// Verify nonce matches (prevents replay)
assert nonces.get(accountId) == nonce : "invalid nonce";
}
}
Making the nonce public doesn't leak the user's secret or the content of their actions — it just lets verifiers confirm "this specific sequence has been processed" without anyone being able to replay an old proof.
Merkle Roots: Public
Merkle roots summarize a large set of private data in a single public commitment. They're the canonical tool for making ZK proofs work at scale:
- The root commits to the full set of valid states
- A user can prove their specific state is in the set (via a merkle path witness)
- The root is updated when states change
- No individual leaf is revealed
contract MerkleStateStore {
ledger stateRoot: Bytes<32>; // commits to all current valid states
ledger epoch: Uint<32>; // tracks state version
circuit proveInclusion(
witness leafData: Bytes<256>, // private: the actual state being proven
witness leafIndex: Uint<32>, // private: position in tree (leaks set size if exposed)
witness siblings: MerkleProof, // private: the merkle path
public leafHash: Bytes<32> // public: hash of leaf (can reveal if preimage is guessable)
) {
// Verify the leaf is in the current tree
assert siblings.verify(stateRoot, leafIndex, leafData) : "not in state tree";
// Bind the public hash to the private data
assert hash(leafData) == leafHash : "hash mismatch";
}
}
Performance Implications
Proof size grows with circuit complexity, and circuit complexity grows with the amount of private state you're manipulating. A few rules of thumb:
Public inputs are cheap. They go into the proof's public statement and don't require constraint generation beyond basic type checking. If you need to expose something, making it public rather than witness reduces proof size.
Every private bit costs constraints. A Uint<64> witness requires roughly 64 constraints to range-check. A Bytes<32> witness (256 bits) costs more. Complex private data structures (merkle proofs with 20 levels, for example) can add thousands of constraints.
Merkle depth is a tradeoff. Deeper trees hold more state (good for privacy — more leaves to hide among) but merkle proofs have more siblings to verify (bad for proof size). A tree of depth 20 can hold ~1 million leaves, but a proof has 20 hash verifications.
Private state that must be reconstructed is expensive. If your circuit requires the user to provide their full history as witnesses to prove a current state, proof generation time grows with history length. Design state to be compact and forward-only where possible.
Real-World Patterns
Voting: Nullifier-Based
In a private voting system, you need to guarantee that each eligible voter votes exactly once, without revealing who voted for what.
contract PrivateVote {
ledger eligibleVotersRoot: Bytes<32>; // public: root of eligible voter set
ledger nullifierSet: Set<Bytes<32>>; // public: tracks who has voted (anonymously)
ledger voteCount: Map<Uint<8>, Uint<32>>; // public: tally per option
circuit castVote(
witness voterSecret: Bytes<32>, // private: proves eligibility
witness voterIndex: Uint<32>, // private: position in eligibility tree
witness merkleProof: MerkleProof, // private: proof of eligibility
public nullifier: Bytes<32>, // public: H(voterSecret) — proves uniqueness
public voteOption: Uint<8> // public: the vote itself
) {
// Prove voter is in the eligible set
const voterCommitment: Bytes<32> = hash(voterSecret);
assert merkleProof.verify(eligibleVotersRoot, voterIndex, voterCommitment)
: "not an eligible voter";
// Prove nullifier is derived from voter secret (can't fake it)
assert nullifier == hash(voterSecret, 0xVOTE) : "invalid nullifier";
// Prove they haven't voted before
assert !nullifierSet.contains(nullifier) : "already voted";
// Vote option is valid
assert voteOption < 4 : "invalid vote option";
}
}
What's public: the eligibility root (anyone can verify the set is correct), nullifiers (proves unique votes without identity), vote tallies. What's private: voter identity, which nullifier belongs to which person, the merkle path that proves eligibility.
Ownership Proofs: Credential-Based
For compliance scenarios, you might need to prove ownership of an asset or credential without revealing the asset itself:
contract OwnershipProof {
ledger credentialRegistryRoot: Bytes<32>; // public: root of valid credential set
circuit proveOwnership(
witness credentialData: Bytes<512>, // private: the actual credential
witness issuanceSecret: Bytes<32>, // private: secret from issuance
witness merkleProof: MerkleProof, // private: credential is in registry
public credentialHash: Bytes<32>, // public: H(credentialData) — binding but not revealing
public claimType: Uint<16> // public: what type of ownership is being claimed
) {
// Verify credential is registered
assert merkleProof.verify(credentialRegistryRoot, credentialData)
: "credential not in registry";
// Bind the public hash to the private data
assert hash(credentialData) == credentialHash : "hash mismatch";
// Verify the claim type is supported by this credential
// (credential format-specific logic here)
const claimField: Uint<16> = credentialData.slice(0, 2) as Uint<16>;
assert claimField & claimType == claimType : "credential doesn't support this claim";
}
}
Compliance Attestations: Selective Public State
Sometimes you need a regulator to be able to audit specific things while keeping other things private. This calls for selective public state:
contract ComplianceToken {
ledger totalSupply: Uint<64>; // public: regulators need aggregate data
ledger auditRoot: Bytes<32>; // public: root of audit trail
ledger sanctionedAddresses: Set<Bytes<32>>; // public: blocked addresses
// NOTE: individual balances are NOT in ledger — they're private
circuit transfer(
witness senderBalance: Uint<64>,
witness senderSecret: Bytes<32>,
witness auditKey: Bytes<32>, // private: regulator's audit key for this tx
public senderNullifier: Bytes<32>,
public recipientCommitment: Bytes<32>,
public amount: Uint<64>,
public auditToken: Bytes<32> // public: encrypted audit record for regulator
) {
// ... transfer logic ...
// Prove audit token is valid for this transfer
// (regulator can decrypt with their key, others cannot)
assert auditToken == encrypt(auditKey, hash(amount, senderNullifier, recipientCommitment))
: "invalid audit token";
}
}
The regulator gets auditToken on-chain. With their key, they can decrypt it and see the full transfer details. Without the key, an observer just sees random bytes. This is selective disclosure at the state level.
Anti-Patterns: Accidentally Leaking Private Data
Anti-Pattern 1: Committing Correlatable Public Data
If you put both senderNullifier and recipientAddress on-chain in the same transaction, you've implicitly revealed that the sender and recipient interacted. Even if the actual amounts are private, the graph of who-transacted-with-whom is public.
Solution: use recipient commitments instead of addresses. The recipient reveals their address only to the sender (off-chain), who commits to it in the proof.
Anti-Pattern 2: Total Supply as a Side Channel
Posting totalSupply on-chain seems harmless, but if your protocol mints a commitment per user, an observer can watch total supply grow and infer when new users join. Sometimes that's acceptable; sometimes it's a privacy violation.
If total supply must be public for economic reasons, consider batch minting or delayed reveals.
Anti-Pattern 3: Merkle Tree Size Leaking Set Size
A merkle tree of depth D can hold up to 2^D leaves. But how many leaves are actually in the tree is a separate question. If you use sequential insertion, the tree's current fill level leaks how many participants there are.
// Leaks: how many commitments exist (tree fill level is observable)
ledger nextLeafIndex: Uint<32>; // public counter
// Better: use sparse trees where leaf position is derived from commitment,
// not from insertion order
Anti-Pattern 4: Using Witness Data Directly in Public Output
circuit badProof(
witness secretValue: Uint<64>,
public result: Uint<64>
) {
// BAD: result reveals secretValue completely
assert result == secretValue * 2 : "must be double";
// If result is public, and result = secretValue * 2,
// observers can compute secretValue = result / 2
}
If you're publishing a function of a private value, think about whether that function is invertible. Linear functions of a single secret are almost always invertible. Hash functions aren't.
Decision Framework
When you're deciding whether something goes in ledger (public) or witness (private):
| Question | Yes → | No → |
|---|---|---|
| Does the network need to verify this to check proofs? | Public | Can stay private |
| Is this needed by multiple independent users? | Public | Can stay private |
| Would revealing this break user privacy? | Keep private | Can be public |
| Is this needed to prevent double-spending/replay? | Public | Can stay private |
| Does this need to persist across multiple transactions? | Public ledger | Can be local state |
Most contracts end up with a handful of public anchors (roots, nullifier sets, nonces) and a rich set of private state that users manage locally.
Summary
The public/private split in Midnight is a design choice with real consequences. Get it right by:
- Putting anchors on-chain — roots, nullifiers, nonces, and aggregates that validators need to verify proofs
- Keeping sensitive data off-chain — balances, identity attributes, transaction details belong with users
- Using commitments as the bridge — commit to private values, prove properties about them without revealing them
- Auditing your public state for correlatable information — even individually innocuous public fields can leak information when combined
- Sizing your trees and types carefully — proof cost scales with private state complexity, not public state
The goal is a contract where everything on-chain is either necessary for security or explicitly intended to be public — and nothing else.
Top comments (0)