From d10b308733c3880443a9cbe4430e1b13cb640b71 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 19:41:43 +0000 Subject: [PATCH] perpetual-futures: adopt the Percolator haircut risk model Replace the reserve-and-cap risk model in both the Anchor and Quasar implementations with the haircut model from Percolator (github.com/aeyakovenko/percolator): - Profit is a junior claim. Positions open with no up-front reserve and no open-interest cap; profit runs uncapped. Solvency is held at exit by a global haircut ratio h that scales every winner's profit to the backing the pool can cover, the same fraction for all. Removes Pool.reserved_liquidity and the per-position profit cap; provider withdrawals are gated by the profit traders are currently owed. - Profit maturation: positions carry entry_slot and cannot be closed in profit until profit_warmup_slots elapse (oracle-manipulation defense). Loss is never gated. - Insurance fund: funded by an insurance_fee_bps cut of each fee, absorbs bankruptcy deficits before liquidity providers, and counts as backing in the haircut. Both implementations build to SBF and pass their LiteSVM tests (29 Anchor, 20 Quasar), including new haircut, maturation, withdrawal-guard, and insurance-fund cases. READMEs, TERMINOLOGY, and CHANGELOG document the model and why Percolator's peer-to-peer A/K overhang indices do not map onto a single-counterparty pool. --- CHANGELOG.md | 13 + finance/perpetual-futures/anchor/README.md | 68 +++-- .../perpetual-futures/anchor/TERMINOLOGY.md | 13 + .../perpetual-futures/src/constants.rs | 7 + .../programs/perpetual-futures/src/errors.rs | 3 + .../src/instructions/close_position.rs | 44 ++- .../src/instructions/initialize_pool.rs | 18 +- .../src/instructions/liquidate_position.rs | 33 +- .../src/instructions/open_position.rs | 28 +- .../src/instructions/remove_liquidity.rs | 19 +- .../src/instructions/shared.rs | 69 ++++- .../perpetual-futures/src/state/pool.rs | 27 +- .../perpetual-futures/src/state/position.rs | 4 + .../tests/test_perpetual_futures.rs | 284 ++++++++++++++++-- finance/perpetual-futures/quasar/README.md | 6 +- .../perpetual-futures/quasar/src/constants.rs | 4 + .../quasar/src/instructions/close_position.rs | 51 +++- .../src/instructions/initialize_pool.rs | 11 +- .../src/instructions/liquidate_position.rs | 28 +- .../quasar/src/instructions/open_position.rs | 32 +- .../src/instructions/remove_liquidity.rs | 15 +- .../quasar/src/instructions/shared.rs | 71 ++++- finance/perpetual-futures/quasar/src/lib.rs | 4 + finance/perpetual-futures/quasar/src/state.rs | 20 +- finance/perpetual-futures/quasar/src/tests.rs | 196 +++++++++++- 25 files changed, 889 insertions(+), 179 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 028271cb..4a654900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this repository are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2026-06-29] - Perpetual-futures Percolator risk model + +### Changed + +- `finance/perpetual-futures` (both the Anchor and Quasar implementations) replaced its reserve-and-cap risk model with the haircut model adapted from [Percolator](https://github.com/aeyakovenko/percolator). Trader profit is now a junior claim: positions open with no up-front reserve and no open-interest cap, profit runs uncapped, and solvency is held at exit by a global haircut ratio `h` that scales every winner's profit to the backing the pool can cover. The `Pool.reserved_liquidity` field and the per-position profit cap were removed; provider withdrawals are now gated by the profit traders are currently owed rather than by a reserve. +- Liquidating a position that gapped through zero equity now draws the deficit from the insurance fund before socializing any of it to liquidity providers. + +### Added + +- Profit maturation: positions carry an `entry_slot` and cannot be closed in profit until `profit_warmup_slots` have elapsed, an oracle-manipulation defense. Loss is never gated. +- An insurance fund (`Pool.insurance_fund`), funded by an `insurance_fee_bps` cut of every open/close fee, which absorbs bankruptcy deficits and counts as backing in the haircut. +- Tests in both implementations for the haircut, maturation, withdrawal guard, and insurance fund; READMEs and `TERMINOLOGY.md` document the model and why Percolator's peer-to-peer `A`/`K` overhang indices do not map onto a single-counterparty pool. + ## [2026-06-12] - Rust + LiteSVM tests everywhere ### Changed diff --git a/finance/perpetual-futures/anchor/README.md b/finance/perpetual-futures/anchor/README.md index 25b4a3fe..5eafda0d 100644 --- a/finance/perpetual-futures/anchor/README.md +++ b/finance/perpetual-futures/anchor/README.md @@ -36,9 +36,29 @@ short profit/loss = size * (entry_price - price) / entry_price There is no order book. Every trade is against one shared [liquidity pool](https://www.investopedia.com/terms/l/liquidity.asp) that other users fund; the pool is the counterparty to all of them — it pays trader profits and keeps trader losses. Providers receive shares priced against [mark-to-market](https://www.investopedia.com/terms/m/marktomarket.asp) assets-under-management (the pool's value if every open position were settled now), derived from running per-side accumulators rather than by iterating positions. Pricing against the marked value stops a provider exiting just before an in-flight trader profit is realized. The first deposit mints `deposit - MINIMUM_LIQUIDITY` shares (the Uniswap V2 convention) so the share supply never starts at a dust amount. -### Reserved liquidity +### Profit is a junior claim — the haircut `h` -So a winning trader can always be paid, the pool **reserves** liquidity to back each open position's maximum recoverable profit (its notional `size`). An open is allowed only while `reserved + size <= liquidity`, which doubles as an open-interest cap. `close_position` caps a winner's payout at the reserved `size` (for a long, profit is capped on a more-than-doubling move; a short's profit is naturally within `size`), and provider withdrawals can take only the *free* remainder (`liquidity - reserved`). This is the simplified, single-collateral form of the reserve accounting in `solana-labs/perpetuals`. The reserve covers price profit only — funding owed *to* a position (the lighter side receives funding) is not reserved, so in the extreme a payout the pool cannot cover makes the close fail closed (revert) rather than leave the pool insolvent. +This example takes its risk model from [Percolator](https://github.com/aeyakovenko/percolator), Anatoly Yakovenko's formally-verified perp risk engine. The one idea everything rests on: **deposited capital is senior, profit is junior.** A trader's posted collateral is always theirs to reclaim; their *profit* is only as real as the money behind it. + +So there is no per-position profit cap and no up-front reserve. Positions open freely — even when the pool could not pay their full winnings — and profit runs uncapped. Solvency is kept at *exit* instead, by a single global number, the **haircut ratio `h`**: + +``` +backing = liquidity + insurance_fund // what can pay profit +liability = max(0, traders' aggregate unrealized profit) // the junior claim +h = min(1, backing / liability) // floored, so payouts never exceed backing +``` + +When the pool can back every winner, `h = 1` and profit is paid in full. When a sharp move leaves traders owed more than the pool holds, `h` drops below one and *every* closing winner is paid the same fraction of their profit — no queue, no chosen victims, the way an auto-deleveraging queue would pick them. As losses settle back in, `backing` recovers and `h` rises on its own. The withheld `(1 - h)` of each winner's profit stays in `liquidity`: this is how a single-counterparty pool socializes a shortfall across its providers. + +### Profit maturation (warm-up) + +A haircut alone is gameable: spike the oracle, open against the paper gain, cash out in the same block. So profit must **mature** before it can be realized — a position cannot be closed in profit until `profit_warmup_slots` have passed since it opened. By the time a manipulated price's profit would mature, the manipulation is gone. Loss is never gated this way: an underwater position can always be closed or liquidated at once. + +### The insurance fund + +A fraction of every open/close fee (`insurance_fee_bps`) accrues to an **insurance fund** — a senior buffer. When a position gaps straight through zero equity and owes more than its collateral, that deficit is drawn from the insurance fund first, and only what the fund cannot cover is socialized to liquidity providers. The fund also counts as `backing` in the haircut math above, so a healthy fund keeps `h` at one for longer. This is the pool-model stand-in for the bankruptcy-overhang clearing that a peer-to-peer venue does with an auto-deleveraging queue. + +Provider withdrawals can still only take *free* liquidity — the backing for the profit traders are currently owed stays put, so a provider cannot withdraw out from under a winning trader. (See [Design notes](#design-notes-and-further-reading) for why Percolator's per-side `A`/`K` overhang indices don't map onto a single-counterparty pool.) ### Funding @@ -46,7 +66,7 @@ So a winning trader can always be paid, the pool **reserves** liquidity to back ### Maintenance margin and liquidation -A position's *equity* is its net collateral plus profit/loss minus funding. Once equity falls to or below the [maintenance margin](https://www.investopedia.com/terms/m/maintenancemargin.asp) (`maintenance_margin_bps` of notional), the position can be [liquidated](https://www.investopedia.com/terms/l/liquidation.asp). Liquidation is permissionless — anyone can crank it and earn the liquidation fee. +A position's *equity* is its net collateral plus profit/loss minus funding. Once equity falls to or below the [maintenance margin](https://www.investopedia.com/terms/m/maintenancemargin.asp) (`maintenance_margin_bps` of notional), the position can be [liquidated](https://www.investopedia.com/terms/l/liquidation.asp). Liquidation is permissionless — anyone can crank it and earn the liquidation fee. If the position gapped through zero equity and owes more than its collateral, the deficit is taken from the insurance fund before any of it reaches the liquidity providers. ### Oracle @@ -54,7 +74,7 @@ The mark price comes from an oracle feed. This example validates the price for s ### Fees and slippage -Open and close fees are charged in [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (1 bp = 0.01%) of notional and accrue to the protocol. Every state-changing handler takes a `minimum_*` / acceptable-price bound — protection against [slippage](https://www.investopedia.com/terms/s/slippage.asp), the gap between the expected and actual fill — and reverts if the bound is breached. Pass `0` to opt out. +Open and close fees are charged in [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (1 bp = 0.01%) of notional. Each fee is split: `insurance_fee_bps` of it tops up the insurance fund and the rest accrues to the protocol. Every state-changing handler takes a `minimum_*` / acceptable-price bound — protection against [slippage](https://www.investopedia.com/terms/s/slippage.asp), the gap between the expected and actual fill — and reverts if the bound is breached. Pass `0` to opt out. --- @@ -70,7 +90,7 @@ Open and close fees are charged in [basis points](https://www.investopedia.com/t | **Bob** | Short trader | He thinks NVDA will fall and wants to profit from the downside. | | **Dave** | Liquidator | Runs a bot that closes under-margined positions to earn the liquidation fee. | -Amounts below are shown in whole USDC; on-chain they are base units (× 10⁶). The pool is configured with 10× max leverage, 0.1% open/close fees, a 5% maintenance margin, a 1% liquidation fee, and a 1% maximum oracle confidence band. +Amounts below are shown in whole USDC; on-chain they are base units (× 10⁶). The pool is configured with 10× max leverage, 0.1% open/close fees, a 5% maintenance margin, a 1% liquidation fee, and a 1% maximum oracle confidence band. The insurance-fee cut and profit warm-up are left at zero in this walkthrough so the numbers stay exact; the [risk-model concepts](#profit-is-a-junior-claim--the-haircut-h) above cover what they do. --- @@ -82,7 +102,7 @@ Amounts below are shown in whole USDC; on-chain they are base units (× 10⁶). | Account | Seeds / Derivation | What it stores | |---------|--------------------|----------------| -| `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda) | `["pool", collateral_mint, oracle_feed]` | parameters, liquidity, reserved liquidity, collateral total, per-side open-interest accumulators, funding index, protocol fees | +| `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda) | `["pool", collateral_mint, oracle_feed]` | parameters, liquidity, insurance fund, collateral total, per-side open-interest accumulators, funding index, protocol fees | | `pool_authority` PDA | `["authority", pool]` | nothing; signs vault and mint CPIs | | `custody_vault` [token account](https://solana.com/docs/terminology#token-account) PDA | `["vault", pool]` | all USDC — both provider liquidity and trader collateral | | `lp_mint` PDA | `["lp_mint", pool]` | the share [mint](https://solana.com/docs/terminology#mint-account); `pool_authority` is the mint authority | @@ -116,21 +136,22 @@ NVDAx is at $100. The 0.1% open fee ($5) comes out of her collateral, leaving $9 | Account | Change | |---------|--------| -| `Position` PDA `["position", pool, alice, Long]` (created) | side Long, collateral $995, size $5,000, entry price $100 | +| `Position` PDA `["position", pool, alice, Long]` (created) | side Long, collateral $995, size $5,000, entry price $100, entry slot | | `alice_usdc` | −1,000 USDC | | `custody_vault` | +1,000 USDC | | `Pool.total_collateral` | +$995 | -| `Pool.protocol_fees` | +$5 | -| `Pool.reserved_liquidity` | +$5,000 (must stay ≤ liquidity) | +| `Pool.protocol_fees` | +$5 (the protocol's share of the open fee) | | `Pool` long open-interest accumulators | += this position | +No liquidity is reserved and there is no open-interest cap: the position can open even if the pool could not pay its full winnings, because the haircut keeps the pool solvent at exit (see [the haircut `h`](#profit-is-a-junior-claim--the-haircut-h)). + --- ### Step 4 — Bob opens a 5× short **Instruction:** `open_position(side = Short, collateral_amount = 1,000 USDC, size = 5,000 USDC, acceptable_price)` -**Accounts modified:** a `Position` PDA `["position", pool, bob, Short]` is created; `custody_vault` +1,000 USDC; `Pool.total_collateral` +$995; `Pool.protocol_fees` +$5; `Pool.reserved_liquidity` +$5,000 (now $10,000 of the $100,000 reserved); short open-interest accumulators rise. +**Accounts modified:** a `Position` PDA `["position", pool, bob, Short]` is created; `custody_vault` +1,000 USDC; `Pool.total_collateral` +$995; `Pool.protocol_fees` +$5; short open-interest accumulators rise. While both are open, **funding** accrues to the pool from the heavier side; it is settled when each position closes. @@ -140,14 +161,13 @@ While both are open, **funding** accrues to the pool from the heavier side; it i **Instruction:** `close_position(minimum_payout)` -Her profit is `5,000 × (116 − 100) / 100 = $800` (well under the $5,000 reserve cap), minus the $5 close fee. +Her profit is `5,000 × (116 − 100) / 100 = $800`, minus the $5 close fee. The pool's $100,000 of backing dwarfs the profit traders are owed, so the haircut `h` is one and her profit is paid in full. (Had the pool been stressed, she would have been paid `h × $800` — the same fraction every other winner gets at that moment.) **Accounts modified:** | Account | Change | |---------|--------| | `Pool.liquidity` | −$800 (providers pay her profit) | -| `Pool.reserved_liquidity` | −$5,000 (reserve released) | | `Pool.total_collateral` | −$995 | | `Pool.protocol_fees` | +$5 | | long open-interest accumulators | −= this position | @@ -167,13 +187,14 @@ At $116 Bob's short has lost $800; his equity ($995 − $800 = $195) has fallen | Account | Change | |---------|--------| | short open-interest accumulators | −= Bob's position | -| `Pool.reserved_liquidity` | −$5,000 (reserve released) | | `Pool.total_collateral` | −$995 | | `Pool.liquidity` | +$800 (the loss accrues to providers) | | `custody_vault` → `dave_usdc` (created) | $50 liquidation fee | | `custody_vault` → `bob_usdc` | $145 remaining equity refunded | | `Position` (Bob) | closed; rent returned to Bob | +Bob still had positive equity here, so the insurance fund is untouched. Had he gapped below zero — owing more than his $995 collateral — the shortfall would have been drawn from the insurance fund first, and only the remainder socialized to `Pool.liquidity`. + --- ### Step 7 — Admin collects the protocol's fees @@ -188,7 +209,7 @@ At $116 Bob's short has lost $800; his equity ($995 − $800 = $195) has fallen **Instruction:** `remove_liquidity(shares, minimum_amount_out)` -Carol burns her shares and redeems USDC. Her balance now reflects the fees the pool earned plus the net of traders' wins and losses while she was in. She can withdraw only the *free* liquidity — while a position is open, the part backing it is reserved and cannot be pulled out. +Carol burns her shares and redeems USDC. Her balance now reflects the fees the pool earned plus the net of traders' wins and losses while she was in. She can withdraw only the *free* liquidity — the part backing the profit traders are currently owed stays put, so she cannot pull capital out from under a winning trader. **Accounts modified:** `lp_mint` burns Carol's shares; `Pool.liquidity` falls; `custody_vault` pays out USDC to `carol_usdc`. @@ -196,13 +217,15 @@ Carol burns her shares and redeems USDC. Her balance now reflects the fees the p ## Design notes and further reading -The genuinely hard part of a perpetual-futures venue is keeping it solvent and permissionless *without* re-evaluating the entire market on every action. For a rigorous, formally-verified (Kani) treatment, see Anatoly Yakovenko's [percolator](https://github.com/aeyakovenko/percolator), an educational perp risk engine. It states three invariants this example also leans on, in simplified form: +The genuinely hard part of a perpetual-futures venue is keeping it solvent and permissionless *without* re-evaluating the entire market on every action. The risk model here is adapted from Anatoly Yakovenko's [percolator](https://github.com/aeyakovenko/percolator), an educational, formally-verified (Kani) perp risk engine. The pieces it contributes: + +- **Profit is junior; the haircut `h`.** Percolator's core rule — "deposited capital is senior, positive PnL is junior" — is exactly the haircut above. Profit is honoured only up to the backing the pool actually holds, every winner scaled by the same global `h`, with the division floored so the payouts can never sum past the vault. No queue, no chosen victims. +- **Maturation.** Percolator only lets profit count once it has matured past a warm-up; this example's `profit_warmup_slots` is that rule, the defense against an oracle spike being opened against and cashed out in one block. +- **Account-local safety / bounded progress.** Percolator requires that "every favorable action refreshes the account first" and that "no public instruction evaluates the whole market." Here, every action reads a fresh oracle (stale or wide-confidence prices are rejected), and assets-under-management plus the haircut are both derived from running per-side accumulators — so no handler's cost grows with the number of open positions. -- **Realizable credit** — "protected principal is senior, positive PnL is junior, and source-domain positive credit cannot exceed realizable backing reserved for that domain." Here, provider capital is senior and trader profit is a junior claim against it: shares are priced against marked assets-under-management, and the pool reserves each position's payout up front (capping recoverable profit at the reserve) so a winner's price profit can always be paid. -- **Account-local safety** — "every favorable action refreshes the account's full active portfolio first; … stale … legs fail closed." Here, every position and liquidity action reads a fresh oracle (stale or wide-confidence prices are rejected) and recomputes pool exposure before any payout. -- **Bounded progress** — "no public instruction needs to evaluate the whole market." Here, assets-under-management comes from running per-side accumulators, and liquidation acts on one position at a time, so no handler's cost grows with the number of open positions. +**Why not `A`/`K`?** Percolator's *other* mechanism — the per-side `A` (position-scaling) and `K` (PnL-accumulator) indices, with their DrainOnly → ResetPending → Normal state machine — clears bankruptcy overhang in a **peer-to-peer** vault, where profitable traders on the opposite side are the ones who must absorb a bankrupt account's loss without being individually named. This venue is **pool-collateralized**: the liquidity pool is the single counterparty to every trader, so there is no opposite side to deleverage. The pool-model equivalent of `K`'s loss socialization is the drop in `liquidity_provider_aum` (every provider's share absorbs the loss pro-rata), and the equivalent of the bankruptcy buffer is the **insurance fund**. So `A`/`K` are deliberately not ported — naming a coefficient after them here would not do what they do upstream. -What production pool-perps (`solana-labs/perpetuals`) add that this example still leaves out: multi-asset custody with reserves in the payout token, utilization-based borrow fees, auto-deleveraging (ADL) and an insurance fund for the bad-debt tail, and using the oracle's EMA for a less manipulable mark. +What production pool-perps (`solana-labs/perpetuals`) still add beyond this example: multi-asset custody with reserves in the payout token, utilization-based borrow fees, and using the oracle's EMA for a less manipulable mark. --- @@ -211,15 +234,16 @@ What production pool-perps (`solana-labs/perpetuals`) add that this example stil This is a teaching example, not an audited exchange. Notably: - A single position per side per trader, and one collateral token per pool. -- Recoverable profit is capped at the reserved notional, so the cap binds on a more-than-doubling move; a production venue would let profit run and absorb extreme moves with ADL, an insurance fund, and bankruptcy-residual accounting. -- The liquidation reward is paid from the position's remaining equity, so a position that gaps straight through zero equity pays the liquidator nothing — production venues fund the reward from collateral or an insurance fund so the worst positions are still worth liquidating. +- The haircut's profit liability is the *net* of the per-side accumulators, an O(1) proxy for the gross profit owed. When longs and shorts are both deep in profit at once it can understate the true liability, in which case a close that the pool genuinely cannot fund fails closed (reverts) rather than over-paying — the same conservative direction Percolator takes, but its `spec.md` tracks the realizable figure more precisely. +- Maturation is a single per-position warm-up since open, not Percolator's persistent maturity reserve that ages each increment of fresh profit separately. +- The liquidation reward is paid from the position's remaining equity, so a position that gaps straight through zero equity pays the liquidator nothing — production venues fund the reward from the insurance fund so the worst positions are still worth liquidating. - Funding is a single time-decay index on the heavier side rather than a skew-weighted rate. --- ## Testing -The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover liquidity round-trips, opening and closing longs and shorts in profit and loss, leverage and slippage rejection, stale-price and wide-confidence rejection, funding accrual, liquidation (and the refusal to liquidate a healthy position), reserved-liquidity behaviour (profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity), and fee collection. +The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover liquidity round-trips, opening and closing longs and shorts in profit and loss, leverage and slippage rejection, stale-price and wide-confidence rejection, funding accrual, liquidation (and the refusal to liquidate a healthy position), and fee collection — plus the risk model: profit running uncapped when the pool can back it, the haircut scaling profit when the pool is stressed, the warm-up blocking unmatured profit (but never a loss), the withdrawal guard, and the insurance fund taking its fee cut and absorbing a bankruptcy deficit. ```bash anchor build diff --git a/finance/perpetual-futures/anchor/TERMINOLOGY.md b/finance/perpetual-futures/anchor/TERMINOLOGY.md index 9ef81ba1..da49f6e2 100644 --- a/finance/perpetual-futures/anchor/TERMINOLOGY.md +++ b/finance/perpetual-futures/anchor/TERMINOLOGY.md @@ -20,6 +20,19 @@ Terms used in this example, in the sense they carry here. a position must keep to avoid liquidation. - **Liquidation** — closing an under-margined position. Permissionless here: any caller can trigger it and earns the liquidation fee. +- **Senior / junior claim** — a trader's deposited collateral is *senior*: always + reclaimable in full. Their profit is *junior*: only as real as the backing + behind it, and scaled down by the haircut when the pool is stressed. +- **Haircut ratio (`h`)** — a single global number, between zero and one, that + every closing winner's profit is multiplied by. One when the pool can back all + profit; below one when it cannot, the same fraction for everyone — no queue, no + singled-out trader. +- **Profit maturation (warm-up)** — profit cannot be realized until a position has + been open `profit_warmup_slots` slots. An oracle spike's paper gain cannot be + cashed out before the manipulation passes. Loss is never gated this way. +- **Insurance fund** — a senior buffer, funded by a cut of fees, that absorbs a + bankrupt position's deficit before liquidity providers do, and counts as + backing for trader profit in the haircut. - **Funding** — a periodic payment that anchors the pool's risk. The heavier side of open interest pays funding to the pool over time. - **Open interest** — the total notional size currently open on a side. diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs index 3bf65d18..4fa15284 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs @@ -15,6 +15,13 @@ pub const FUNDING_PRECISION: i128 = 1_000_000_000; /// from two running sums instead of iterating every open position. pub const SIZE_PRECISION: u128 = 1_000_000_000; +/// Fixed-point precision for the haircut ratio `h`. The ratio is carried scaled +/// by this factor: `HAIRCUT_PRECISION` means `h = 1` (profit fully backed), and a +/// smaller value means profit is honoured only in proportion. Profit (a junior +/// claim) is multiplied by `h` and divided by this on the way out, rounding down +/// so the haircut payouts can never sum to more than the pool actually holds. +pub const HAIRCUT_PRECISION: u128 = 1_000_000_000; + /// Liquidity-provider shares withheld from the first deposit. The first /// depositor receives `deposit - MINIMUM_LIQUIDITY` shares rather than the full /// amount, the same convention Uniswap V2 uses, so the share supply can never be diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs index 442e93f8..b98a3ad7 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs @@ -53,6 +53,9 @@ pub enum PerpError { #[msg("Position equity is below maintenance margin; it must be liquidated, not closed")] PositionNotHealthy, + #[msg("Profit has not matured yet; wait out the warm-up period before closing in profit")] + ProfitNotMatured, + #[msg("No protocol fees are available to collect")] NothingToClaim, } diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/close_position.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/close_position.rs index fd50ea00..aff19e47 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/close_position.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/close_position.rs @@ -6,7 +6,10 @@ use anchor_spl::{ use crate::constants::{AUTHORITY_SEED, POOL_SEED, POSITION_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{basis_points_of, refresh_price_and_funding, settle_position}; +use crate::instructions::shared::{ + apply_haircut, basis_points_of, haircut_ratio, refresh_price_and_funding, settle_position, + split_fee, +}; use crate::state::{Pool, Position}; pub fn handle_close_position( @@ -16,15 +19,30 @@ pub fn handle_close_position( let pool = &mut context.accounts.pool; let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + // Compute the haircut against the whole pool *before* this position leaves + // the accumulators, so the closer is one of the winners being scaled rather + // than scaling only those left behind. + let haircut = haircut_ratio(pool, price)?; + let position = &context.accounts.position; let position_size = position.size; + let entry_slot = position.entry_slot; let settlement = settle_position(pool, position, price)?; let close_fee = basis_points_of(position_size, pool.close_fee_bps)?; - // Recoverable profit is capped at the reserved amount (the position's - // notional `size`), so the pool can always cover a winner. Losses are not - // capped. - let realized_pnl = settlement.profit_and_loss.min(position_size as i128); + // Profit is a junior claim, gated twice before it is paid; a loss settles in + // full and skips both gates. First it must have *matured* — the warm-up + // since open must have elapsed, so a freshly minted oracle gain cannot be + // cashed out in the block it appears. Then it is *haircut* to the fraction + // `h` the pool can currently back, the same fraction for every winner. + let realized_pnl = if settlement.profit_and_loss > 0 { + let matured = + Clock::get()?.slot >= entry_slot.saturating_add(pool.profit_warmup_slots); + require!(matured, PerpError::ProfitNotMatured); + apply_haircut(settlement.profit_and_loss, haircut)? + } else { + settlement.profit_and_loss + }; let equity = settlement .equity .checked_sub(settlement.profit_and_loss) @@ -42,14 +60,12 @@ pub fn handle_close_position( let payout: u64 = payout.try_into().map_err(|_| PerpError::MathOverflow)?; require!(payout >= minimum_payout, PerpError::SlippageExceeded); - // Release the position's reserved liquidity now that it is closing. - pool.reserved_liquidity = pool - .reserved_liquidity - .checked_sub(position_size) - .ok_or(PerpError::MathOverflow)?; + let (insurance_cut, protocol_cut) = split_fee(close_fee, pool.insurance_fee_bps)?; - // Liquidity providers are the counterparty: they pay the trader's (capped) + // Liquidity providers are the counterparty: they pay the trader's (haircut) // profit and receive their loss, and collect the funding the trader owed. + // The part of a winner's profit withheld by the haircut stays in liquidity — + // the pool-model way bankruptcy overhang is socialized to providers. let liquidity_delta = settlement .funding .checked_sub(realized_pnl) @@ -63,7 +79,11 @@ pub fn handle_close_position( .map_err(|_| PerpError::MathOverflow)?; pool.protocol_fees = pool .protocol_fees - .checked_add(close_fee) + .checked_add(protocol_cut) + .ok_or(PerpError::MathOverflow)?; + pool.insurance_fund = pool + .insurance_fund + .checked_add(insurance_cut) .ok_or(PerpError::MathOverflow)?; let pool_key = pool.key(); diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs index 3699414c..79f3fa81 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs @@ -30,6 +30,13 @@ pub struct PoolParameters { /// Maximum oracle confidence band tolerated, in basis points of the price. pub max_confidence_bps: u16, + + /// Fraction of every open/close fee, in basis points, routed to the + /// insurance fund rather than to protocol fees. + pub insurance_fee_bps: u16, + + /// Slots a position must stay open before its profit can be realized. + pub profit_warmup_slots: u64, } pub fn handle_initialize_pool( @@ -74,6 +81,13 @@ pub fn handle_initialize_pool( parameters.max_confidence_bps > 0 && parameters.max_confidence_bps < denominator, PerpError::InvalidParameter ); + // The insurance cut is a fraction of the fee, so it cannot exceed the whole + // fee. `denominator` (100%) would route every fee to insurance, leaving the + // protocol nothing — allowed, but anything above it is meaningless. + require!( + parameters.insurance_fee_bps <= denominator, + PerpError::InvalidParameter + ); let pool = &mut context.accounts.pool; pool.authority = context.accounts.authority.key(); @@ -83,7 +97,7 @@ pub fn handle_initialize_pool( pool.custody_vault = context.accounts.custody_vault.key(); pool.lp_mint = context.accounts.lp_mint.key(); pool.liquidity = 0; - pool.reserved_liquidity = 0; + pool.insurance_fund = 0; pool.total_collateral = 0; pool.protocol_fees = 0; pool.long_size = 0; @@ -99,6 +113,8 @@ pub fn handle_initialize_pool( pool.maintenance_margin_bps = parameters.maintenance_margin_bps; pool.liquidation_fee_bps = parameters.liquidation_fee_bps; pool.max_confidence_bps = parameters.max_confidence_bps; + pool.insurance_fee_bps = parameters.insurance_fee_bps; + pool.profit_warmup_slots = parameters.profit_warmup_slots; pool.bump = context.bumps.pool; pool.authority_bump = context.bumps.pool_authority; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/liquidate_position.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/liquidate_position.rs index 26c87d25..6dee5b1d 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/liquidate_position.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/liquidate_position.rs @@ -17,14 +17,9 @@ pub fn handle_liquidate_position( let position = &context.accounts.position; let position_size = position.size; + let position_collateral = position.collateral; let settlement = settle_position(pool, position, price)?; - // Release the position's reserved liquidity now that it is closing. - pool.reserved_liquidity = pool - .reserved_liquidity - .checked_sub(position_size) - .ok_or(PerpError::MathOverflow)?; - // Liquidatable only once equity has fallen to or below the maintenance // margin. A healthy position can only be closed by its owner. let maintenance = basis_points_of(position_size, pool.maintenance_margin_bps)?; @@ -40,17 +35,35 @@ pub fn handle_liquidate_position( .max(0) .try_into() .map_err(|_| PerpError::MathOverflow)?; - let liquidation_fee = basis_points_of(position.size, pool.liquidation_fee_bps)?; + let liquidation_fee = basis_points_of(position_size, pool.liquidation_fee_bps)?; let liquidator_payout = liquidation_fee.min(remaining_equity); let trader_refund = remaining_equity .checked_sub(liquidator_payout) .ok_or(PerpError::MathOverflow)?; + // A position that gapped through zero equity owes more than its collateral. + // That deficit is drawn from the insurance fund first; only what the fund + // cannot cover is socialized to liquidity providers. + let deficit: u64 = settlement + .equity + .min(0) + .unsigned_abs() + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + let insurance_drawn = deficit.min(pool.insurance_fund); + pool.insurance_fund = pool + .insurance_fund + .checked_sub(insurance_drawn) + .ok_or(PerpError::MathOverflow)?; + // Everything the trader does not get back stays with the liquidity - // providers. Derived from vault conservation: the pool keeps the position's - // collateral minus whatever is paid out as equity. - let liquidity_delta = (position.collateral as i128) + // providers, topped up by the insurance draw. Derived from vault + // conservation: the pool keeps the position's collateral minus whatever is + // paid out as equity, plus the insurance that absorbed the deficit. + let liquidity_delta = (position_collateral as i128) .checked_sub(remaining_equity as i128) + .ok_or(PerpError::MathOverflow)? + .checked_add(insurance_drawn as i128) .ok_or(PerpError::MathOverflow)?; let new_liquidity = (pool.liquidity as i128) .checked_add(liquidity_delta) diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/open_position.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/open_position.rs index 8c3ec8dc..60af744b 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/open_position.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/open_position.rs @@ -6,7 +6,7 @@ use anchor_spl::{ use crate::constants::{POOL_SEED, POSITION_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{basis_points_of, refresh_price_and_funding, scale_size}; +use crate::instructions::shared::{basis_points_of, refresh_price_and_funding, scale_size, split_fee}; use crate::state::{Pool, Position, Side}; pub fn handle_open_position( @@ -48,20 +48,13 @@ pub fn handle_open_position( let maintenance = basis_points_of(size, pool.maintenance_margin_bps)?; require!(net_collateral > maintenance, PerpError::PositionNotHealthy); - // Reserve liquidity to cover this position's maximum recoverable profit - // (its notional `size`). The reserve must be backed by liquidity-provider - // capital, which also caps total open interest at the pool's liquidity. - let new_reserved = pool - .reserved_liquidity - .checked_add(size) - .ok_or(PerpError::MathOverflow)?; - require!( - new_reserved <= pool.liquidity, - PerpError::InsufficientLiquidity - ); - pool.reserved_liquidity = new_reserved; - + // No open-interest cap: a position can open even when the pool could not + // cover its full winnings. Profit is a junior claim — if traders end up + // collectively owed more than the pool holds, the haircut `h` scales every + // winner's profit to the available backing rather than reserving capital up + // front. Solvency is preserved at exit, not gated at entry. let size_scaled = scale_size(size, price)?; + let (insurance_cut, protocol_cut) = split_fee(open_fee, pool.insurance_fee_bps)?; // Effects: record the position and the pool's new aggregates before moving // any tokens. @@ -74,6 +67,7 @@ pub fn handle_open_position( position.entry_price = price; position.size_scaled = size_scaled; position.entry_funding = pool.cumulative_funding; + position.entry_slot = Clock::get()?.slot; position.bump = context.bumps.position; pool.total_collateral = pool @@ -82,7 +76,11 @@ pub fn handle_open_position( .ok_or(PerpError::MathOverflow)?; pool.protocol_fees = pool .protocol_fees - .checked_add(open_fee) + .checked_add(protocol_cut) + .ok_or(PerpError::MathOverflow)?; + pool.insurance_fund = pool + .insurance_fund + .checked_add(insurance_cut) .ok_or(PerpError::MathOverflow)?; match side { diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs index 2426c556..1d995d03 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs @@ -8,7 +8,9 @@ use anchor_spl::{ use crate::constants::{AUTHORITY_SEED, POOL_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding}; +use crate::instructions::shared::{ + liquidity_provider_aum, pool_profit_liability, refresh_price_and_funding, +}; use crate::state::Pool; pub fn handle_remove_liquidity( @@ -35,15 +37,14 @@ pub fn handle_remove_liquidity( .map_err(|_| PerpError::MathOverflow)?; require!(amount_out > 0, PerpError::AmountRoundsToZero); - // Only free liquidity can leave: the portion reserved to cover open - // positions' payouts stays put, so a winning trader can always be paid. A - // provider wanting more must wait for positions to close. - let free_liquidity = pool - .liquidity - .checked_sub(pool.reserved_liquidity) - .ok_or(PerpError::MathOverflow)?; + // Only free liquidity can leave: the backing for the profit traders are + // currently owed stays put, so providers cannot withdraw out from under a + // winning trader and force their haircut. Profit traders are owed beyond + // what liquidity can cover already has `h < 1`, leaving no free liquidity. + let liability = pool_profit_liability(pool, price)?; + let free_liquidity = (pool.liquidity as u128).saturating_sub(liability); require!( - amount_out <= free_liquidity, + amount_out as u128 <= free_liquidity, PerpError::InsufficientLiquidity ); require!( diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs index e8e32209..3483bbf3 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs @@ -1,6 +1,8 @@ use anchor_lang::prelude::*; -use crate::constants::{BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, SIZE_PRECISION}; +use crate::constants::{ + BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, HAIRCUT_PRECISION, SIZE_PRECISION, +}; use crate::errors::PerpError; use crate::state::{Pool, Position, Side}; @@ -132,9 +134,9 @@ pub fn position_pnl(side: Side, size: u64, entry_price: u64, price: u64) -> Resu /// from the pool's running accumulators rather than iterating positions. /// Positive means traders are collectively up (and the pool is down). /// -/// Profit is marked uncapped here: a position already past the reserved-profit -/// cap is carried at more than the pool will actually pay out, so -/// assets-under-management reads slightly low until that position closes. +/// This is marked at full value, before any haircut: in a stressed pool the +/// winners will actually be paid only `h` of their profit, so the figure is the +/// pool's *gross* liability to traders, an upper bound on what leaves the vault. pub fn traders_unrealized_pnl(pool: &Pool, price: u64) -> Result { let price = price as i128; let size_precision = SIZE_PRECISION as i128; @@ -173,6 +175,65 @@ pub fn liquidity_provider_aum(pool: &Pool, price: u64) -> Result { .ok_or(PerpError::MathOverflow.into()) } +/// The pool's gross profit liability at `price`: how much it would owe traders +/// beyond their own collateral if every winner closed now. This is the *junior* +/// claim the haircut applies to. Floored at zero — when traders are collectively +/// down the pool owes them no profit. +pub fn pool_profit_liability(pool: &Pool, price: u64) -> Result { + Ok(traders_unrealized_pnl(pool, price)?.max(0) as u128) +} + +/// The haircut ratio `h`, scaled by `HAIRCUT_PRECISION`. +/// +/// Profit is a junior claim, backed by liquidity-provider capital plus the +/// insurance fund. When that backing covers the whole profit liability, `h` is +/// one and profit is paid in full. When it falls short — a sharp move leaves +/// traders owed more than the pool holds — `h` drops below one and *every* +/// winner is paid the same fraction of their profit. No queue, no chosen +/// victims. The division floors, so the haircut payouts can never sum to more +/// than the backing. As losses settle back in, the backing recovers and `h` +/// rises on its own. +pub fn haircut_ratio(pool: &Pool, price: u64) -> Result { + let liability = pool_profit_liability(pool, price)?; + if liability == 0 { + return Ok(HAIRCUT_PRECISION); + } + let backing = (pool.liquidity as u128) + .checked_add(pool.insurance_fund as u128) + .ok_or(PerpError::MathOverflow)?; + if backing >= liability { + return Ok(HAIRCUT_PRECISION); + } + backing + .checked_mul(HAIRCUT_PRECISION) + .ok_or(PerpError::MathOverflow)? + .checked_div(liability) + .ok_or(PerpError::MathOverflow.into()) +} + +/// Apply the haircut ratio to a non-negative profit, rounding down. `profit` +/// must be `>= 0` (only profit is haircut; losses are taken in full). +pub fn apply_haircut(profit: i128, haircut: u128) -> Result { + (profit as u128) + .checked_mul(haircut) + .ok_or(PerpError::MathOverflow)? + .checked_div(HAIRCUT_PRECISION) + .ok_or(PerpError::MathOverflow)? + .try_into() + .map_err(|_| PerpError::MathOverflow.into()) +} + +/// Split a fee into the insurance-fund cut and the protocol cut. The insurance +/// cut is `insurance_fee_bps` of the fee (rounded down); the protocol keeps the +/// remainder, so no base unit is lost between the two. +pub fn split_fee(fee: u64, insurance_fee_bps: u16) -> Result<(u64, u64)> { + let insurance_cut = basis_points_of(fee, insurance_fee_bps)?; + let protocol_cut = fee + .checked_sub(insurance_cut) + .ok_or(PerpError::MathOverflow)?; + Ok((insurance_cut, protocol_cut)) +} + /// Funding a position owes since it opened, in collateral base units. Positive /// means the trader pays the pool; negative means the pool pays the trader. pub fn position_funding( diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs index eae178c5..3ccbd1e7 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs @@ -30,15 +30,17 @@ pub struct Pool { /// Liquidity-provider-owned assets, in collateral base units. Grows with /// deposits, trader losses, fees-to-LPs; shrinks with withdrawals and /// trader profits. Trader collateral is tracked separately in - /// `total_collateral` and is not part of this figure. + /// `total_collateral` and is not part of this figure. Together with + /// `insurance_fund` it is the backing for trader profit: when their sum + /// cannot cover everyone's matured profit, the haircut `h` scales profit + /// down to fit (see `instructions::shared::haircut_ratio`). pub liquidity: u64, - /// Portion of `liquidity` reserved to cover open positions' maximum - /// recoverable profit (one notional `size` per position). Liquidity-provider - /// withdrawals can only take the free remainder (`liquidity - reserved`), so - /// a winning trader can always be paid. Also caps total exposure: a position - /// can only open while `reserved + size <= liquidity`. - pub reserved_liquidity: u64, + /// Senior buffer that absorbs a bankrupt position's deficit (loss beyond its + /// collateral) before the loss is socialized to liquidity providers, and + /// counts alongside `liquidity` as backing for trader profit in the haircut + /// math. Funded by `insurance_fee_bps` of every open/close fee. + pub insurance_fund: u64, /// Sum of every open position's posted collateral, held in the same vault. pub total_collateral: u64, @@ -91,6 +93,17 @@ pub struct Pool { /// pool will trade against. A wider band is rejected as untrustworthy. pub max_confidence_bps: u16, + /// Fraction of each open/close fee, in basis points, routed to the insurance + /// fund instead of to `protocol_fees`. The rest is the protocol's slice. + pub insurance_fee_bps: u16, + + /// Slots a position must stay open before its profit *matures* into a + /// withdrawable claim. Profit cannot be realized before this elapses, so an + /// attacker who spikes the oracle to mint a paper gain cannot cash it out in + /// the same block — by the time it matures, the manipulation is gone. Loss + /// is never gated this way; an underwater position can be liquidated at once. + pub profit_warmup_slots: u64, + pub bump: u8, /// Bump for the vault/LP-mint authority PDA, stored so CPIs can sign without diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/position.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/position.rs index 29a0d708..aea0ff64 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/position.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/position.rs @@ -48,5 +48,9 @@ pub struct Position { /// Pool `cumulative_funding` at open. Funding owed is the change since. pub entry_funding: i128, + /// Slot the position opened at. Its profit matures (becomes withdrawable) + /// once `entry_slot + pool.profit_warmup_slots` has passed. + pub entry_slot: u64, + pub bump: u8, } diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs index a672a5e9..947c4695 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -63,19 +63,33 @@ struct Market { impl Market { /// Stand up a market with the given starting oracle price and per-slot /// funding rate. The admin is both the pool authority and the oracle feed - /// authority. + /// authority. Insurance fee and profit warm-up are off by default, so fee + /// and profit/loss assertions are exact; the tests that exercise them set + /// their own parameters. fn new(initial_price: i128, funding_rate_per_slot: u64) -> Market { let parameters = PoolParameters { - oracle_scale: ORACLE_SCALE, funding_rate_per_slot, + ..Market::default_parameters() + }; + Market::try_new(initial_price, parameters).expect("pool initialization should succeed") + } + + /// The parameter set the other constructors build on. 10× max leverage, 0.1% + /// open/close fees, 5% maintenance margin, 1% liquidation fee, 1% maximum + /// confidence band; funding, insurance, and warm-up off. + fn default_parameters() -> PoolParameters { + PoolParameters { + oracle_scale: ORACLE_SCALE, + funding_rate_per_slot: 0, open_fee_bps: 10, close_fee_bps: 10, max_leverage: 10, maintenance_margin_bps: 500, liquidation_fee_bps: 100, max_confidence_bps: 100, - }; - Market::try_new(initial_price, parameters).expect("pool initialization should succeed") + insurance_fee_bps: 0, + profit_warmup_slots: 0, + } } /// Like `new`, but takes the full parameter set and surfaces an @@ -960,25 +974,29 @@ fn test_collect_fees_requires_authority() { } #[test] -fn test_open_rejects_when_pool_cannot_back_it() { +fn test_open_allowed_without_full_backing() { + // There is no open-interest cap: a position can open even when the pool + // could not pay its full winnings. Solvency is kept at exit by the haircut, + // not gated at entry. Here a 10,000 position opens against only 6,000 of + // liquidity. let mut market = Market::default_market(); - // Only 3,000 of liquidity, but a 5,000 position must reserve 5,000. - market.seed_liquidity(3_000 * ONE_USDC); - let (trader, trader_collateral) = market.funded_trader(1_000 * ONE_USDC); - assert!(market + market.seed_liquidity(6_000 * ONE_USDC); + let (trader, trader_collateral) = market.funded_trader(1_100 * ONE_USDC); + market .open_position( &trader, trader_collateral, Side::Long, - 1_000 * ONE_USDC, - 5_000 * ONE_USDC, - 0 + 1_100 * ONE_USDC, + 10_000 * ONE_USDC, + 0, ) - .is_err()); + .unwrap(); + assert_eq!(market.pool_state().long_size, (10_000 * ONE_USDC) as u128); } #[test] -fn test_profit_capped_at_reserved_notional() { +fn test_profit_runs_uncapped_when_backed() { let mut market = Market::default_market(); market.seed_liquidity(100_000 * ONE_USDC); let collateral = 2_000 * ONE_USDC; @@ -988,8 +1006,8 @@ fn test_profit_capped_at_reserved_notional() { .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) .unwrap(); - // Price triples: uncapped profit would be 2x the notional, but recoverable - // profit is capped at the reserved notional (`size`). + // Price triples: profit is 2x the notional. The deep pool fully backs it, so + // the haircut is 1 and the trader keeps every cent — profit runs uncapped. market.set_price(dollars(300)); market .close_position(&trader, trader_collateral, Side::Long, 0) @@ -998,7 +1016,8 @@ fn test_profit_capped_at_reserved_notional() { let open_fee = size / 1_000; let close_fee = size / 1_000; let net_collateral = collateral - open_fee; - let expected = net_collateral + size - close_fee; + let profit = 2 * size; // 200% of notional, uncapped + let expected = net_collateral + profit - close_fee; assert_eq!( get_token_account_balance(&market.svm, &trader_collateral).unwrap(), expected @@ -1006,7 +1025,49 @@ fn test_profit_capped_at_reserved_notional() { } #[test] -fn test_remove_liquidity_blocked_by_reserved() { +fn test_haircut_scales_profit_when_pool_stressed() { + // A thin pool, a large long, and a doubling price: traders are owed more + // profit than the pool holds, so the haircut scales the winner down to what + // the backing can cover instead of letting them drain it and reverting on + // the next withdrawal. + let mut market = Market::default_market(); + market.seed_liquidity(6_000 * ONE_USDC); + + let collateral = 1_100 * ONE_USDC; + let size = 10_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + + // Price doubles: full profit would be the whole 10,000 notional, but backing + // is only 6,000 of liquidity (no insurance), so h = 6,000 / 10,000 = 0.6. + market.set_price(dollars(200)); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + + let open_fee = size / 1_000; + let close_fee = size / 1_000; + let net_collateral = collateral - open_fee; + let full_profit = size; // 100% of notional at a doubling + let haircut_profit = 6_000 * ONE_USDC; // 0.6 of full_profit + assert!(haircut_profit < full_profit); + let expected = net_collateral + haircut_profit - close_fee; + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + expected + ); + // The winner was paid down to the backing, leaving the pool solvent at zero. + assert_eq!(market.pool_state().liquidity, 0); +} + +#[test] +fn test_remove_liquidity_capped_at_liquidity() { + // Assets-under-management marks open trader losses as provider gains, but + // that gain is not cash until the position closes — it still sits in the + // trader's collateral. A provider therefore cannot withdraw more than the + // tracked liquidity, even when their marked share is worth more. let mut market = Market::default_market(); let (provider, provider_collateral) = market.seed_liquidity(10_000 * ONE_USDC); let (trader, trader_collateral) = market.funded_trader(1_000 * ONE_USDC); @@ -1021,8 +1082,10 @@ fn test_remove_liquidity_blocked_by_reserved() { ) .unwrap(); - // 5,000 of the 10,000 liquidity is now reserved. Pulling everything fails, - // but withdrawing within the free half succeeds. + // Price falls 20%: the long is down 1,000, so AUM marks to 11,000 while + // liquidity is still 10,000. Redeeming every share would demand 11,000 and + // is refused; redeeming half stays within liquidity and succeeds. + market.set_price(dollars(80)); let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); assert!(market @@ -1039,14 +1102,181 @@ fn test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin() { // position that is too healthy to liquidate but too poor to pay the fee to // close, so initialize_pool refuses the configuration. let parameters = PoolParameters { - oracle_scale: ORACLE_SCALE, - funding_rate_per_slot: 0, - open_fee_bps: 10, close_fee_bps: 600, - max_leverage: 10, - maintenance_margin_bps: 500, - liquidation_fee_bps: 100, - max_confidence_bps: 100, + ..Market::default_parameters() }; assert!(Market::try_new(dollars(100), parameters).is_err()); } + +#[test] +fn test_profit_blocked_before_maturation() { + // A 100-slot warm-up: profit cannot be realized in the same block it + // appears, so an oracle spike cannot be opened against and cashed out at + // once. The position is opened and the price jumps within the warm-up. + let parameters = PoolParameters { + profit_warmup_slots: 100, + ..Market::default_parameters() + }; + let mut market = Market::try_new(dollars(100), parameters).unwrap(); + market.seed_liquidity(100_000 * ONE_USDC); + + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + + // Price jumps 20%; closing for profit before the warm-up elapses is refused. + market.set_price(dollars(120)); + assert!(market + .close_position(&trader, trader_collateral, Side::Long, 0) + .is_err()); +} + +#[test] +fn test_profit_realized_after_maturation() { + let parameters = PoolParameters { + profit_warmup_slots: 100, + ..Market::default_parameters() + }; + let mut market = Market::try_new(dollars(100), parameters).unwrap(); + market.seed_liquidity(100_000 * ONE_USDC); + + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + + // Wait past the warm-up, then close: the profit has matured and is paid in + // full (the deep pool means no haircut). + market.warp(200); + market.set_price(dollars(120)); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + + let open_fee = size / 1_000; + let close_fee = size / 1_000; + let net_collateral = collateral - open_fee; + let profit = size / 5; // 20% of notional + let expected = net_collateral + profit - close_fee; + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + expected + ); +} + +#[test] +fn test_loss_not_gated_by_maturation() { + // The warm-up gates profit only. A losing position can always be closed at + // once — there is no manipulation incentive to lock down a loss. + let parameters = PoolParameters { + profit_warmup_slots: 100, + ..Market::default_parameters() + }; + let mut market = Market::try_new(dollars(100), parameters).unwrap(); + market.seed_liquidity(100_000 * ONE_USDC); + + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + + // Price falls 10%; closing the loss within the warm-up still succeeds. + market.set_price(dollars(90)); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + + let open_fee = size / 1_000; + let close_fee = size / 1_000; + let net_collateral = collateral - open_fee; + let loss = size / 10; + let expected = net_collateral - loss - close_fee; + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + expected + ); +} + +#[test] +fn test_insurance_fund_funded_by_fees() { + // Half of every fee is routed to the insurance fund. + let parameters = PoolParameters { + insurance_fee_bps: 5_000, + ..Market::default_parameters() + }; + let mut market = Market::try_new(dollars(100), parameters).unwrap(); + market.seed_liquidity(100_000 * ONE_USDC); + + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + + let open_fee = size / 1_000; + let insurance_cut = open_fee / 2; + let pool = market.pool_state(); + assert_eq!(pool.insurance_fund, insurance_cut); + assert_eq!(pool.protocol_fees, open_fee - insurance_cut); +} + +#[test] +fn test_insurance_absorbs_bankruptcy_deficit() { + // A position that gaps through zero equity owes more than its collateral. + // The insurance fund covers that deficit so liquidity providers don't. + // Open fee is 5% and the whole of it funds insurance, so the fund has + // enough to absorb the deficit in this test. + let parameters = PoolParameters { + open_fee_bps: 500, + insurance_fee_bps: 10_000, + ..Market::default_parameters() + }; + let mut market = Market::try_new(dollars(100), parameters).unwrap(); + market.seed_liquidity(100_000 * ONE_USDC); + + let collateral = 160 * ONE_USDC; + let size = 1_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + + // Open fee (5% of 1,000 = 50) all went to insurance. + let open_fee = size * 500 / 10_000; + assert_eq!(market.pool_state().insurance_fund, open_fee); + let liquidity_before = market.pool_state().liquidity; + + // Price falls 15%: a 1,000 long loses 150 against ~110 of net collateral, so + // equity is about -40 — a 40 deficit beyond the collateral. + market.set_price(dollars(85)); + let net_collateral = collateral - open_fee; + let loss = size * 15 / 100; + let deficit = loss - net_collateral; // 40 USDC + + let liquidator = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + create_associated_token_account( + &mut market.svm, + &liquidator.pubkey(), + &market.collateral_mint, + &market.payer, + ) + .unwrap(); + market + .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) + .unwrap(); + + let pool = market.pool_state(); + // The fund paid the deficit; what remains is the fee cut minus the deficit. + assert_eq!(pool.insurance_fund, open_fee - deficit); + // Providers kept the collateral and were topped up by the insurance draw, + // rather than eating the deficit. + assert_eq!(pool.liquidity, liquidity_before + net_collateral + deficit); +} diff --git a/finance/perpetual-futures/quasar/README.md b/finance/perpetual-futures/quasar/README.md index 5c6a21a3..753ecf05 100644 --- a/finance/perpetual-futures/quasar/README.md +++ b/finance/perpetual-futures/quasar/README.md @@ -25,7 +25,11 @@ math. This page only covers what differs in the Quasar version. Tests run in-process with [`quasar-svm`](https://github.com/blueshift-gg/quasar-svm). They build the program, set up a collateral mint, oracle feed, and funded wallets, then exercise pool initialization, liquidity add/remove, opening and -closing a long in profit, leverage rejection, liquidation, and fee collection. +closing a long in profit, leverage rejection, liquidation, and fee collection — +plus the same risk model as the Anchor sibling: profit running uncapped when the +pool can back it, the haircut scaling profit when the pool is stressed, the +warm-up blocking unmatured profit (but never a loss), the withdrawal guard, and +the insurance fund taking its fee cut and absorbing a bankruptcy deficit. ```bash cargo build-sbf diff --git a/finance/perpetual-futures/quasar/src/constants.rs b/finance/perpetual-futures/quasar/src/constants.rs index 1ff6012a..0c1c3136 100644 --- a/finance/perpetual-futures/quasar/src/constants.rs +++ b/finance/perpetual-futures/quasar/src/constants.rs @@ -10,6 +10,10 @@ pub const FUNDING_PRECISION: i128 = 1_000_000_000; /// Fixed-point precision for the per-side `size / entry_price` accumulators. pub const SIZE_PRECISION: u128 = 1_000_000_000; +/// Fixed-point precision for the haircut ratio `h`. `HAIRCUT_PRECISION` means +/// `h = 1` (profit fully backed); a smaller value scales junior profit down. +pub const HAIRCUT_PRECISION: u128 = 1_000_000_000; + /// Liquidity-provider shares withheld from the first deposit so the share /// supply never starts at a dust amount. pub const MINIMUM_LIQUIDITY: u64 = 1_000; diff --git a/finance/perpetual-futures/quasar/src/instructions/close_position.rs b/finance/perpetual-futures/quasar/src/instructions/close_position.rs index 7ca1765a..aceeed76 100644 --- a/finance/perpetual-futures/quasar/src/instructions/close_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/close_position.rs @@ -2,7 +2,8 @@ use { crate::{ constants::SIDE_LONG, instructions::shared::{ - basis_points_of, err, error, position_funding, position_pnl, refresh_price_and_funding, + apply_haircut, basis_points_of, err, error, haircut_ratio, position_funding, + position_pnl, refresh_price_and_funding, split_fee, }, state::{Pool, Position}, PoolAuthorityPda, @@ -51,12 +52,25 @@ pub fn handle_close_position( let slot = accounts.clock.slot.get(); let price = refresh_price_and_funding(&mut accounts.pool, &accounts.oracle_feed, slot)?; + // Compute the haircut against the whole pool before this position leaves the + // accumulators, so the closer is one of the winners being scaled. + let haircut = haircut_ratio( + accounts.pool.liquidity.get(), + accounts.pool.insurance_fund.get(), + accounts.pool.long_size.get(), + accounts.pool.long_size_scaled.get(), + accounts.pool.short_size.get(), + accounts.pool.short_size_scaled.get(), + price, + )?; + let side = accounts.position.side; let size = accounts.position.size.get(); let entry_price = accounts.position.entry_price.get(); let collateral = accounts.position.collateral.get(); let size_scaled = accounts.position.size_scaled.get(); let entry_funding = accounts.position.entry_funding.get(); + let entry_slot = accounts.position.entry_slot.get(); let pnl = position_pnl(side, size, entry_price, price)?; let funding = position_funding( @@ -65,9 +79,17 @@ pub fn handle_close_position( entry_funding, accounts.pool.cumulative_funding.get(), )?; - // Recoverable profit is capped at the reserved amount (the notional `size`), - // so the pool can always cover a winner. Losses are not capped. - let realized_pnl = pnl.min(size as i128); + // Profit is a junior claim, gated twice; a loss settles in full. It must + // have matured (the warm-up since open elapsed), then it is haircut to the + // fraction `h` the pool can back. + let realized_pnl = if pnl > 0 { + if slot < entry_slot.saturating_add(accounts.pool.profit_warmup_slots.get()) { + return Err(err(error::PROFIT_NOT_MATURED)); + } + apply_haircut(pnl, haircut)? + } else { + pnl + }; let equity = (collateral as i128) .checked_add(realized_pnl) .ok_or(ProgramError::ArithmeticOverflow)? @@ -86,16 +108,9 @@ pub fn handle_close_position( return Err(err(error::SLIPPAGE_EXCEEDED)); } - remove_open_interest(&mut accounts.pool, side, size, size_scaled)?; + let (insurance_cut, protocol_cut) = split_fee(close_fee, accounts.pool.insurance_fee_bps.get())?; - // Release the position's reserved liquidity now that it is closing. - let new_reserved = accounts - .pool - .reserved_liquidity - .get() - .checked_sub(size) - .ok_or(ProgramError::ArithmeticOverflow)?; - accounts.pool.reserved_liquidity.set(new_reserved); + remove_open_interest(&mut accounts.pool, side, size, size_scaled)?; let new_total_collateral = accounts .pool @@ -123,10 +138,18 @@ pub fn handle_close_position( .pool .protocol_fees .get() - .checked_add(close_fee) + .checked_add(protocol_cut) .ok_or(ProgramError::ArithmeticOverflow)?; accounts.pool.protocol_fees.set(new_protocol_fees); + let new_insurance_fund = accounts + .pool + .insurance_fund + .get() + .checked_add(insurance_cut) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.insurance_fund.set(new_insurance_fund); + let bump = [bumps.pool_authority]; let seeds: &[Seed] = &[ Seed::from(b"authority".as_ref()), diff --git a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs index cc237efa..c479e49e 100644 --- a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs @@ -60,6 +60,8 @@ pub fn handle_initialize_pool( maintenance_margin_bps: u16, liquidation_fee_bps: u16, max_confidence_bps: u16, + insurance_fee_bps: u16, + profit_warmup_slots: u64, bumps: &InitializePoolBumps, ) -> Result<(), ProgramError> { let denominator = BASIS_POINTS_DENOMINATOR as u16; @@ -86,6 +88,11 @@ pub fn handle_initialize_pool( if max_confidence_bps == 0 || max_confidence_bps >= denominator { return Err(err(error::INVALID_PARAMETER)); } + // The insurance cut is a fraction of the fee, so it cannot exceed the whole + // fee; `denominator` (100%) routes every fee to insurance. + if insurance_fee_bps > denominator { + return Err(err(error::INVALID_PARAMETER)); + } let slot = accounts.clock.slot.get(); accounts.pool.set_inner(PoolInner { @@ -96,7 +103,7 @@ pub fn handle_initialize_pool( lp_mint: *accounts.lp_mint.address(), oracle_scale, liquidity: 0, - reserved_liquidity: 0, + insurance_fund: 0, total_collateral: 0, protocol_fees: 0, long_size: 0, @@ -112,6 +119,8 @@ pub fn handle_initialize_pool( maintenance_margin_bps, liquidation_fee_bps, max_confidence_bps, + insurance_fee_bps, + profit_warmup_slots, bump: bumps.pool, authority_bump: bumps.pool_authority, }); diff --git a/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs b/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs index 314a6826..1bb7dd9a 100644 --- a/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs @@ -98,15 +98,6 @@ pub fn handle_liquidate_position( remove_open_interest(&mut accounts.pool, side, size, size_scaled)?; - // Release the position's reserved liquidity now that it is closing. - let new_reserved = accounts - .pool - .reserved_liquidity - .get() - .checked_sub(size) - .ok_or(ProgramError::ArithmeticOverflow)?; - accounts.pool.reserved_liquidity.set(new_reserved); - let new_total_collateral = accounts .pool .total_collateral @@ -115,9 +106,26 @@ pub fn handle_liquidate_position( .ok_or(ProgramError::ArithmeticOverflow)?; accounts.pool.total_collateral.set(new_total_collateral); - // The pool keeps the position's collateral minus whatever equity is paid out. + // A position that gapped through zero equity owes more than its collateral. + // The insurance fund covers that deficit first; only what it cannot cover is + // socialized to liquidity providers. + let deficit = u64::try_from(equity.min(0).unsigned_abs()) + .map_err(|_| ProgramError::ArithmeticOverflow)?; + let insurance_drawn = deficit.min(accounts.pool.insurance_fund.get()); + let new_insurance_fund = accounts + .pool + .insurance_fund + .get() + .checked_sub(insurance_drawn) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.insurance_fund.set(new_insurance_fund); + + // The pool keeps the position's collateral minus whatever equity is paid + // out, topped up by the insurance that absorbed the deficit. let liquidity_delta = (collateral as i128) .checked_sub(remaining_equity as i128) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_add(insurance_drawn as i128) .ok_or(ProgramError::ArithmeticOverflow)?; let new_liquidity = (accounts.pool.liquidity.get() as i128) .checked_add(liquidity_delta) diff --git a/finance/perpetual-futures/quasar/src/instructions/open_position.rs b/finance/perpetual-futures/quasar/src/instructions/open_position.rs index 9b10dac1..fe428eff 100644 --- a/finance/perpetual-futures/quasar/src/instructions/open_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/open_position.rs @@ -2,7 +2,7 @@ use { crate::{ constants::{SIDE_LONG, SIDE_SHORT}, instructions::shared::{ - basis_points_of, err, error, refresh_price_and_funding, scale_size, + basis_points_of, err, error, refresh_price_and_funding, scale_size, split_fee, }, state::{Pool, Position, PositionInner}, }, @@ -90,21 +90,12 @@ pub fn handle_open_position( return Err(err(error::POSITION_NOT_HEALTHY)); } - // Reserve liquidity to cover this position's maximum recoverable profit - // (its notional `size`), backed by liquidity-provider capital. This also - // caps total open interest at the pool's liquidity. - let new_reserved = accounts - .pool - .reserved_liquidity - .get() - .checked_add(size) - .ok_or_else(|| ProgramError::ArithmeticOverflow)?; - if new_reserved > accounts.pool.liquidity.get() { - return Err(err(error::INSUFFICIENT_LIQUIDITY)); - } - accounts.pool.reserved_liquidity.set(new_reserved); - + // No open-interest cap: a position can open even when the pool could not + // cover its full winnings. Solvency is preserved at exit by the haircut `h`, + // which scales every winner's profit to the available backing, not by + // reserving capital up front. let size_scaled = scale_size(size, price)?; + let (insurance_cut, protocol_cut) = split_fee(open_fee, accounts.pool.insurance_fee_bps.get())?; accounts.position.set_inner(PositionInner { owner: *accounts.owner.address(), @@ -115,6 +106,7 @@ pub fn handle_open_position( entry_price: price, size_scaled, entry_funding: accounts.pool.cumulative_funding.get(), + entry_slot: slot, bump: bumps.position, }); @@ -130,10 +122,18 @@ pub fn handle_open_position( .pool .protocol_fees .get() - .checked_add(open_fee) + .checked_add(protocol_cut) .ok_or(ProgramError::ArithmeticOverflow)?; accounts.pool.protocol_fees.set(new_protocol_fees); + let new_insurance_fund = accounts + .pool + .insurance_fund + .get() + .checked_add(insurance_cut) + .ok_or(ProgramError::ArithmeticOverflow)?; + accounts.pool.insurance_fund.set(new_insurance_fund); + if side == SIDE_LONG { let long_size = accounts .pool diff --git a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs index 26708dd8..9f3fe85c 100644 --- a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs @@ -80,14 +80,13 @@ pub fn handle_remove_liquidity( if amount_out == 0 { return Err(err(error::AMOUNT_ROUNDS_TO_ZERO)); } - // Only free liquidity can leave; the reserved portion backs open positions. - let free_liquidity = accounts - .pool - .liquidity - .get() - .checked_sub(accounts.pool.reserved_liquidity.get()) - .ok_or(ProgramError::ArithmeticOverflow)?; - if amount_out > free_liquidity { + // Only free liquidity can leave: the backing for the profit traders are + // currently owed stays put, so providers cannot withdraw out from under a + // winning trader. When traders are net up this also keeps the withdrawal + // within `liquidity`; when they are net down the marked gain is not cash yet. + let liability = traders.max(0) as u128; + let free_liquidity = (accounts.pool.liquidity.get() as u128).saturating_sub(liability); + if amount_out as u128 > free_liquidity { return Err(err(error::INSUFFICIENT_LIQUIDITY)); } if amount_out < minimum_amount_out { diff --git a/finance/perpetual-futures/quasar/src/instructions/shared.rs b/finance/perpetual-futures/quasar/src/instructions/shared.rs index 9e02f8c7..407d4b5b 100644 --- a/finance/perpetual-futures/quasar/src/instructions/shared.rs +++ b/finance/perpetual-futures/quasar/src/instructions/shared.rs @@ -5,8 +5,8 @@ use quasar_lang::prelude::*; use crate::constants::{ - BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, MAX_PRICE_STALENESS_SLOTS, SIDE_LONG, - SIZE_PRECISION, + BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, HAIRCUT_PRECISION, MAX_PRICE_STALENESS_SLOTS, + SIDE_LONG, SIZE_PRECISION, }; use crate::state::Pool; @@ -28,6 +28,7 @@ pub mod error { pub const AMOUNT_ROUNDS_TO_ZERO: u32 = 15; pub const ORACLE_CONFIDENCE_TOO_WIDE: u32 = 16; pub const INSUFFICIENT_COLLATERAL: u32 = 17; + pub const PROFIT_NOT_MATURED: u32 = 18; } #[inline(always)] @@ -200,6 +201,72 @@ pub fn traders_unrealized_pnl( long_pnl.checked_add(short_pnl).ok_or_else(overflow) } +/// The pool's gross profit liability at `price`: how much it would owe traders +/// beyond their collateral if every winner closed now — the junior claim the +/// haircut applies to. Floored at zero. +pub fn pool_profit_liability( + long_size: u128, + long_size_scaled: u128, + short_size: u128, + short_size_scaled: u128, + price: u64, +) -> Result { + let net = traders_unrealized_pnl(long_size, long_size_scaled, short_size, short_size_scaled, price)?; + Ok(net.max(0) as u128) +} + +/// The haircut ratio `h`, scaled by `HAIRCUT_PRECISION`. Profit is backed by +/// liquidity plus the insurance fund; when that backing covers the whole profit +/// liability `h` is one, otherwise it is `backing / liability` and every winner +/// is paid the same fraction. The division floors, so haircut payouts can never +/// sum past the backing. +#[allow(clippy::too_many_arguments)] +pub fn haircut_ratio( + liquidity: u64, + insurance_fund: u64, + long_size: u128, + long_size_scaled: u128, + short_size: u128, + short_size_scaled: u128, + price: u64, +) -> Result { + let liability = + pool_profit_liability(long_size, long_size_scaled, short_size, short_size_scaled, price)?; + if liability == 0 { + return Ok(HAIRCUT_PRECISION); + } + let backing = (liquidity as u128) + .checked_add(insurance_fund as u128) + .ok_or_else(overflow)?; + if backing >= liability { + return Ok(HAIRCUT_PRECISION); + } + backing + .checked_mul(HAIRCUT_PRECISION) + .ok_or_else(overflow)? + .checked_div(liability) + .ok_or_else(overflow) +} + +/// Apply the haircut to a non-negative profit, rounding down. `profit` must be +/// `>= 0` — only profit is haircut, losses settle in full. +pub fn apply_haircut(profit: i128, haircut: u128) -> Result { + let scaled = (profit as u128) + .checked_mul(haircut) + .ok_or_else(overflow)? + .checked_div(HAIRCUT_PRECISION) + .ok_or_else(overflow)?; + i128::try_from(scaled).map_err(|_| overflow()) +} + +/// Split a fee into the insurance-fund cut (`insurance_fee_bps` of it, floored) +/// and the protocol cut (the remainder), so no base unit is lost between them. +pub fn split_fee(fee: u64, insurance_fee_bps: u16) -> Result<(u64, u64), ProgramError> { + let insurance_cut = basis_points_of(fee, insurance_fee_bps)?; + let protocol_cut = fee.checked_sub(insurance_cut).ok_or_else(overflow)?; + Ok((insurance_cut, protocol_cut)) +} + pub fn position_funding( side: u8, size: u64, diff --git a/finance/perpetual-futures/quasar/src/lib.rs b/finance/perpetual-futures/quasar/src/lib.rs index 995baa0b..7a4cdff0 100644 --- a/finance/perpetual-futures/quasar/src/lib.rs +++ b/finance/perpetual-futures/quasar/src/lib.rs @@ -48,6 +48,8 @@ mod quasar_perpetual_futures { maintenance_margin_bps: u16, liquidation_fee_bps: u16, max_confidence_bps: u16, + insurance_fee_bps: u16, + profit_warmup_slots: u64, ) -> Result<(), ProgramError> { instructions::handle_initialize_pool( &mut ctx.accounts, @@ -59,6 +61,8 @@ mod quasar_perpetual_futures { maintenance_margin_bps, liquidation_fee_bps, max_confidence_bps, + insurance_fee_bps, + profit_warmup_slots, &ctx.bumps, ) } diff --git a/finance/perpetual-futures/quasar/src/state.rs b/finance/perpetual-futures/quasar/src/state.rs index fd08daa8..2b5d280d 100644 --- a/finance/perpetual-futures/quasar/src/state.rs +++ b/finance/perpetual-futures/quasar/src/state.rs @@ -13,11 +13,11 @@ pub struct Pool { pub lp_mint: Address, pub oracle_scale: u32, pub liquidity: u64, - /// Portion of `liquidity` reserved to cover open positions' maximum - /// recoverable profit (one notional `size` each). Withdrawals can only take - /// the free remainder, and a position can open only while - /// `reserved + size <= liquidity`. - pub reserved_liquidity: u64, + /// Senior buffer that absorbs a bankrupt position's deficit before the loss + /// is socialized to liquidity providers, and counts alongside `liquidity` as + /// backing for trader profit in the haircut math. Funded by + /// `insurance_fee_bps` of every open/close fee. + pub insurance_fund: u64, pub total_collateral: u64, pub protocol_fees: u64, pub long_size: u128, @@ -35,6 +35,13 @@ pub struct Pool { /// Maximum oracle confidence band, in basis points of the price, the pool /// will trade against. A wider band is rejected as untrustworthy. pub max_confidence_bps: u16, + /// Fraction of each open/close fee, in basis points, routed to the insurance + /// fund instead of to `protocol_fees`. + pub insurance_fee_bps: u16, + /// Slots a position must stay open before its profit matures into a + /// withdrawable claim. Profit cannot be realized before this elapses, the + /// oracle-manipulation defense; loss is never gated this way. + pub profit_warmup_slots: u64, pub bump: u8, pub authority_bump: u8, } @@ -55,5 +62,8 @@ pub struct Position { pub entry_price: u64, pub size_scaled: u128, pub entry_funding: i128, + /// Slot the position opened at. Its profit matures once + /// `entry_slot + pool.profit_warmup_slots` has passed. + pub entry_slot: u64, pub bump: u8, } diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index 9210da8f..135cddd4 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -112,7 +112,7 @@ struct Env { const SLOT: u64 = 10; /// Build an SVM with the program, token program, a collateral mint, an oracle -/// feed at $100, and an initialized pool. +/// feed at $100, and an initialized pool. Insurance fee and profit warm-up off. fn setup() -> Env { try_setup(500, 10).expect("pool initialization should succeed") } @@ -121,6 +121,19 @@ fn setup() -> Env { /// `initialize_pool` rejection surfaced instead of panicking, so tests can /// probe the parameter validation. fn try_setup(maintenance_margin_bps: u16, close_fee_bps: u16) -> Result { + try_setup_full(maintenance_margin_bps, close_fee_bps, 10, 0, 0) +} + +/// The full pool-parameter builder. Exposes the open fee, the insurance-fund fee +/// cut, and the profit warm-up so the haircut, maturation, and insurance tests +/// can configure them. +fn try_setup_full( + maintenance_margin_bps: u16, + close_fee_bps: u16, + open_fee_bps: u16, + insurance_fee_bps: u16, + profit_warmup_slots: u64, +) -> Result { let elf = fs::read("target/deploy/quasar_perpetual_futures.so").unwrap(); let collateral_mint = Pubkey::new_unique(); let feed = Pubkey::new_unique(); @@ -142,12 +155,14 @@ fn try_setup(maintenance_margin_bps: u16, close_fee_bps: u16) -> Result let mut data = vec![0u8]; data.extend_from_slice(&ORACLE_SCALE.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); // funding_rate_per_slot = 0 - data.extend_from_slice(&10u16.to_le_bytes()); // open_fee_bps + data.extend_from_slice(&open_fee_bps.to_le_bytes()); data.extend_from_slice(&close_fee_bps.to_le_bytes()); data.extend_from_slice(&10u16.to_le_bytes()); // max_leverage data.extend_from_slice(&maintenance_margin_bps.to_le_bytes()); data.extend_from_slice(&100u16.to_le_bytes()); // liquidation_fee_bps data.extend_from_slice(&100u16.to_le_bytes()); // max_confidence_bps + data.extend_from_slice(&insurance_fee_bps.to_le_bytes()); + data.extend_from_slice(&profit_warmup_slots.to_le_bytes()); let metas = vec![ AccountMeta::new(admin, true), AccountMeta::new(pool, false), @@ -302,6 +317,14 @@ impl Env { )); } + /// Advance the clock and refresh the feed at the new slot, so the price stays + /// fresh past the warm-up window. + fn warp_and_set_price(&mut self, slot: u64, price: i128) { + self.svm.sysvars.warp_to_slot(slot); + self.svm + .set_account(feed_account(&self.feed, price, ORACLE_SCALE, slot, 0)); + } + fn close_position(&mut self, owner: &Pubkey) -> bool { let trader_collateral = ata(owner, &self.collateral_mint); let position = pda(&[b"position", self.pool.as_ref(), owner.as_ref()]); @@ -564,17 +587,20 @@ fn test_wide_oracle_confidence_rejected() { } #[test] -fn test_open_rejects_when_pool_cannot_back_it() { +fn test_open_allowed_without_full_backing() { + // No open-interest cap: a 10,000 position opens against only 6,000 of + // liquidity; solvency is kept at exit by the haircut, not gated at entry. let mut env = setup(); - let (provider, _) = env.funded_wallet(3_000 * ONE_USDC); - assert!(env.add_liquidity(&provider, 3_000 * ONE_USDC)); - let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); - // A 5,000 position must reserve 5,000, but the pool only holds 3,000. - assert!(!env.open_position(&trader, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC)); + let (provider, _) = env.funded_wallet(6_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 6_000 * ONE_USDC)); + let (trader, _) = env.funded_wallet(1_100 * ONE_USDC); + assert!(env.open_position(&trader, 0, 1_100 * ONE_USDC, 10_000 * ONE_USDC)); + let position = pda(&[b"position", env.pool.as_ref(), trader.as_ref()]); + assert!(env.svm.get_account(&position).is_some()); } #[test] -fn test_profit_capped_at_reserved_notional() { +fn test_profit_runs_uncapped_when_backed() { let mut env = setup(); let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); @@ -584,34 +610,174 @@ fn test_profit_capped_at_reserved_notional() { let (trader, trader_collateral) = env.funded_wallet(collateral); assert!(env.open_position(&trader, 0, collateral, size)); - // Price triples: uncapped profit would be 2x the notional, but recoverable - // profit is capped at the reserved notional (`size`). + // Price triples: profit is 2x the notional. The deep pool fully backs it, so + // the haircut is 1 and the trader keeps every cent — profit is uncapped. env.set_price(dollars(300)); assert!(env.close_position(&trader)); let open_fee = size / 1_000; let close_fee = size / 1_000; let net_collateral = collateral - open_fee; - let expected = net_collateral + size - close_fee; + let profit = 2 * size; + let expected = net_collateral + profit - close_fee; + assert_eq!(token_amount(&env.svm, &trader_collateral), expected); +} + +#[test] +fn test_haircut_scales_profit_when_pool_stressed() { + // A thin pool and a doubling price: traders are owed more than the pool + // holds, so the haircut scales the winner to the backing. h = 6,000 / 10,000. + let mut env = setup(); + let (provider, _) = env.funded_wallet(6_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 6_000 * ONE_USDC)); + + let collateral = 1_100 * ONE_USDC; + let size = 10_000 * ONE_USDC; + let (trader, trader_collateral) = env.funded_wallet(collateral); + assert!(env.open_position(&trader, 0, collateral, size)); + + env.set_price(dollars(200)); + assert!(env.close_position(&trader)); + + let open_fee = size / 1_000; + let close_fee = size / 1_000; + let net_collateral = collateral - open_fee; + let haircut_profit = 6_000 * ONE_USDC; // 0.6 of the 10,000 full profit + let expected = net_collateral + haircut_profit - close_fee; assert_eq!(token_amount(&env.svm, &trader_collateral), expected); } #[test] -fn test_remove_liquidity_blocked_by_reserved() { +fn test_remove_liquidity_capped_at_liquidity() { + // A provider cannot withdraw more than the tracked liquidity, even when open + // trader losses mark their share higher — that gain is not cash yet. let mut env = setup(); let (provider, _) = env.funded_wallet(10_000 * ONE_USDC); assert!(env.add_liquidity(&provider, 10_000 * ONE_USDC)); let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); assert!(env.open_position(&trader, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC)); - // 5,000 of the 10,000 liquidity is reserved: pulling everything fails, but - // withdrawing within the free half succeeds. + // Price falls 20%: the long is down 1,000, AUM marks to 11,000 while + // liquidity is 10,000. Redeeming every share is refused; half succeeds. + env.set_price(dollars(80)); let provider_lp = ata(&provider, &env.lp_mint); let shares = token_amount(&env.svm, &provider_lp); assert!(!env.remove_liquidity(&provider, shares)); assert!(env.remove_liquidity(&provider, shares / 2)); } +#[test] +fn test_profit_blocked_before_maturation() { + // A 100-slot warm-up: profit cannot be realized in the block it appears. + let mut env = try_setup_full(500, 10, 10, 0, 100).unwrap(); + let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); + + let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); + assert!(env.open_position(&trader, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC)); + + // Price jumps 20%; closing for profit before the warm-up elapses is refused. + env.set_price(dollars(120)); + assert!(!env.close_position(&trader)); +} + +#[test] +fn test_profit_realized_after_maturation() { + let mut env = try_setup_full(500, 10, 10, 0, 100).unwrap(); + let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); + + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = env.funded_wallet(1_000 * ONE_USDC); + assert!(env.open_position(&trader, 0, 1_000 * ONE_USDC, size)); + + // Wait past the warm-up (opened at slot 10), refresh the price, then close: + // the profit has matured and is paid in full. + env.warp_and_set_price(SLOT + 200, dollars(120)); + assert!(env.close_position(&trader)); + + let open_fee = size / 1_000; + let close_fee = size / 1_000; + let net_collateral = 1_000 * ONE_USDC - open_fee; + let profit = size / 5; + let expected = net_collateral + profit - close_fee; + assert_eq!(token_amount(&env.svm, &trader_collateral), expected); +} + +#[test] +fn test_loss_not_gated_by_maturation() { + // The warm-up gates profit only; a loss can be closed at once. + let mut env = try_setup_full(500, 10, 10, 0, 100).unwrap(); + let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); + + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = env.funded_wallet(1_000 * ONE_USDC); + assert!(env.open_position(&trader, 0, 1_000 * ONE_USDC, size)); + + env.set_price(dollars(90)); + assert!(env.close_position(&trader)); + + let open_fee = size / 1_000; + let close_fee = size / 1_000; + let net_collateral = 1_000 * ONE_USDC - open_fee; + let loss = size / 10; + let expected = net_collateral - loss - close_fee; + assert_eq!(token_amount(&env.svm, &trader_collateral), expected); +} + +#[test] +fn test_insurance_fund_funded_by_fees() { + // Half of every fee is routed to the insurance fund, so `collect_fees` + // sweeps only the protocol's half of the open fee. + let mut env = try_setup_full(500, 10, 10, 5_000, 0).unwrap(); + let (provider, _) = env.funded_wallet(100_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); + + let size = 5_000 * ONE_USDC; + let (trader, _) = env.funded_wallet(1_000 * ONE_USDC); + assert!(env.open_position(&trader, 0, 1_000 * ONE_USDC, size)); + + assert!(env.collect_fees()); + let admin_collateral = ata(&env.admin, &env.collateral_mint); + let open_fee = size / 1_000; + assert_eq!(token_amount(&env.svm, &admin_collateral), open_fee / 2); +} + +#[test] +fn test_insurance_absorbs_bankruptcy_deficit() { + // A position gaps through zero equity, owing 40 beyond its collateral. The + // insurance fund (here, the 50 open fee) covers that deficit, so the sole + // provider reclaims the trader's collateral *and* the insurance top-up + // rather than eating the loss. + let mut env = try_setup_full(500, 10, 500, 10_000, 0).unwrap(); + let (provider, provider_collateral) = env.funded_wallet(100_000 * ONE_USDC); + assert!(env.add_liquidity(&provider, 100_000 * ONE_USDC)); + + let collateral = 160 * ONE_USDC; + let size = 1_000 * ONE_USDC; + let (trader, _) = env.funded_wallet(collateral); + assert!(env.open_position(&trader, 0, collateral, size)); + + // Price falls 15%: a 1,000 long loses 150 against 110 of net collateral. + env.set_price(dollars(85)); + let liquidator = Pubkey::new_unique(); + env.svm + .set_account(create_keyed_system_account(&liquidator, 100_000_000_000)); + assert!(env.liquidate(&liquidator, &trader)); + + // The deficit (150 loss − 110 net collateral = 40) was paid from insurance. + // The provider redeems all shares and ends with deposit + collateral + the + // insurance top-up: 100,000 + 110 + 40 = 100,150. + let provider_lp = ata(&provider, &env.lp_mint); + let shares = token_amount(&env.svm, &provider_lp); + assert!(env.remove_liquidity(&provider, shares)); + assert_eq!( + token_amount(&env.svm, &provider_collateral), + 100_150 * ONE_USDC + ); +} + #[test] fn test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin() { // A pool whose close fee reached the maintenance margin could strand a