← All posts

ZKPerp 2/4: How Do You Liquidate a Position You Can't See?

Β·9 min read
Web3Zero-KnowledgeAleoDeFi

ZKPerp β€” privacy-preserving trading with on-chain verifiability

Part 2 of a four-part series on ZKPerp. The hardest problem in privacy-preserving perpetuals, and the pattern I used to solve it.

Part 1 covered what ZKPerp is and what it encrypts. This post is about the problem that encryption creates.

Every perpetual futures exchange needs liquidation. A trader posts collateral, takes leveraged exposure, and if the market moves against them far enough, someone has to close that position before the losses exceed what they put up. On a transparent exchange this is a solved problem, and it's solved the same way everywhere: positions are public, so anyone can compute anyone else's liquidation price, and a competitive market of bots races to close underwater positions in exchange for a fee.

That entire design depends on one assumption. The positions are readable.

ZKPerp is a perpetual DEX built natively on Aleo, where position size, entry price, collateral, leverage, and PnL live inside encrypted records that only the position owner can decrypt. Nobody else can read them. Not other traders, not the frontend, not the protocol.

Which raises the obvious question: if nobody can see the position, who closes it?

The problem is worse than it first looks

The instinct is to hand a liquidator the trader's viewing key, or some scoped derivative of it. That fails twice.

It fails on privacy, because a viewing key that reveals enough to compute a liquidation threshold reveals the position itself β€” that's the same information.

But it fails on mechanics first, which is the more interesting failure. Aleo uses a UTXO-style record model. A private record has an owner, and only that owner can consume it in a transition. A liquidator holding a viewing key can read the trader's position record. It still cannot spend it. The liquidation transaction would need the trader to sign, and the entire point of liquidation is that it must work when the trader is offline, unwilling, or actively hoping nobody notices.

So the liquidator needs a record it owns, containing enough information to act, minted at a moment when the trader was necessarily present.

The keeper-auth pattern

There is exactly one such moment: when the position opens.

Every call to open_position() produces, in a single atomic transition:

  • the trader's updated PositionSlot β€” the private record holding the full position
  • three LiquidationAuth records, one minted to each keeper in the on-chain liquidator_set

Each LiquidationAuth mirrors the position's parameters: entry_price, size_usdc, collateral_usdc, is_long, position_id. Each is owned by a keeper. Any single keeper can pass its own record into liquidate() and terminate the position without ever touching β€” or reading β€” the trader's slot.

Atomicity matters here. The auths are not a separate registration step the trader might skip; they exist or the position doesn't. And the keeper addresses are pinned to the on-chain liquidator_set and verified in open_position's finalize, so a trader can't quietly substitute three wallets they control and make their position unliquidatable.

But now the keeper is holding numbers

Handing keepers a record with the position's parameters in it solves the mechanics and immediately creates a new problem. Those fields are just data in a record. What stops a keeper from editing them β€” inflating collateral_usdc to inflate its own reward, or shrinking size_usdc so a healthy position looks underwater?

Nothing, at the record level. So the chain doesn't trust them.

At open time, the ZK circuit hashes the position parameters into a single commitment and stores only that hash on-chain:

commit = BHP256::hash_to_field(PositionCommit {
    entry_price, size_usdc, collateral_usdc, is_long, position_id
})

Mapping::set(position_commits, position_id, commit)

One field element. It reveals nothing about the position β€” a block explorer sees an opaque hash.

At liquidation time, the keeper supplies the parameters as private inputs, the circuit recomputes the commitment, and finalize asserts it equals the stored hash. Any edited field produces a different hash and the transaction reverts.

This is the same mechanism that closed an uglier hole on the close path. In an earlier version of the contracts, a trader closing a position supplied their own expected_payout as an unverified private input. The contract had no way to check it, because the parameters lived in a record finalize cannot read. A malicious trader could claim any payout the pool could cover. The commitment scheme fixed that, and the liquidation path now extends the identical mechanism in the other direction β€” against the keeper instead of the trader.

Five gates

finalize_liquidate enforces the following on-chain, in order:

Gate 1 β€” commitment match. The recomputed BHP256 hash must equal position_commits[position_id]. Position parameters are verified, not asserted.

Gate 2 β€” oracle price and freshness. The exit price must equal the live price in the 2-of-3 quorum oracle mapping, and that price must be no more than 150 blocks old (roughly five minutes). A keeper can't liquidate against a price it invented or a stale price it waited for.

Gate 3 β€” pool solvency. total_liquidity must cover the reward.

Gate 4 β€” genuinely underwater. Equity is computed on-chain from the commit-verified parameters and the fresh oracle price, and must be below the maintenance margin β€” 5% of notional. This is the gate that makes wrongful liquidation impossible rather than merely discouraged.

Gate 5 β€” caller is a registered keeper.

Plus replay protection: active_position_ids is checked and cleared atomically, which kills double-liquidation, the close-and-liquidate race, and stale TP/SL execution against an already-closed position.

The reward is deterministic β€” 10% of collateral β€” and derived from the commit-verified collateral_usdc, so it can't be inflated either. A malicious keeper's entire attack surface reduces to submitting failing transactions and wasting its own gas.

1-of-N buys liveness, not honesty

It's worth being precise about what the multi-keeper set actually does, because it's easy to mistake it for a trust mechanism.

It isn't one. Correctness is enforced on-chain regardless of which keeper submits β€” the gates above don't care who the caller is beyond membership. The keeper set exists for liveness and censorship-resistance. Because any single keeper can act alone, up to Nβˆ’1 can be offline or refuse without halting liquidations. A keeper that withholds a liquidation doesn't protect the trader; it just hands the fee to a competitor. Keepers race rather than collude, and the losers clean up their now-stale auths via burn_liquidation_auth.

That's a different trust story from the orchestrator that aggregates open interest, where the chain genuinely cannot verify the inputs and distributing the key across a quorum is the only available defense. Here the chain already verifies everything. The keepers only need to show up. (That distinction β€” verified versus merely distributed β€” is the subject of next week's post.)

The dead record problem

One consequence of asymmetric ownership is worth mentioning, because it's the kind of thing that looks like a bug in a wallet.

When a keeper liquidates, it consumes its own LiquidationAuth. It cannot consume the trader's PositionSlot β€” it doesn't own it. Without mitigation, every liquidation would strand a permanently unusable record in the trader's wallet with no user-side recovery path.

So liquidate() mints a fresh, empty PositionSlot directly to the trader's address. The old filled slot stays in the wallet as dust, and any attempt to use it reverts at finalize because active_position_ids no longer contains the position ID. The frontend queries that mapping and filters records before requesting wallet decryption, so the dust never surfaces in the UI.

Net result for the trader: the active slot set is always three records β€” two PositionSlots and one LPSlot β€” regardless of trade history. No cleanup transaction, no user action after liquidation.

The part I haven't solved

Here's the honest limitation, and it's a real one.

ZKPerp's keeper set is permissioned. The protocol decides who may liquidate. I can defend the bounds of that power β€” keepers cannot set prices, invent positions, redirect funds, or touch a solvent position β€” so what remains centralized is the ability to withhold a liquidation or compete for a reward, not to seize anything. Non-custodial and rule-bound is a meaningful property. It is not decentralization, and I don't claim it is.

Opening the set to anyone runs into a problem I consider genuinely unsolved rather than merely unbuilt: detection under privacy.

Permissionless liquidation requires that any party can identify a liquidatable position. But a keeper can only tell whether a position is underwater if it can evaluate the margin condition β€” which means knowing size, collateral, direction, and entry. Once it knows those, the position isn't private. And entry price can be narrowed further from the public oracle price at the block where the position opened.

Every other perpetual exchange has permissionless liquidation because its positions were public to begin with. That's not a design choice they made and I declined; it's a property I gave up when I encrypted the positions. Closing the gap needs something like a proof of liquidatability that reveals only the boolean β€” and that's open research, not a roadmap item with a date on it.

Where this sits

ZKPerp runs on Aleo testnet. Core perpetuals for BTC, ETH, and SOL are live, with the commitment scheme covering close, take-profit, stop-loss, and liquidation. It has not been formally audited, and it is not intended for real funds.

If you're building on Leo and hit the same wall β€” a privileged action that must work on a record you can't touch β€” the keeper-auth pattern generalizes well beyond perps. Mint the authorization at the moment the owner is present, commit the parameters, and verify on the way out.

This series

  1. What ZKPerp is β€” the overview: what's encrypted, what's live, and what isn't
  2. How do you liquidate a position you can't see? β€” this post
  3. What zero-knowledge can't do β€” every trust assumption in the system, named and bounded, including the one this post just gestured at
  4. 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.