ZKPerp 3/4: What Zero-Knowledge Can't Do
Part 3 of a four-part series on ZKPerp. Every private protocol has a trust assumption somewhere. Most of them don't tell you where.
There's a genre of protocol documentation that describes a system entirely in terms of what it prevents. Nobody can forge this. Nobody can steal that. The list gets long, the reader gets reassured, and the interesting question β what is still true if one specific party turns hostile? β never gets asked out loud.
I want to do the opposite here, using a system I built.
ZKPerp is a perpetual futures DEX on Aleo where position size, entry price, collateral, leverage, and PnL are encrypted in records rather than posted to a public order book. That property is real and it's enforced by the chain. It also has a cost, and the cost is not a rounding error. It's structural, it follows from how zero-knowledge execution works, and it will show up in your system too if you build one.
By the end of this post you'll have the full list: who is trusted, for what, and what the bound is in each case. There's a table at the bottom if you'd rather skip the reasoning.
The constraint in one sentence
Private witnesses cannot flow into public state.
That's it. Everything below is a consequence.
In Aleo's model, a transition body can read a trader's private inputs but not on-chain mappings. A finalize block runs on-chain against public mapping state β and every argument passed into finalize appears in plaintext on-chain. So the moment you want a private value to affect shared state, you have to publish it.
This isn't an Aleo quirk. Any ZK execution model where the verifier learns only that a computation was correct, not what it computed over, has the same shape.
Where it bit us: open interest
A perp pool needs to know its aggregate exposure. Long open interest and short open interest determine how much liquidity LPs can safely withdraw β you can't let LPs drain a pool that's the counterparty to $5M of live positions.
The natural implementation is to update OI atomically inside open_position's finalize. Position opens, exposure goes up, one transaction, no trust required.
We tried it. Here's what the chain stores:
active_position_idsβ which position IDs exist. Hashes. No sizes.position_commitsβ commitment hashes of position parameters. Opaque by design.pool_stateβ whatever was last written there.
There is no way to sum size_usdc across active positions, because sizes were never stored on-chain. They're encrypted inside PositionSlot records. To update long_open_interest inside finalize, the size and direction have to be declared as public finalize inputs.
We verified this empirically rather than reasoning about it. When we included OI updates in finalize, trade sizes and directions appeared as u64.public and boolean.public in every transaction on the explorer. Perfect atomic accounting, zero privacy.
Three options, and a fourth that almost works
Update OI in finalize. Atomic and on-chain, no trust required β and sizes and directions leak publicly. This is the option that defeats the entire purpose.
Orchestrator writes the aggregates. OI is visible on-chain, positions stay private from the public, and you have introduced a trust assumption.
Compute off-chain, display only. No trust required, and the OI figure never reaches the chain β so nothing on-chain can depend on it, including the LP withdrawal guard.
We chose the middle one. An off-chain orchestrator holds a view key over the position data, computes the aggregates, and writes them on-chain via update_pool_state.
The fourth option is worth describing, because it's the one a cryptographer will raise and it gets closer than the other three. Publish a Pedersen commitment to +size as a public finalize input and accumulate it into a group-typed mapping. Group addition works in finalize, so the chain would hold a running commitment to total OI without any individual size ever appearing in plaintext. That's strictly better than option one on privacy and strictly better than option two on integrity β the orchestrator could then only ever open the true aggregate.
It still doesn't close the gap, for two reasons. The LP withdrawal guard needs an inequality against OI, and finalize cannot compare against a hidden committed value without someone supplying an opening β which returns you to trusting whoever supplies it, now with more machinery. And nothing in that scheme prevents a trader from committing to a negative or absurd size, so you'd need an in-circuit range proof on every open just to make the accumulator meaningful.
So the honest version is: the option space isn't exhausted, but every candidate either publishes the sizes or ends with an off-chain party asserting a number the chain can't check. I haven't found a fifth, and I'd genuinely like to be shown one.
What the orchestrator can see
Before what it can do, what it can read, because that's the part most disclosures skip.
To sum sizes, the orchestrator has to decrypt them. It holds a view key over the position data and therefore sees, for every open position: size, direction, entry price, and collateral β which means leverage and live PnL are derivable, since the oracle price is public. Positions are private from other traders, from MEV bots, from the frontend, and from anyone reading the explorer. They are not private from the orchestrator.
That's the price of option two, and it's a privacy cost rather than an integrity cost, so it doesn't show up anywhere in the "what a malicious operator can do" list below. It belongs in the disclosure anyway.
The honest version of the trust surface
update_pool_state overwrites the entire pool state from unverified inputs β not just the two OI figures, but total_liquidity, total_lp_tokens, and accumulated_fees as well. The only check is that the caller is the orchestrator address in roles[1u8].
The obvious consequence is denial of service. Deflating total_liquidity blocks withdrawals, but it also blocks close_position, liquidate, execute_take_profit and execute_stop_loss β all four assert that the pool covers the payout before they run. An orchestrator that writes a small enough number freezes every exit path in the protocol, and underwater positions stay open while the market keeps moving.
The non-obvious consequence is the LP redemption path. remove_liquidity prices LP tokens from mutable pool state:
let max_usdc: u64 = (amount_to_burn * state.total_liquidity)
/ (state.total_lp_tokens + 1u64);
assert(expected_usdc <= max_usdc as u128);
LPSlot has no commitment mapping. Trader positions are commitment-verified against a BHP256 hash written at open time; LP redemptions are a ratio over numbers the orchestrator writes. So the orchestrator sets the per-token redemption price, and the withdrawal solvency guard it also has to clear is computed from OI figures it also writes.
Which means: inflate total_liquidity, zero the open interest, deflate total_lp_tokens, and a single LP withdrawal clears the pool's real balance. add_liquidity has a 100 USDCx minimum, so the orchestrator can be that LP. One key, no collusion required.
It is tempting to file this under "the orchestrator cannot move more than the pool's real balance β the USDCx token contract enforces that independently." That is true and it is misleading. The token contract bounds the theft at the real balance; it does not prevent the theft. The pool is at the orchestrator's mercy, and saying otherwise is exactly the failure this post exists to argue against.
What the orchestrator genuinely cannot do:
- Inflate an individual trader's position payout. Close, take-profit, stop-loss, and liquidation each recompute the position from its on-chain BHP256 commitment. A payout figure supplied by anyone β trader, keeper, or orchestrator β is checked against a hash committed at open time. This is the protection LP slots don't have, and the asymmetry is the bug, not the design.
- Set oracle prices. Those are governed separately, which is not the same as being safe β see below.
The fix
total_liquidity and total_lp_tokens are already maintained correctly on-chain by add_liquidity and remove_liquidity. They never needed to be orchestrator-writable. update_pool_state now takes two arguments instead of five:
fn update_pool_state(
public new_long_oi: u64,
public new_short_oi: u64,
) -> Final {
with the remaining fields read from the current mapping and written back verbatim. The orchestrator bot was already reading those three values off-chain and echoing them straight back, so the honest path loses nothing; only the dishonest one closes.
That leaves the orchestrator trusted for open interest and net PnL β the two figures the chain genuinely cannot derive, for the reason at the top of this post β and for firing TP/SL triggers honestly. Those go behind a k-of-n quorum next, the same on-chain pattern the price oracle already uses. That reduces trust from a single key to a threshold. It is defense-in-depth, not verification. The aggregates are still computed off-chain, because they have to be.
Summary, if you're evaluating this: the orchestrator is trusted for open interest and net PnL, and can read every open position. Trader position payouts are commitment-protected. Before the fix above, LP redemptions were not, and the pool's balance was reachable by a single key.
The keepers can read your position too
Part 2 described the keeper-auth pattern: open_position() mints three LiquidationAuth records in the same atomic transition, one to each keeper in the on-chain liquidator_set, each mirroring entry_price, size_usdc, collateral_usdc, is_long, and position_id.
I framed that as an integrity question β what stops a keeper from editing those fields β and answered it with the commitment scheme. The confidentiality question deserved the same airtime and didn't get it. In Aleo the owner of a record decrypts it. The keepers own those records. So every keeper knows the full parameters of every position from the moment it opens, and the auths are minted in the same transition as the trader's slot, which makes the linkage to an opening transaction straightforward to correlate.
The bound: keepers can't act on what they know outside the five gates. They cannot liquidate a solvent position, invent a price, inflate a reward, or consume a trader's slot. What they hold is information, not power.
But information is the thing this protocol is supposed to protect, so the accurate claim is narrower than the one I made in Part 2. ZKPerp's positions are private from the public, from other traders, and from MEV bots. They are not private from the operator set. That is a materially weaker property than "only the owner can decrypt," and it's the sentence I'd want a user to read before depositing.
Whether that gap is closable is the same open problem as permissionless liquidation. The keeper needs to evaluate a margin condition; evaluating it requires the parameters; a proof of liquidatability that reveals only the boolean is research, not roadmap.
The oracle is a trust assumption, not a safeguard
"Cannot set oracle prices" sits in the orchestrator's can't column, and leaving it there would be the kind of framing this series exists to argue against.
Prices come from a 2-of-3 quorum in a separate on-chain program. Two colluding signers set the price. Gate 4 of the liquidation path computes equity from that price, and Gate 2 requires the exit price to equal it. So a compromised oracle quorum can mark healthy positions underwater and liquidate them β at 10% of collateral each, across every open position, at a time of its choosing.
That is a strictly larger power than anything on the orchestrator's list. It is bounded β the quorum is on-chain and auditable rather than a single off-chain key, prices are rejected after 150 blocks, and the signers cannot redirect funds, mint tokens, or touch LP accounting. Liquidation proceeds run through the same commitment-verified payout path as everything else. But "separate program, 2-of-3" is a mitigation, not an exemption, and raising n is the obvious hardening step.
What's fixable and what isn't
The fix above was the easy case: a mistake, so it could simply be corrected. What's left can't be.
Open interest is architecture-bound. Sizes are private witnesses. There is no version of this where the chain derives OI from private position data without making that data public. Not with more engineering, not with a better circuit. It follows from the constraint at the top of this post, and as far as I'm aware no privacy-preserving perpetual DEX on any chain does it β the constraint explains why.
Operator visibility is architecture-bound too. Both the keeper set and the orchestrator need plaintext parameters to do their jobs. Shrinking that to a threshold reduces the number of parties who see everything; it doesn't remove the category.
The distinction I flagged at the end of the liquidation post belongs here too. The keeper set looks superficially like the orchestrator quorum β several parties, one action β but they're solving different problems. The chain can fully verify a liquidation, so the keeper set only needs to guarantee that someone shows up. The chain cannot verify an OI figure, so distributing that key is the only defense available. Multiple parties are a substitute for verification when verification isn't possible, and a liveness mechanism when it is. Worth knowing which one you're looking at.
The same shape, three more times
Once you know what to look for, the pattern recurs across the whole system.
The dark pool operator sees your order. ZKDarkPool settles sealed-bid batch auctions at a uniform clearing price, and nothing about a pending order is visible to other traders or MEV bots β before or after settlement. That's the anti-front-running property, and it's the one that matters for execution quality. But to compute a clearing price off-chain, the operator has to decrypt the orders. submit_order issues an OrderAuth record owned by the operator, so the operator sees size, limit price, direction, asset, and trader address.
The bound is the same shape as the orchestrator's: the operator cannot settle outside the signed limit prices, cannot move more than the escrowed amount, and cannot fabricate a fill against a consumed or expired order β all ZK-enforced at settlement. Its residual power is censorship: declining to match. That's mitigated by order expiry and user-initiated cancellation, not eliminated. Sealing order contents from the operator as well would require in-circuit matching over committed orders, which is open research.
The AMM can't be private at all. The concentrated liquidity AMM keeps LP positions as private records, but pool state β price, tick, liquidity β is public, and swap amounts appear as finalize arguments. This isn't a shortcut. Price discovery requires public state; a market price nobody can read isn't a price. Full AMM privacy is architecturally impossible, which is precisely why the dark pool exists as the venue for size.
TP/SL execution trusts the trigger. The trigger direction is validated on-chain when the order is placed, and execution price must equal the fresh oracle price. But execution does not re-check that the oracle actually crossed the trigger β the orchestrator is trusted to fire only when it has. Small, bounded, and it's in the limitations table rather than buried.
The whole trust surface in one table
| Party | Trusted for | Sees | Cannot |
|---|---|---|---|
| Orchestrator β 1 key | Open interest, net PnL, TP/SL triggers | Every position: size, direction, entry, collateral | Inflate a trader's payout; write liquidity or LP-token figures |
| Keeper set β 3, permissioned | Liveness only | Every position, from the moment it opens | Liquidate a solvent position; inflate a reward |
| Oracle signers β 2-of-3, on-chain | Price truth | Public prices only | Move funds; touch LP accounting |
| Dark pool operator | Matching honesty; non-censorship | Order size, limit, direction, asset, address | Settle outside signed limits; exceed escrow |
| AMM | Nothing β public by design | β | β |
Reducing these: the orchestrator's remaining writes go behind a k-of-n quorum next. Raising n on the oracle is straightforward. Operator visibility β for both the keepers and the dark pool β is open research, not a roadmap item.
Read the second and third columns together. With the write surface scoped down, integrity across this system is in decent shape: no party on that list can take a trader's money or an LP's. Confidentiality is narrower than the headline suggests, and the operators are the reason.
Why publish this
Two reasons, one principled and one selfish.
The principled one: a trust assumption you haven't named is a trust assumption your users are carrying without pricing. Every private system has one somewhere β the alternative to disclosing yours isn't having none, it's having an undisclosed one. The protocols that get this wrong aren't usually lying; they're describing the cryptography accurately and letting readers generalize from it to the whole system.
The selfish one: this is the post that makes the rest of them credible. When I claim that a keeper can't liquidate a healthy position, or that a trader can't inflate a payout, those claims are worth more from someone who also told you that the same keepers can read every position you open, and that the orchestrator can currently overwrite total_lp_tokens. Anyone can list what their system prevents. The disclosure that costs something is the one that carries information.
I'd rather correct my own privacy claim in public than have someone else do it after an audit.
Where this sits
ZKPerp runs on Aleo testnet, has not been formally audited, and is not intended for real funds. The full trust analysis lives in Β§4 and Β§13 of the technical whitepaper; the complete limitations table is Β§14.
This series
- What ZKPerp is β the overview: what's encrypted, what's live, and what isn't
- How do you liquidate a position you can't see? β the keeper-auth pattern, five on-chain gates, and the part that stays unsolved
- What zero-knowledge can't do β this post
- Four buildathons and a regulatory wall β what broke, what I got wrong, and why I can't run this myself
Technical whitepaper: ZKPerp whitepaper (PDF). Contracts and source: github.com/hwdeboer1977/ZKPerp. Network: Aleo testnet. MIT licensed.
If you're working on privacy-preserving markets, ZK execution models, or you operate a licensed venue and the phrase "settlement layer where order flow isn't visible to the market" sounds like a product rather than a slogan β I'd like to hear from you.