Building a Private AMM on Midnight: Constant Product Pools with ZK Privacy
Automated market makers fundamentally changed decentralized trading. They also created one of the most profitable attack surfaces in blockchain history: frontrunning and MEV extraction. On transparent chains, every pending swap is visible in the mempool before it executes. Searchers watch for large trades, sandwich them with their own transactions, and extract value directly from retail traders.
Midnight's privacy model offers a different foundation. By hiding swap amounts and pool state behind zero-knowledge proofs, you can build AMMs where trades are economically private — visible enough to prove validity, but hidden enough to prevent the adversarial ordering attacks that plague public DEXs.
This article covers the architecture of a private AMM on Midnight: why privacy matters here specifically, how to implement the constant product formula in Compact circuits with overflow-safe arithmetic, how liquidity positions work as private state, and a full contract sketch for a private swap pool.
Why AMM Privacy Matters
On Uniswap V2/V3 or any EVM DEX, when you submit a swap transaction, the following is publicly visible before your transaction executes:
- Your input amount — exactly how much you're trading
-
Your slippage tolerance — the maximum you'll accept (your
minAmountOut) - Your wallet address — linkable to prior activity
- The pool's current price — so anyone can compute your expected output
This is enough information for a sandwich attack: a searcher sees your USDC→ETH swap for $100k with 0.5% slippage, submits a buy transaction before yours (moving price up), lets your trade execute at worse price, then sells immediately after. You paid their profit.
MEV bots extract hundreds of millions per year this way. The root cause is transparent inputs.
A private AMM on Midnight addresses this at the protocol level:
- Swap amounts are hidden — the circuit proves the swap is valid (satisfies x*y=k) without revealing input/output amounts publicly
- Wallet addresses aren't linked to swaps — ZK proofs verify authorization without revealing who's trading
- Pool state can be partially hidden — the invariant k can be proven to be maintained without revealing x and y individually
Constant Product Formula in Compact
The constant product invariant is: x * y = k, where x and y are the two token reserves, and k must not decrease after a swap (it increases with fees).
In a standard swap of dx tokens in for dy tokens out:
- New reserves:
(x + dx) * (y - dy) >= k - This ensures:
(x + dx) * (y - dy) >= x * y - Rearranged:
dy <= (y * dx) / (x + dx)
The challenge in a ZK circuit: multiplication of two 64-bit quantities produces 128-bit results. We need 128-bit intermediate arithmetic to avoid overflow, or we bound our reserve sizes carefully.
// Safe constant product check with 128-bit intermediate arithmetic
circuit constant_product_valid(
witness reserve_x: Uint<64>,
witness reserve_y: Uint<64>,
witness dx_in: Uint<64>, // tokens going in
witness dy_out: Uint<64>, // tokens coming out
witness fee_numerator: Uint<16>, // e.g. 30 for 0.3%
witness fee_denominator: Uint<16>, // e.g. 10000
public pool_id: Field,
public new_reserve_x_commitment: Commitment,
public new_reserve_y_commitment: Commitment
) {
// Promote to 128-bit for safe multiplication
let rx: Uint<128> = reserve_x as Uint<128>;
let ry: Uint<128> = reserve_y as Uint<128>;
let dx: Uint<128> = dx_in as Uint<128>;
let dy: Uint<128> = dy_out as Uint<128>;
// K before the swap
let k_before: Uint<128> = rx * ry;
// Apply fee: effective input is dx * (1 - fee)
// dx_effective = dx * (fee_denominator - fee_numerator) / fee_denominator
let fee_denom: Uint<128> = fee_denominator as Uint<128>;
let fee_num: Uint<128> = fee_numerator as Uint<128>;
let dx_effective: Uint<128> = dx * (fee_denom - fee_num);
// New reserves (scaled by fee_denominator to avoid fractional math)
// (rx * fee_denom + dx_effective) * (ry * fee_denom - dy * fee_denom) >= k_before * fee_denom^2
let new_rx_scaled: Uint<256> = rx * fee_denom + dx_effective;
let new_ry_scaled: Uint<256> = (ry - dy) * fee_denom;
let k_after_scaled: Uint<256> = new_rx_scaled * new_ry_scaled;
let k_before_scaled: Uint<256> = k_before * (fee_denom * fee_denom) as Uint<256>;
// The invariant must hold (k can only grow due to fees)
assert k_after_scaled >= k_before_scaled;
// dy must be positive (no zero-value swaps)
assert dy_out > 0;
assert dx_in > 0;
// Commit to new reserve values
let new_rx: Uint<64> = (reserve_x + dx_in); // actual new reserve
let new_ry: Uint<64> = (reserve_y - dy_out);
assert hash(pool_id, new_rx) == new_reserve_x_commitment;
assert hash(pool_id, new_ry) == new_reserve_y_commitment;
}
The key insight: we never post reserve_x or reserve_y to the chain directly. We post commitments to the new reserve values. The circuit proves the constant product was maintained without revealing actual reserve amounts.
Note the use of Uint<256> for the scaled invariant check — this is the widest intermediate we need. In practice, Compact targets a field of ~254 bits, so we must carefully bound inputs to avoid wrapping. Constraining reserve_x < 2^60 and reserve_y < 2^60 with fee_denominator = 10000 keeps all intermediates within the field.
Pool State as Private State
The most radical design choice: do you hide the pool reserves entirely, or do you make them public?
Option A: Fully private pool state
- Reserves are commitments on-chain
- Price is never revealed
- Price discovery is impossible without querying the prover
- MEV-immune, but less composable
Option B: Hybrid — hide swap amounts, reveal reserves
- Pool reserves are public (traders can compute price)
- Swap amounts are private
- Prevents sandwich attacks but not informed frontrunning based on pool state
Option C: Reveal price ranges, not exact amounts
- Pool reserves are rounded to the nearest "price bucket"
- Provides approximate price discovery with reduced MEV surface
For this article, we implement Option B — public reserves with private swap amounts — as it's the most practically useful and directly addresses sandwich attacks.
ledger {
// Pool reserves — public, for price discovery
pools: Map<PoolId, Pool>,
// Spent swap nullifiers — prevents replay
swap_nullifiers: Set<Field>,
}
type Pool = {
reserve_x: Uint<64>,
reserve_y: Uint<64>,
token_x: TokenId,
token_y: TokenId,
fee_numerator: Uint<16>,
fee_denominator: Uint<16>,
lp_supply: Uint<64>,
}
Shielded Liquidity Positions
Liquidity provider (LP) positions can be represented as private state using the same commitment pattern from the vault article. Each LP position is a commitment hash(pool_id, lp_share, secret, owner_pubkey).
circuit add_liquidity(
witness amount_x: Uint<64>,
witness amount_y: Uint<64>,
witness secret: Field,
witness owner_pubkey: PublicKey,
public pool_id: PoolId,
public lp_commitment: Commitment
) {
let pool = ledger.pools[pool_id];
// Check proportional deposit (maintain price ratio)
// amount_x / amount_y must equal reserve_x / reserve_y
// Cross-multiply to avoid division: amount_x * reserve_y == amount_y * reserve_x
let ax: Uint<128> = amount_x as Uint<128>;
let ay: Uint<128> = amount_y as Uint<128>;
let rx: Uint<128> = pool.reserve_x as Uint<128>;
let ry: Uint<128> = pool.reserve_y as Uint<128>;
// Allow 1% tolerance for rounding (in practice use exact matching or range constraint)
let lhs = ax * ry;
let rhs = ay * rx;
// assert lhs == rhs; // exact
// For deposits, allow rhs +/- tolerance, but exact is preferred
assert lhs == rhs;
// Compute LP share: proportional to contribution
// lp_share = (amount_x / reserve_x) * lp_supply
// Use: lp_share * reserve_x == amount_x * lp_supply (cross-multiply)
let lp: Uint<128> = (ax * pool.lp_supply as Uint<128>) / rx;
let lp_share: Uint<64> = lp as Uint<64>;
// Commit to the LP position
let computed_commitment = hash(pool_id, lp_share, secret, owner_pubkey);
assert computed_commitment == lp_commitment;
}
contract PrivateAMM {
fn add_liquidity(
proof: ZKProof,
pool_id: PoolId,
amount_x: Uint<64>,
amount_y: Uint<64>,
lp_commitment: Commitment
) {
let pool = ledger.pools[pool_id];
verify_proof(proof, add_liquidity_circuit, [pool_id, lp_commitment]);
// Transfer tokens from caller
token_x.transfer_from(caller(), self(), amount_x);
token_y.transfer_from(caller(), self(), amount_y);
// Update pool reserves
ledger.pools[pool_id].reserve_x += amount_x;
ledger.pools[pool_id].reserve_y += amount_y;
ledger.pools[pool_id].lp_supply += lp_share_from_proof(proof);
// Store LP commitment (private ownership)
lp_commitments.insert(lp_commitment);
}
}
When an LP wants to remove liquidity, they prove ownership of the LP commitment and burn it, receiving proportional tokens from the pool.
Swap Circuit: Private Amounts, Proven Validity
The swap circuit is the core of the private AMM. The user proves:
- The swap satisfies the constant product invariant
- Their input amount matches what's transferred (via token proof)
- The nullifier is fresh (prevents replay)
circuit private_swap(
// Private: the actual trade amounts
witness amount_in: Uint<64>,
witness amount_out: Uint<64>,
witness nonce: Field, // for nullifier uniqueness
// The user proves they hold amount_in (via separate token proof, not shown)
witness input_token_proof: TokenProof,
// Public: minimal info for contract execution
public pool_id: PoolId,
public swap_nullifier: Field,
public recipient: Address,
// The contract verifies new reserves implicitly via the pool state
public min_amount_out: Uint<64> // slippage bound (public for UX, not required for privacy)
) {
let pool = ledger.pools[pool_id];
// Verify the swap satisfies constant product
let rx: Uint<128> = pool.reserve_x as Uint<128>;
let ry: Uint<128> = pool.reserve_y as Uint<128>;
let ai: Uint<128> = amount_in as Uint<128>;
let ao: Uint<128> = amount_out as Uint<128>;
let fee_d: Uint<128> = pool.fee_denominator as Uint<128>;
let fee_n: Uint<128> = pool.fee_numerator as Uint<128>;
// k before: rx * ry
// k after (with fee applied to input):
// (rx + ai * (fd - fn) / fd) * (ry - ao) >= rx * ry
// Cross-multiply to avoid division:
let lhs: Uint<256> = (rx * fee_d + ai * (fee_d - fee_n)) * (ry * fee_d - ao * fee_d);
let rhs: Uint<256> = rx * ry * fee_d * fee_d;
assert lhs >= rhs;
// Slippage: output meets minimum (note: min_amount_out is public, leaks info about expected output)
assert amount_out >= min_amount_out;
// Nullifier prevents replay of this proof
let computed_nullifier = hash(nonce, pool_id, amount_in);
assert computed_nullifier == swap_nullifier;
// Verify input token ownership (user has amount_in to spend)
assert verify_token_proof(input_token_proof, amount_in, pool.token_x);
}
contract PrivateAMM {
fn swap(
proof: ZKProof,
pool_id: PoolId,
swap_nullifier: Field,
recipient: Address,
min_amount_out: Uint<64>
) {
verify_proof(proof, private_swap_circuit, [pool_id, swap_nullifier, recipient, min_amount_out]);
assert !ledger.swap_nullifiers.contains(swap_nullifier);
ledger.swap_nullifiers.insert(swap_nullifier);
// Extract amounts from proof public outputs (Midnight exposes declared public circuit outputs)
let amount_in = proof.public_output.amount_in; // This would need to be made public in the circuit
let amount_out = proof.public_output.amount_out;
// Update reserves
ledger.pools[pool_id].reserve_x += amount_in;
ledger.pools[pool_id].reserve_y -= amount_out;
// Transfer output to recipient
token_y.transfer(recipient, amount_out);
// Pull input from prover/caller
token_x.transfer_from(caller(), self(), amount_in);
}
}
Note the tension: for the contract to update reserves correctly, it needs to know amount_in and amount_out. Making these public defeats some privacy. In a fully private design, you'd use committed reserves and prove reserve transitions without revealing amounts — more complex but possible using the same commitment approach as the shielded vault.
Removing Liquidity: Private Proportional Redemption
circuit remove_liquidity(
witness lp_share: Uint<64>,
witness secret: Field,
witness owner_privkey: PrivateKey,
witness merkle_path: MerklePath<TREE_DEPTH>,
witness pool_id: PoolId,
public nullifier: Nullifier,
public recipient: Address,
public amount_x_out: Uint<64>,
public amount_y_out: Uint<64>
) {
let owner_pubkey = derive_pubkey(owner_privkey);
let lp_commitment = hash(pool_id, lp_share, secret, owner_pubkey);
// Verify LP commitment exists
assert merkle_verify(ledger.lp_tree.root(), lp_commitment, merkle_path);
let pool = ledger.pools[pool_id];
// Verify proportional redemption
// amount_x_out / reserve_x == lp_share / lp_supply
// Cross multiply: amount_x_out * lp_supply == lp_share * reserve_x
assert (amount_x_out as Uint<128>) * (pool.lp_supply as Uint<128>)
== (lp_share as Uint<128>) * (pool.reserve_x as Uint<128>);
assert (amount_y_out as Uint<128>) * (pool.lp_supply as Uint<128>)
== (lp_share as Uint<128>) * (pool.reserve_y as Uint<128>);
// Nullify LP position
let computed_nullifier = hash(secret, lp_commitment);
assert computed_nullifier == nullifier;
}
MEV Resistance Analysis
With the private swap implementation:
- Sandwich attacks: Defeated for hidden-amount swaps. An attacker can't sandwich what they can't see.
- Frontrunning based on pool state: Partially mitigated if reserves are hidden (Option A). In Option B with public reserves, traders can still compute optimal swap sizes — but they can't see individual pending orders.
- JIT liquidity attacks: Mitigated — private LP positions mean attackers can't identify and remove liquidity around large swaps.
- Arbitrage: Still possible, and desirable — arbitrageurs keep prices aligned with other markets. They can observe public reserve ratios (Option B) and arbitrage accordingly.
Practical Deployment Considerations
Proof generation latency: ZK proof generation for arithmetic circuits with 256-bit intermediates can take 1-10 seconds on consumer hardware. For a trading application, this introduces latency that users must accept. Server-side proving (trusted prover with hardware acceleration) reduces latency but introduces trust assumptions.
Gas/fee model: Midnight uses a different fee model than EVM chains, but proof verification is compute-intensive. Batch multiple swap proofs where possible.
Price oracle integration: Private pools need some mechanism for external price reference to prevent large-deviation swaps. A public price feed (e.g., from a Chainlink-style oracle on Midnight's public state) can serve as a circuit constraint: assert amount_out <= oracle_price * amount_in * 1.05 bounding the swap to ±5% of oracle price.
Composability: Private AMMs are harder to compose with other DeFi protocols since intermediate swap amounts aren't visible to other contracts. Design integration points carefully — consider selective disclosure for protocol-to-protocol interactions.
Summary
Building a private AMM on Midnight requires rethinking the standard AMM architecture at every level: reserve state becomes commitments or public values with private swap amounts, LP positions become private commitments, and the constant product invariant is enforced via ZK circuits with overflow-safe 128/256-bit arithmetic. The result is a trading system that maintains economic validity guarantees while eliminating the transparent mempool that makes sandwich attacks trivially profitable. The tradeoff is proof generation latency and reduced composability — acceptable for most use cases where privacy is the core value proposition.
Top comments (0)