DEV Community

Cover image for You're Probably Using export ledger Wrong
Tushar Pamnani
Tushar Pamnani

Posted on • Edited on

You're Probably Using export ledger Wrong

Part 2 of building with Midnight. Part 1 here.

In the first article we built a bonding curve on Midnight and were honest about something most ecosystem articles aren't: the token balances in that contract are fully public. Not because Midnight can't do better; it can, but because we used export ledger for everything and wrapped every balance update in disclose().

That's the default pattern developers reach for when coming from Solidity, and it's the wrong instinct on Midnight.

This article is specifically about that decision: what export ledger actually means at the protocol level, what private state actually means, what disclose() is doing as a compile-time mechanism, and when you should use each. By the end you'll have a clear mental model and a concrete refactor path for the bonding curve balances.

The mental model you need to drop first

Coming from Solidity, your mental model of contract state is a single flat namespace. Everything in storage is public. Privacy is something you bolt on externally; encryption, commit-reveal, ZK gadgets. The language doesn't have an opinion about it.

Midnight inverts this completely. In Compact, private information should be disclosed only as necessary, and the language requires disclosure to be explicitly declared. This makes privacy the default and disclosure an explicit exception, reducing the risk of accidental disclosure.

That sentence is doing a lot. Privacy is the default. Disclosure is an exception you have to actively declare. The compiler enforces this; it will reject your program if you try to move witness data to the public ledger without explicitly saying so.

The practical implication: every piece of state in a Midnight contract starts in one of two worlds, and moving between them requires a deliberate act.

World 1: export ledger: the public world

ledger declarations represent the local state of the smart contract, the state that is kept on-chain. The values in the ledger are visible and public.

When you write this:

export ledger totalSupply: Uint<64>;
export ledger balances: Map<Bytes<32>, Uint<64>>;
Enter fullscreen mode Exit fullscreen mode

both fields are synchronized across every node on the network. Any observer can read them. They are as public as a Solidity mapping, no caveats.

The export keyword does two additional things beyond making the field public: it makes the field readable from the TypeScript/JavaScript side of your dApp, and it makes it part of the deployed contract's on-chain interface. You need export on any ledger field your frontend will read.

This is the right choice for state that the market needs to see. In the bonding curve, totalSupply, reserveBalance, and curveSlope are correctly export ledger. Anyone interacting with the contract needs to know the current price, and the price is a function of those three values. Hiding them would break the market.

World 2: Private state: the local world

Private state is encrypted data stored locally by users, never exposed to the network.

Private state in a Midnight contract lives on the user's machine, managed by the wallet SDK. It is never written to the chain as plaintext. The chain only ever sees a cryptographic commitment to it; a hash that proves the value exists and hasn't changed without revealing what it is.

This is the right home for per-user data. Token balances, allowances, position sizes; information that belongs to the user and that nobody else has a legitimate reason to see.

The contrast with export ledger is stark:

export ledger Private state
Where it lives Every node on the network User's local storage
Who can read it Anyone Only the owner
On-chain representation Plaintext value Cryptographic commitment
How it's updated Direct ledger assignment Via ZK proof
Solidity equivalent public storage variable No equivalent

disclose(): the boundary marker, not an escape hatch

This is the most misunderstood operator in Compact, and understanding it precisely matters.

A Compact program must explicitly declare its intention to disclose data that might be private before storing it in the public ledger, returning it from an exported circuit, or passing it to another contract.

disclose() is not an encryption function. It is not a privacy mechanism. It does the opposite: it is a compile-time annotation that says "I know this witness data is going public, and I am doing it on purpose."

Placing a disclose() wrapper does not cause disclosure in itself; it has no effect other than telling the compiler that it is okay to disclose the value of the wrapped expression.

Think of it as a signed waiver. You're not changing what happens at runtime, the value was going to the ledger either way. You're just telling the compiler you made a deliberate choice, not an accidental one.

Without it, the compiler rejects your program outright:

witness getBalance(): Bytes<32>;
export ledger balance: Bytes<32>;

export circuit recordBalance(): [] {
    balance = getBalance();  // compiler error
}
Enter fullscreen mode Exit fullscreen mode
Exception: line 6 char 11:
  potential witness-value disclosure must be declared but is not:
    witness value potentially disclosed:
      the return value of witness getBalance at line 2 char 1
    nature of the disclosure:
      ledger operation might disclose the witness value
Enter fullscreen mode Exit fullscreen mode

With it:

export circuit recordBalance(): [] {
    balance = disclose(getBalance());  // compiles fine
}
Enter fullscreen mode Exit fullscreen mode

The compiler calls its enforcement mechanism the "witness protection program", it is implemented as an abstract interpreter, where the abstract values are not actual run-time values but information about witness data that will be contained within the actual run-time values. If at some point the interpreter encounters an undeclared disclosure of an abstract value containing witness data, the compiler halts and produces an appropriate error message.

One subtlety worth knowing: the disclosure tracking follows witness data through arithmetic, type conversions, and function calls. You cannot obfuscate your way past it:

circuit obfuscate(x: Field): Field {
    return x + 73;  // still witness data
}

export circuit recordBalance(): [] {
    const s = S { x: getBalance() as Field };
    const x = obfuscate(s.x);
    balance = x as Bytes<32>;  // compiler still catches this
}
Enter fullscreen mode Exit fullscreen mode

Subjecting witness data to arithmetic, converting it from one representation to another, and passing it into and out of other circuits does not hide potential disclosure from the compiler.

The best practice from the docs: put the disclose() wrapper as close to the disclosure point as possible to avoid accidental disclosure if the data travels along multiple paths.

The commitment pattern: how private state actually works

If export ledger puts values on-chain as plaintext, and private state keeps them off-chain entirely, you might wonder: how does the contract verify anything about private state without seeing it?

The answer is commitments. A commitment scheme hashes arbitrary data together with a random nonce. The result can be safely placed into the ledger's state without revealing the original data. At a later point, the commitment can be "opened" by revealing the value and nonce, or a contract can simply prove (assert) that it has the correct value and nonce without ever revealing them.

The Compact standard library provides the tools for this:

circuit transientHash<T>(value: T): Field;
circuit transientCommit<T>(value: T, rand: Field): Field;
circuit persistentHash<T>(value: T): Bytes<32>;
circuit persistentCommit<T>(value: T, rand: Bytes<32>): Bytes<32>;
Enter fullscreen mode Exit fullscreen mode

The transient* functions should only be used when the values are not kept in state, while persistent* outputs are suitable for storage in a contract's ledger state.

There's an important note on nonces: the nonce must not be reused. If it is, you can link the commitments with the same nonces and values. Nonce reuse breaks the privacy guarantee, two commitments with the same nonce and value are identical on-chain, letting an observer link them.

One additional thing the compiler knows: transientCommit(e) is treated as non-witness data even if e contains witness data. transientHash(e) is not: the hash output is still tracked as witness-tainted. This is because a commitment includes a random nonce that sufficiently hides the input, while a bare hash might not.

Applying this to the bonding curve

Here's the current state of the bonding curve contract:

// currently: fully public
export ledger balances: Map<Bytes<32>, Uint<64>>;
export ledger allowances: Map<Bytes<32>, Map<Bytes<32>, Uint<64>>>;
Enter fullscreen mode Exit fullscreen mode

And the buy circuit updates them like this:

const caller = disclose(callerAddress());
balances.insert(caller, disclose((prevBalance + n) as Uint<64>));
Enter fullscreen mode Exit fullscreen mode

Both the address and the new balance are disclose()-d; explicitly pushed public. Anyone watching the chain can see who bought and how many tokens they now hold. This is identical to what you'd get on Ethereum.

The refactor toward private balances involves three changes:

1. Store a commitment in the ledger instead of the balance

export ledger balanceCommitments: Map<Bytes<32>, Bytes<32>>;
Enter fullscreen mode Exit fullscreen mode

The map key is still the user's address (which stays public, you need to look up commitments by user). The value is persistentCommit(balance, nonce), a hash of the balance and a random nonce.

2. Keep the actual balance in private state

The user's wallet holds the plaintext balance and the nonce locally. These never touch the chain.

3. In the circuit, prove the old commitment and assert the new one

export circuit buy(n: Uint<64>, maxCost: Uint<64>): [] {
    // ...existing checks...

    const caller = disclose(callerAddress());
    const prevBalance = localBalance();      // witness: read from private state
    const nonce = localNonce();              // witness: read from private state
    const newNonce = freshNonce();           // witness: generate new nonce

    // verify the existing commitment matches what's on-chain
    const oldCommit = persistentCommit(prevBalance, nonce);
    assert(balanceCommitments[caller] == oldCommit, "Balance commitment mismatch");

    // write the new commitment — balance updated, but value stays private
    const newBalance = prevBalance + n;
    balanceCommitments.insert(caller, disclose(persistentCommit(newBalance, newNonce)));
}
Enter fullscreen mode Exit fullscreen mode

What goes on-chain: the new commitment. What stays private: the actual balance and nonce. The ZK proof guarantees the arithmetic was done correctly without revealing the operands.

This is the pattern PROTOCOL.md describes and the current contract code doesn't yet implement. The core curve logic, the witness-verified cost calculation, the reserve invariant, the slippage protection; stays completely unchanged. Only the balance storage layer changes.

The decision table

Here's the practical guide for any state field you're designing:

If your data... Use... Because...
Defines market price or global invariants export ledger Every participant needs it to interact correctly
Needs to be read by your frontend directly export ledger Private state isn't accessible from TypeScript without a proof
Is per-user and sensitive Private state + commitment in ledger Balance stays local; proof verifies correctness
Is a temporary computation input Witness (no ledger at all) Lives only for the duration of proof generation
Proves membership without revealing value persistentCommit in export ledger Commitment on-chain, value stays private
Is an identity/address you're intentionally publishing disclose() + export ledger Explicit, deliberate public disclosure

The quick heuristic: if removing this field from the chain would break another user's ability to interact with the contract, it belongs in export ledger. If it belongs only to one user and no one else needs to verify it directly, it belongs in private state.

Why the compiler enforces this

It's worth appreciating what Midnight's design actually achieves here. On Ethereum, accidentally making private data public is easy; you just expose the wrong storage variable. There's no language-level guardrail. The compiler is indifferent to privacy.

Compact's "witness protection program" makes accidental disclosure a compile error. The decision to disclose private information must rest with each Midnight dApp because disclosure requirements are inherently situation-specific. Because private information should be disclosed only as necessary, Midnight's Compact language requires disclosure to be explicitly declared.

Every disclose() in your codebase is a record of a conscious decision. You can audit your contract's privacy model by grepping for disclose(), every hit is a place where witness data crosses into the public world. If you see one you didn't intend, the fix is architectural, not cosmetic.

That's a fundamentally different relationship between language and privacy than anything in the EVM ecosystem.

What's next?

The bonding curve commitment refactor outlined above is a genuine open contribution on the repo. If you're exploring Midnight's private state primitives, that's a contained, well-tested codebase to experiment on; the test suite already covers the invariants, so you'll know immediately if your refactor breaks something.

The next level from here is understanding how private state interacts with transfers between users, which requires both parties to update their local private state atomically, coordinated by a proof that neither can fake. That's a more involved pattern and worth its own article.

Part 1: Building a Bonding Curve Token on Midnight
Full source: github.com/sevryn-labs/midnight-bonding-curve

Top comments (0)