DEV Community

Tosh
Tosh

Posted on

Designing Public vs. Private State in Compact: What Goes Where and Why

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
  ) {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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";
  }
}
Enter fullscreen mode Exit fullscreen mode

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";
  }
}
Enter fullscreen mode Exit fullscreen mode

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";
  }
}
Enter fullscreen mode Exit fullscreen mode

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";
  }
}
Enter fullscreen mode Exit fullscreen mode

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";
  }
}
Enter fullscreen mode Exit fullscreen mode

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";
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Putting anchors on-chain — roots, nullifiers, nonces, and aggregates that validators need to verify proofs
  2. Keeping sensitive data off-chain — balances, identity attributes, transaction details belong with users
  3. Using commitments as the bridge — commit to private values, prove properties about them without revealing them
  4. Auditing your public state for correlatable information — even individually innocuous public fields can leak information when combined
  5. 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)