DEV Community

Cover image for Building a Bonding Curve Token on Midnight, With Real ZK Proofs
Tushar Pamnani
Tushar Pamnani

Posted on

Building a Bonding Curve Token on Midnight, With Real ZK Proofs

Full source: github.com/sevryn-labs/midnight-bonding-curve

If you've built DeFi on EVM chains before, you already know the shape of a bonding curve: a smart contract that prices tokens algorithmically against supply, mints on buy, burns on sell, and holds a reserve. No order book. No oracle. The math is the market maker.

What makes building one on Midnight interesting isn't that this implementation hides all your data - it doesn't, and we'll be precise about what's public and what isn't. What's interesting is the execution model: how Midnight separates off-chain computation from on-chain verification using ZK proofs, and what that unlocks as a foundation for genuinely private DeFi primitives.

We'll cover the bonding curve math, how Midnight's execution model differs from what you're used to, the ZK witness pattern that replaces what would normally just be on-chain arithmetic, and an honest account of the current privacy boundary. The full contract is in Compact (Midnight's smart contract language) and the off-chain layer is TypeScript.

The math first, briefly

We're using a linear bonding curve where the marginal price at supply s is:

P(s) = a · s
Enter fullscreen mode Exit fullscreen mode

a is the slope set at deployment. Price scales linearly with supply — simple, predictable, well-understood economic properties.

Because price moves during a purchase, you can't just multiply. You integrate the curve over the range of tokens being bought:

Cost(s, n)   = (a / 2) · ((s + n)²  s²)   // buying n tokens from supply s
Refund(s, n) = (a / 2) · (s²  (s  n)²)   // selling n tokens from supply s
Enter fullscreen mode Exit fullscreen mode

From this falls a reserve invariant that must hold at all times:

R(s) = (a / 2) · s²
Enter fullscreen mode Exit fullscreen mode

Every buy and sell is checked against this invariant inside the circuit. Any state that violates it cannot produce a valid ZK proof; the math enforces solvency at the cryptographic level.

If you want the full derivation and economic intuition behind the curve, I wrote a deeper breakdown on my blog →

What Midnight is actually doing differently

ZK transaction flow

state split

Before diving into the contract, it's worth being precise about Midnight's execution model, because it's genuinely different from EVM or Solana and the differences shape every design decision.

On a typical chain, when you call a function the validators re-execute your computation and check the result. Everyone sees the inputs. On Midnight, you execute the circuit locally, generate a ZK proof that you did it correctly, and submit only the proof plus public outputs. Validators verify the proof in milliseconds without ever seeing your private inputs.

The state model reflects this split directly. The first diagram above shows the full ZK transaction flow. The second shows the state split as it exists in this contract. totalSupply, reserveBalance, curveSlope, and the admin fields are public export ledger variables; anyone can read them on-chain. The witnesses, callerAddress and calculateCost, are computed locally and fed into the proof generation step but never written to the chain as plaintext.

The proof server is a local Docker container. It never touches the network. It takes your inputs, runs the circuit constraints, and generates the proof. Your witness values never leave your machine.

An honest account of what's public in this contract

This is the section that most implementations gloss over, so let's be direct.

balances and allowances are defined as export ledger maps:

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

In Compact, ledger variables represent public state synchronized across all nodes. And in the buy, sell, and transfer circuits, balances are updated using disclose():

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

disclose() explicitly adds a value to the transaction's public circuit outputs. That means updated balances and associated addresses are visible to anyone observing the blockchain. This is the same privacy model as a standard EVM token.

It's worth noting that PROTOCOL.md in the repo describes a commitment-based privacy model for balances, but the current contract code doesn't implement it yet. Consider this a working reference implementation of the bonding curve mechanics, with private balances as the natural next step once the pattern is wired in.

Midnight absolutely supports genuinely private balance maps using persistent private state and commitment types. This contract just doesn't use them yet. When someone asks "are token balances private on this contract?" — the honest answer right now is no, but the architecture of the chain makes it achievable without changing the core curve logic.

The witness pattern, where EVM and Midnight diverge most sharply

This is the part that most confuses developers coming from Solidity, and it's also the most interesting part of the architecture.

In Solidity, you'd compute the bonding curve cost directly inside the contract:

function mintCost(uint256 s, uint256 n) internal view returns (uint256) {
    return (slope * ((s + n)**2 - s**2)) / 2;
}
Enter fullscreen mode Exit fullscreen mode

In Compact, you can't do arbitrary arithmetic inside the circuit for free. ZK circuits have constraints, and squaring large integers is expensive inside them. The solution is the witness pattern: compute the expensive thing off-chain in TypeScript, pass the result into the circuit, and have the circuit verify the result rather than recompute it.

The contract declares the witness as a foreign function:

witness calculateCost(
    slope: Uint<64>,
    s_old: Uint<64>,
    s_new: Uint<64>
): Uint<64>;
Enter fullscreen mode Exit fullscreen mode

You implement this in TypeScript using native BigInt, no circuit constraints, full precision:

// src/math.ts
export function calculateMintCost(slope: bigint, s: bigint, n: bigint): bigint {
    const sNew = s + n;
    return (slope * (sNew * sNew - s * s)) / 2n;
}
Enter fullscreen mode Exit fullscreen mode

The circuit then verifies the result rather than trusting it blindly:

circuit verifiedHalfProduct(
    slope: Uint<64>,
    s_old: Uint<64>,
    s_new: Uint<64>
): Uint<64> {
    const result = calculateCost(slope, s_old, s_new);
    const totalProduct = slope * (s_new * s_new - s_old * s_old);
    assert(
        2 * result == totalProduct || 2 * result + 1 == totalProduct,
        "Witness cost value failed verification"
    );
    return result;
}
Enter fullscreen mode Exit fullscreen mode

That 2 * result + 1 branch deserves an explanation. Compact uses integer (truncating) division. When deltaSq = (s+n)² − s² is odd, dividing by 2 truncates — the remainder is lost. The circuit permits both branches because either 2·result == totalProduct (even case) or 2·result + 1 == totalProduct (odd case, truncated). Critically, both Cost(s,n) and Refund(s,n) for the same (s,n) pair truncate the same product, so mint/burn reversibility is preserved across rounding.

This is not a trust assumption, it's a constraint. A witness that provides a wrong value will fail this assertion and no valid proof can be generated.

callerAddress(), the msg.sender equivalent

The other witness in the contract is callerAddress(), declared as:

witness callerAddress(): Bytes<32>;
Enter fullscreen mode Exit fullscreen mode

This is the Midnight equivalent of Solidity's msg.sender. It's implemented in witnesses.ts to read the address field out of the user's local PrivateState, the wallet's identity, resolved off-chain during proof generation.

Because it's a witness, you might wonder: can a user just lie and provide someone else's address? No. The value is bound by the ZK proof. In deployment, the circuit verifies that the provided witness is consistent with the public key that authorized the state transition; a user can only move tokens that their own proof can account for. The witness is provided by the user, but the circuit constraints make fraud cryptographically impossible to prove.

In buy, the caller address is used like this:

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

The disclose() on callerAddress() is what makes the buyer's address visible on-chain, consistent with the public balance model the contract currently uses.

Walking through a buy

Here's the full buy circuit with annotations:

export circuit buy(n: Uint<64>, maxCost: Uint<64>): [] {
    // 1. Emergency halt check
    assert(!paused, "Contract is paused");

    // 2. Optional supply cap enforcement
    const s = totalSupply;
    const newSupply = s + n;
    assert(supplyCap == 0 || newSupply <= supplyCap, "Supply cap exceeded");

    // 3. Cost via verified witness (off-chain compute, on-chain verify)
    const cost = verifiedHalfProduct(curveSlope, s, newSupply);

    // 4. Slippage protection — caller set their own limit
    assert(cost <= maxCost, "Cost exceeds maxCost slippage limit");

    // 5. Mint: update supply, reserve, and caller's balance
    totalSupply = newSupply;
    reserveBalance = reserveBalance + cost;
    const caller = disclose(callerAddress());
    balances.insert(caller, disclose((prevBalance + n) as Uint<64>));
}
Enter fullscreen mode Exit fullscreen mode

The sell circuit mirrors this exactly, using verifiedHalfProduct(curveSlope, newSupply, s) (arguments reversed) to compute the refund, and a minRefund guard instead of maxCost.

What you actually deploy and run

The repo structure maps cleanly to the execution model:

contracts/
  bonding_curve.compact       # Compact source → compiled to ZK circuits + TS bindings

src/
  math.ts                     # Off-chain witness implementations (pure BigInt)
  simulator.ts                # In-process state replica for testing (no proof server needed)
  cli.ts                      # Interactive buy/sell/transfer terminal
  deploy.ts                   # Deployment to Midnight Preprod
  witnesses.ts                # Witness implementations including callerAddress()
  tests/
    bonding_curve.test.ts     # 27 test sections, fuzz and invariant tests
Enter fullscreen mode Exit fullscreen mode

Compilation turns bonding_curve.compact into ZK circuits, generating keys and TypeScript API bindings:

npm install
npm run compile   # compactc → zkir/ + keys/ + TS bindings
npm run build
Enter fullscreen mode Exit fullscreen mode

To deploy or interact with a live contract, the proof server must be running:

npm run start-proof-server   # local Docker container, keep it alive

npm run deploy               # deploys to Midnight Preprod, prompts for wallet seed
npm run cli                  # interactive: buy, sell, transfer, inspect state
Enter fullscreen mode Exit fullscreen mode

Your wallet needs tNIGHT tokens for gas. Use the Midnight preprod faucet, then the CLI prompts you through everything else.

One implementation detail worth calling out: the CLI uses Midnight Unshielded Addresses (mn_addr_...) as the canonical user identity. These Bech32m strings are decoded and SHA-256 hashed to produce a deterministic 32-byte key for the internal maps. This normalization prevents the same user from appearing as different identities depending on which address format they use.

The test suite is worth reading

The contract ships with 27 test sections. A few worth calling out:

The reserve invariant tests verify R = (a/2) · s² after every operation across interleaved multi-user sequences. The integer arithmetic tests specifically probe the odd/even deltaSq truncation behavior and confirm that mint/burn round-trips are symmetric. The randomized fuzz runs 100 random buy/sell sequences and checks the invariant after each round. The large-number stress tests push supply values to 10¹¹ to surface overflow before it surfaces in production.

If you're deploying a variant, the overflow section in the README deserves careful attention: at slope=10, the reserve overflows Uint<64> around s ≈ 1.36 × 10⁹. Choosing slope and cap values such that slope · cap² / 2 < 2⁶⁴ is a deployment-time decision, not something the circuit enforces for you.

Where to go from here

The natural next step is refactoring balances and allowances to use Midnight's persistent private state - replacing the current export ledger maps and disclose() calls with commitment-backed private storage. The core curve logic, the witness pattern, and the reserve invariant all stay exactly the same. Only the state storage layer changes. That's the version that matches what PROTOCOL.md describes, and it's a meaningful contribution to make to this repo if you're exploring Midnight's privacy primitives.

Beyond that, the patterns here - witness-verified arithmetic, ZK-enforced reserve invariants, slippage protection at the circuit level, generalize naturally. Multi-token pools, TWAP oracles, prediction markets where positions are hidden until resolution: all of these become tractable once you've internalized the witness/circuit split.

The full source, test suite, and deployment scripts are at github.com/sevryn-labs/midnight-bonding-curve.

Reference implementation, not audited, not production-ready without a full security review. See AUDIT.md in the repo before putting real value behind it.

Top comments (1)

Collapse
 
islandghoststephanie profile image
IslandGhost589

This is really good..