← Alle artikelen

ZKPerp 2/4: hoe liquideer je een positie die je niet kunt zien?

·9 min lezen
Web3Zero-KnowledgeAleoDeFi

ZKPerp — privacy-behoudend handelen met on-chain verifieerbaarheid

Deel 2 van een vierdelige serie over ZKPerp. Het lastigste probleem in privacy-behoudende perpetuals, en het patroon waarmee ik het heb opgelost.

Deel 1 ging over wat ZKPerp is en wat het versleutelt. Deze post gaat over het probleem dat die versleuteling veroorzaakt.

Elke perpetual-futuresbeurs heeft liquidatie nodig. Een trader stort collateral, neemt exposure met leverage, en als de markt ver genoeg tegen hem in beweegt, moet iemand die positie sluiten voordat de verliezen groter worden dan de inleg. Op een transparante beurs is dat een opgelost probleem, en het wordt overal op dezelfde manier opgelost: posities zijn publiek, dus iedereen kan andermans liquidatieprijs berekenen, en een concurrerende markt van bots racet om onderwater posities te sluiten in ruil voor een fee.

Dat hele ontwerp steunt op één aanname. De posities zijn leesbaar.

ZKPerp is een perpetual-DEX die natively op Aleo is gebouwd, waar positiegrootte, entry price, collateral, leverage en PnL in versleutelde records staan die alleen de eigenaar van de positie kan ontsleutelen. Niemand anders kan ze lezen. Andere traders niet, de frontend niet, het protocol niet.

Wat de voor de hand liggende vraag oproept: als niemand de positie kan zien, wie sluit hem dan?

Het probleem is erger dan het op het eerste gezicht lijkt

De eerste ingeving is om een liquidator de viewing key van de trader te geven, of een afgebakende afgeleide daarvan. Dat faalt op twee manieren.

Het faalt op privacy, want een viewing key die genoeg onthult om een liquidatiedrempel te berekenen, onthult de positie zelf — dat is dezelfde informatie.

Maar het faalt eerst op mechaniek, en dat is de interessantere mislukking. Aleo gebruikt een UTXO-achtig recordmodel. Een privaat record heeft een eigenaar, en alleen die eigenaar kan het in een transition consumeren. Een liquidator met een viewing key kan het positierecord van de trader lezen. Hij kan het nog steeds niet uitgeven. De liquidatietransactie zou de handtekening van de trader nodig hebben, en het hele punt van liquidatie is dat het moet werken wanneer de trader offline is, niet wil meewerken, of juist hoopt dat niemand het merkt.

De liquidator heeft dus een record nodig dat hij zelf bezit, met genoeg informatie om te handelen, gemint op een moment waarop de trader noodzakelijkerwijs aanwezig was.

Het keeper-auth-patroon

Er is precies één zo'n moment: bij het openen van de positie.

Elke aanroep van open_position() produceert, in één atomaire transition:

  • de bijgewerkte PositionSlot van de trader — het private record met de volledige positie
  • drie LiquidationAuth-records, één gemint aan elke keeper in de on-chain liquidator_set

Elke LiquidationAuth spiegelt de parameters van de positie: entry_price, size_usdc, collateral_usdc, is_long, position_id. Elk record is eigendom van een keeper. Elke afzonderlijke keeper kan zijn eigen record aan liquidate() meegeven en de positie beëindigen zonder ooit de slot van de trader aan te raken — of te lezen.

Atomiciteit is hier van belang. De auths zijn geen aparte registratiestap die de trader zou kunnen overslaan; ze bestaan, of de positie bestaat niet. En de keeper-adressen zijn vastgelegd in de on-chain liquidator_set en worden geverifieerd in de finalize van open_position, zodat een trader er niet stilletjes drie wallets van zichzelf voor in de plaats kan zetten om zijn positie onliquideerbaar te maken.

Maar nu houdt de keeper getallen vast

Keepers een record met de positieparameters geven lost de mechaniek op en creëert meteen een nieuw probleem. Die velden zijn gewoon data in een record. Wat weerhoudt een keeper ervan ze aan te passen — collateral_usdc opblazen om zijn eigen beloning op te blazen, of size_usdc verkleinen zodat een gezonde positie onderwater lijkt?

Niets, op recordniveau. Dus de chain vertrouwt ze niet.

Bij het openen hasht het ZK-circuit de positieparameters tot één commitment en slaat alleen die hash on-chain op:

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

Mapping::set(position_commits, position_id, commit)

Eén field-element. Het onthult niets over de positie — een block explorer ziet een ondoorzichtige hash.

Bij liquidatie levert de keeper de parameters aan als private inputs, herberekent het circuit de commitment, en dwingt finalize af dat die gelijk is aan de opgeslagen hash. Elk gewijzigd veld levert een andere hash op en de transactie draait terug.

Dit is hetzelfde mechanisme dat een lelijker gat op het close-pad dichtte. In een eerdere versie van de contracten leverde een trader die een positie sloot zijn eigen expected_payout aan als ongeverifieerde private input. Het contract had geen manier om dat te controleren, omdat de parameters in een record stonden dat finalize niet kan lezen. Een kwaadwillende trader kon elke uitbetaling claimen die de pool kon dekken. Het commitment-schema loste dat op, en het liquidatiepad breidt nu hetzelfde mechanisme uit in de andere richting — tegen de keeper in plaats van tegen de trader.

Vijf gates

finalize_liquidate dwingt het volgende on-chain af, in deze volgorde:

Gate 1 — commitment komt overeen. De herberekende BHP256-hash moet gelijk zijn aan position_commits[position_id]. Positieparameters worden geverifieerd, niet aangenomen.

Gate 2 — oracle-prijs en versheid. De exit price moet gelijk zijn aan de live prijs in de 2-of-3 quorum-oracle-mapping, en die prijs mag niet ouder zijn dan 150 blocks (ruwweg vijf minuten). Een keeper kan niet liquideren tegen een prijs die hij zelf verzint of een verouderde prijs waarop hij heeft gewacht.

Gate 3 — solvabiliteit van de pool. total_liquidity moet de beloning dekken.

Gate 4 — daadwerkelijk onderwater. De equity wordt on-chain berekend uit de commit-geverifieerde parameters en de verse oracle-prijs, en moet onder de maintenance margin liggen — 5% van de notional. Dit is de gate die onterechte liquidatie onmogelijk maakt in plaats van alleen maar ontmoedigd.

Gate 5 — de caller is een geregistreerde keeper.

Plus replay-bescherming: active_position_ids wordt atomair gecontroleerd en leeggemaakt, wat dubbele liquidatie, de close-en-liquideer-race en verouderde TP/SL-uitvoering tegen een al gesloten positie onmogelijk maakt.

De beloning is deterministisch — 10% van de collateral — en afgeleid van de commit-geverifieerde collateral_usdc, dus ook die valt niet op te blazen. Het volledige aanvalsoppervlak van een kwaadwillende keeper reduceert tot het indienen van falende transacties en het verspillen van zijn eigen gas.

1-of-N koopt liveness, geen eerlijkheid

Het is de moeite waard precies te zijn over wat de multi-keeper-set werkelijk doet, want het is makkelijk om het aan te zien voor een vertrouwensmechanisme.

Dat is het niet. Correctheid wordt on-chain afgedwongen ongeacht welke keeper indient — de gates hierboven kijken verder niet naar wie de caller is, behalve of hij lid is. De keeper-set bestaat voor liveness en censuurbestendigheid. Omdat elke afzonderlijke keeper alleen kan handelen, mogen er tot N−1 offline zijn of weigeren zonder dat liquidaties stilvallen. Een keeper die een liquidatie achterhoudt, beschermt de trader niet; hij geeft de fee alleen weg aan een concurrent. Keepers racen in plaats van samen te spannen, en de verliezers ruimen hun inmiddels verouderde auths op via burn_liquidation_auth.

Dat is een ander vertrouwensverhaal dan bij de orchestrator die open interest aggregeert, waar de chain de inputs werkelijk niet kan verifiëren en het verdelen van de key over een quorum de enige beschikbare verdediging is. Hier verifieert de chain al alles. De keepers hoeven alleen op te komen dagen. (Dat onderscheid — geverifieerd versus slechts verdeeld — is het onderwerp van de post van volgende week.)

Het probleem van het dode record

Eén gevolg van asymmetrisch eigenaarschap is het vermelden waard, want het is het soort ding dat in een wallet op een bug lijkt.

Wanneer een keeper liquideert, consumeert hij zijn eigen LiquidationAuth. Hij kan de PositionSlot van de trader niet consumeren — die is niet van hem. Zonder mitigatie zou elke liquidatie een permanent onbruikbaar record in de wallet van de trader achterlaten, zonder herstelpad aan gebruikerszijde.

Daarom mint liquidate() een verse, lege PositionSlot rechtstreeks naar het adres van de trader. De oude gevulde slot blijft als stof in de wallet achter, en elke poging om hem te gebruiken draait terug bij finalize omdat active_position_ids het position ID niet meer bevat. De frontend bevraagt die mapping en filtert records voordat hij de wallet om ontsleuteling vraagt, zodat het stof nooit in de UI opduikt.

Netto resultaat voor de trader: de actieve slot-set is altijd drie records — twee PositionSlots en één LPSlot — ongeacht de handelsgeschiedenis. Geen opruimtransactie, geen actie van de gebruiker na liquidatie.

Het deel dat ik niet heb opgelost

Hier is de eerlijke beperking, en het is een echte.

De keeper-set van ZKPerp is permissioned. Het protocol bepaalt wie mag liquideren. Ik kan de grenzen van die macht verdedigen — keepers kunnen geen prijzen zetten, geen posities verzinnen, geen geld omleiden en geen solvente positie aanraken — dus wat centraal blijft is de mogelijkheid om een liquidatie achter te houden of om een beloning te concurreren, niet om iets af te pakken. Non-custodial en regelgebonden is een betekenisvolle eigenschap. Het is geen decentralisatie, en dat beweer ik ook niet.

De set voor iedereen openstellen loopt tegen een probleem aan dat ik werkelijk onopgelost acht in plaats van slechts ongebouwd: detectie onder privacy.

Permissionless liquidatie vereist dat elke partij een liquideerbare positie kan identificeren. Maar een keeper kan alleen weten of een positie onderwater staat als hij de margin-conditie kan evalueren — en dat betekent size, collateral, richting en entry kennen. Zodra hij die kent, is de positie niet privé. En de entry price valt verder in te perken met de publieke oracle-prijs in het block waarin de positie werd geopend.

Elke andere perpetual-beurs heeft permissionless liquidatie omdat haar posities om te beginnen publiek waren. Dat is geen ontwerpkeuze die zij maakten en ik afsloeg; het is een eigenschap die ik heb opgegeven toen ik de posities versleutelde. Het gat dichten vraagt om zoiets als een proof of liquidatability die alleen de boolean onthult — en dat is open onderzoek, geen roadmap-item met een datum erop.

Waar dit staat

ZKPerp draait op Aleo-testnet. De kern-perpetuals voor BTC, ETH en SOL zijn live, met het commitment-schema over close, take-profit, stop-loss en liquidatie. Het is niet formeel geaudit en het is niet bedoeld voor echt geld.

Bouw je op Leo en loop je tegen dezelfde muur aan — een bevoorrechte actie die moet werken op een record dat je niet kunt aanraken — dan generaliseert het keeper-auth-patroon goed voorbij perps. Mint de autorisatie op het moment dat de eigenaar aanwezig is, commit de parameters, en verifieer op de weg naar buiten.

Deze serie

  1. Wat ZKPerp is — het overzicht: wat versleuteld is, wat live is en wat niet
  2. Hoe liquideer je een positie die je niet kunt zien? — deze post
  3. Wat zero-knowledge niet kan — elke trust-aanname in het systeem, benoemd en begrensd, inclusief die waar deze post naar wees
  4. Vier buildathons en een regelgevingsmuur — wat brak, wat ik verkeerd deed, en waarom ik dit niet zelf kan draaien

Technische whitepaper: ZKPerp-whitepaper (PDF). Contracten en broncode: github.com/hwdeboer1977/ZKPerp. Netwerk: Aleo-testnet. MIT-gelicentieerd.

Werk je aan privacy-behoudende markten, ZK-uitvoeringsmodellen, of exploiteer je een gelicentieerde venue en klinkt de zin "settlement-laag waar order flow niet zichtbaar is voor de markt" als een product in plaats van een slogan — dan hoor ik graag van je.