diff --git a/finance/perpetual-futures/anchor-v1/CHANGELOG.md b/finance/perpetual-futures/anchor-v1/CHANGELOG.md index f2ee8472..d60195c2 100644 --- a/finance/perpetual-futures/anchor-v1/CHANGELOG.md +++ b/finance/perpetual-futures/anchor-v1/CHANGELOG.md @@ -1,5 +1,122 @@ # Changelog +## 2026-10-01 + +Replace the leverage cap with an initial margin. `max_leverage` on +`PoolParameters` and `Pool` is now `initial_margin_bps`, the net collateral a +position must post to open, in basis points of its size (1,000 is 10x). +`initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <= +10_000`, refusing an initial margin at or below the maintenance margin with the +new `InitialMarginNotAboveMaintenance` and one above 10,000 with +`InvalidParameter`; `MAX_LEVERAGE_CEILING` is removed. `open_position` checks +`net_collateral * 10_000 >= size * initial_margin_bps` and fails with +`InitialMarginNotMet`, which takes `LeverageTooHigh`'s place and its error code +(6004). Its separate check that a new position starts above the maintenance +margin is removed, because the initial margin implies it; `PositionNotHealthy` +remains for `close_position`. + +Add a price band around a program-maintained average price. A fresh, confident +oracle print could still be wrong, and every handler traded at it. The pool now +keeps `average_price`, a time-weighted moving average of the oracle price, +`last_oracle_price`, the price at the most recent oracle read, and +`average_price_timestamp`. `initialize_pool` seeds the average and +`last_oracle_price` from the oracle. Every handler that reads the oracle credits +the seconds since the previous read to the price that read saw, +`average += (last_oracle_price - average) * min(elapsed, +PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`, with the new +constant at 600 seconds, and then records the price it read as +`last_oracle_price`. The price read now only counts from now, so a pool left +idle for a window or more cannot have its average set by one read of a +manipulated price: that price moves the average only if the oracle still shows +it at a later read, weighted by the seconds between the two reads. +`open_position`, `close_position`, `add_liquidity` and `remove_liquidity` refuse +a price outside `|price - average_price| * 10_000 <= average_price * +max_price_deviation_bps` with the new `PriceOutsideBand`, checked against the +stored average before anything is folded in. `liquidate_position` folds and +records without the check. The new permissionless `update_price_average` +handler folds and records too, also without the check, so keepers calling it +repeatedly as time passes can walk the average to a genuine move. `max_price_deviation_bps` is a new +`PoolParameters` field, which `initialize_pool` requires to be above zero and +below 10,000 with the new `InvalidPriceDeviation`. `shared.rs` has +`refresh_price_and_funding_within_band` for the four band-checked handlers +beside `refresh_price_and_funding` for the other two. The `errors` module is +public so the tests can match `PerpError` codes. + +Tested by `test_open_rejects_position_below_initial_margin` (formerly +`test_open_rejects_excess_leverage`, now checking both sides of the boundary), +`test_initialize_pool_rejects_initial_margin_at_or_below_maintenance`, +`test_initialize_pool_rejects_price_deviation_outside_range`, +`test_open_rejected_when_oracle_jumps_outside_band`, +`test_close_rejected_when_oracle_jumps_outside_band`, +`test_liquidity_changes_rejected_when_oracle_jumps_outside_band`, +`test_liquidation_runs_outside_band`, +`test_price_average_catches_up_after_genuine_move`, +`test_single_update_moves_average_by_elapsed_fraction` and +`test_one_manipulated_read_after_idle_does_not_move_average`. The default test market +uses a 1,000 basis point initial margin and a 2,000 basis point band; +`test_profit_runs_uncapped_when_backed` triples the price, far outside the +band, so it now calls `update_price_average` to record the new price, lets a +full window pass, and calls it again before closing. + +Replace reserved liquidity with the haircut risk model from +[Percolator](https://github.com/aeyakovenko/percolator): trader collateral is +senior, and trader profit is junior, paid only as far as the pool can back it. +`Pool.reserved_liquidity` is removed, and with it `open_position`'s +`reserved + size <= liquidity` check, which failed with `InsufficientLiquidity`, +and `close_position`'s cap on profit at the position's size. A position opens +whatever the pool's liquidity, and profit has no cap. `close_position` computes +the haircut ratio `h = min(1, (liquidity + insurance_fund) / +max(0, traders' aggregate unrealized profit, closing position's profit))` from +the per-side accumulators, before the closing position leaves them, and pays a +winning position `profit * h / HAIRCUT_PRECISION`, rounded down, with the new +constant at 10^9, so every winner closing at the same moment is paid the same +fraction; a loss settles in full. A winner who closes while open losers still +offset them is paid at most the pool's backing rather than refused, and every +other winner's fraction is unchanged. The profit is paid from `liquidity` first +and from the insurance fund for the rest; `PoolInsolvent` remains as a +defensive check. +`remove_liquidity` caps a withdrawal at `liquidity` rather than `liquidity - +reserved_liquidity`, still failing with `InsufficientLiquidity`, whose message +now says the withdrawal is larger than the pool's liquidity. `shared.rs` has +the new `haircut_ratio` and `apply_haircut`. + +Add an insurance fund. `Pool.insurance_fund` is new, and so is +`PoolParameters.insurance_fee_bps`, which `initialize_pool` requires to be below +10,000 or fails with `InvalidParameter`. That fraction of every open and close +fee goes to the fund, rounded down, and the rest to `program_fees`, through the +new `split_fee` and `credit_fee` in `shared.rs`. `liquidate_position` takes a +position's deficit, its loss beyond its collateral, from the fund first and +credits what the fund pays to `liquidity`; the providers bear the rest. The +liquidation fee is still paid only out of the position's remaining equity: the +part the equity cannot cover is forgiven, as in Percolator, and neither the +insurance fund nor `liquidity` pays it. The vault holds `liquidity + +total_collateral + program_fees + insurance_fund`, plus any tokens sent to it +directly. + +Add a profit warm-up. `PoolParameters.profit_warmup_slots` and +`Position.entry_slot`, which `open_position` sets to the current slot, are new. +`close_position` refuses to pay a profit before slot `entry_slot + +profit_warmup_slots` with the new `ProfitNotMatured` (6022). A losing position +closes at any time, and liquidation is not delayed. + +Tested by `test_open_allowed_without_full_backing`, +`test_profit_runs_uncapped_when_backed`, +`test_haircut_scales_profit_when_pool_stressed`, +`test_insurance_pays_profit_beyond_liquidity`, +`test_winner_offset_by_open_loser_is_paid_not_refused`, +`test_remove_liquidity_capped_at_liquidity`, +`test_profit_blocked_before_maturation`, +`test_profit_realized_after_maturation`, `test_loss_not_gated_by_maturation`, +`test_insurance_fund_funded_by_fees`, `test_insurance_absorbs_bankruptcy_deficit`, +`test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity` and +`test_initialize_pool_rejects_insurance_fee_at_or_above_full_fee`. They replace +`test_open_rejects_when_pool_cannot_back_it`, +`test_profit_capped_at_reserved_notional` and +`test_remove_liquidity_blocked_by_reserved`. The default test market pays half +of each fee into the insurance fund and has a 10-slot warm-up, so the tests that +close at a profit first let the warm-up pass, and `test_open_long_updates_pool` +checks the fee split. + ## 2026-09-30 Remove `set_funding_rate`. The pool's authority could change the funding rate at diff --git a/finance/perpetual-futures/anchor-v1/README.md b/finance/perpetual-futures/anchor-v1/README.md index d2e38e21..fa2e5408 100644 --- a/finance/perpetual-futures/anchor-v1/README.md +++ b/finance/perpetual-futures/anchor-v1/README.md @@ -29,7 +29,7 @@ All arithmetic is integer `u128` with `checked_*` operations, multiplying before ### Long and short, leverage, collateral -A trader goes [long](https://www.investopedia.com/terms/l/long.asp) if they think the price will rise or [short](https://www.investopedia.com/terms/s/short.asp) if they think it will fall. They post [collateral](https://www.investopedia.com/terms/c/collateral.asp) and choose a position size up to the pool's maximum [leverage](https://www.investopedia.com/terms/l/leverage.asp) (borrowing power). The [notional size](https://www.investopedia.com/terms/n/notionalvalue.asp) is the full exposure (e.g. $5,000 even if only $1,000 of collateral was posted) and profit or loss is the notional times the percentage change in price: +A trader goes [long](https://www.investopedia.com/terms/l/long.asp) if they think the price will rise or [short](https://www.investopedia.com/terms/s/short.asp) if they think it will fall. They post [collateral](https://www.investopedia.com/terms/c/collateral.asp) and choose a position size, and the pool's [initial margin](https://www.investopedia.com/terms/i/initialmargin.asp) caps their [leverage](https://www.investopedia.com/terms/l/leverage.asp) (borrowing power): `open_position` requires the collateral left after the open fee to be at least `initial_margin_bps` of the size, checked as `net_collateral * 10_000 >= size * initial_margin_bps`, and fails with `InitialMarginNotMet` otherwise. An initial margin of 1,000 basis points (10%) allows at most 10× leverage. The [notional size](https://www.investopedia.com/terms/n/notionalvalue.asp) is the full exposure (e.g. $5,000 even if only $1,000 of collateral was posted) and profit or loss is the notional times the percentage change in price: ``` long profit/loss = size * (price - entry_price) / entry_price @@ -40,9 +40,38 @@ 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, and both `add_liquidity` and `remove_liquidity` divide by the share supply plus `MINIMUM_LIQUIDITY`, so the withheld shares belong to nobody and their slice of the pool never leaves. That lock is what defeats share inflation here. Tokens sent straight to the vault move nothing, because shares are priced against `Pool.liquidity`, but `liquidity` grows with funding payments and trader losses, and a provider can also be the pool's only trader. An attacker holding one share who pays funding into the pool to make each share expensive owns 1 of 1,001 shares, so almost all of what they pay in stays with the withheld minimum. -### Reserved liquidity +### Profit is paid as far as the pool can back it: the haircut -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. +The risk model comes from Anatoly Yakovenko's [Percolator](https://github.com/aeyakovenko/percolator): a trader's collateral is **senior**, and their profit is **junior**, paid only as far as the pool holds the tokens to pay it. Nothing is set aside when a position opens, the pool's liquidity does not limit how large a position can be, and profit has no cap. The pool stays solvent at exit instead. When `close_position` settles a winning position it first computes the **haircut ratio** `h`: + +``` +backing = liquidity + insurance_fund +liability = max(0, traders' aggregate unrealized profit, closing position's profit) +h = min(1, backing / liability) +``` + +The liability comes from the same per-side accumulators that price provider shares, at the current price and before the closing position leaves them, so no handler iterates positions. While the backing covers the liability, `h` is one and every profit is paid in full. When a sharp move leaves traders owed more than the backing, every winner who closes is paid `profit * h`, rounded down, so each is paid the same fraction of their profit. The part a haircut withholds stays in `liquidity`, and `h` rises again as losing positions settle their losses into the pool. A loss is never haircut. `HAIRCUT_PRECISION` (10⁹) is the fixed point `h` is carried in. + +Open losing positions offset winners in the aggregate, so one winner's profit can be larger than what traders are owed in total. That is why the closing position's own profit is in the `max`: a winner who closes while open losers still offset them is paid at most the pool's backing, and the close is never refused for lack of it. Whenever the aggregate is the larger of the two, the closer's own profit changes nothing, and every other winner's fraction is unchanged. + +The profit is paid from `liquidity` first. If it is larger than `liquidity`, the insurance fund pays the rest, since the haircut counted the fund as backing. Because the haircut keeps the profit within both, `PoolInsolvent` remains only as a defensive check. + +`test_haircut_scales_profit_when_pool_stressed` opens two longs owed $1,800 between them against $900 of liquidity and checks that the first to close and the second are each paid exactly half of their profit, `test_insurance_pays_profit_beyond_liquidity` checks a profit larger than `liquidity` is paid in full with the insurance fund covering the difference, and `test_winner_offset_by_open_loser_is_paid_not_refused` closes a long up $1,000 while a short down $900 is still open, against $300 of backing, and checks the long is paid exactly $300 and the short's later close settles its loss in full. + +### Profit warm-up + +Every position records the slot it opened in, `Position.entry_slot`. `close_position` refuses to pay a profit before slot `entry_slot + profit_warmup_slots`, failing with `ProfitNotMatured`, so someone who pushes the oracle to a false price cannot open a position and take its profit less than `profit_warmup_slots` apart; by then the price has had that long to correct. A losing position can close in the slot it opened, and liquidation is never delayed. `profit_warmup_slots` is fixed by `initialize_pool`. + +### The insurance fund + +`insurance_fee_bps` of every open and close fee goes to `Pool.insurance_fund`, rounded down, and the rest to `Pool.program_fees`, so the two add up to the whole fee. `initialize_pool` refuses an `insurance_fee_bps` of 10,000 or more with `InvalidParameter`. The fund never pays a fee. It pays for two things: + +- When a liquidated position's equity is below zero, it lost more than its collateral. The fund pays that deficit as far as it can, and the liquidity providers bear only the rest. +- It pays a winner's profit once `liquidity` is exhausted, as above. + +The vault always holds `liquidity + total_collateral + program_fees + insurance_fund`, plus any tokens sent to it directly; the tests' `assert_vault_matches_ledger` checks that after the haircut, insurance-fund and withdrawal scenarios. + +Provider withdrawals are capped at `liquidity`. Shares are priced against assets-under-management, which counts traders' unrealized losses as the providers' gain, but those losses are still in the traders' collateral until their positions close, so `remove_liquidity` fails with `InsufficientLiquidity` when a redemption would pay out more than `liquidity`. While traders are up instead, share pricing already keeps a withdrawal below `liquidity` minus their profit, so the backing for that profit stays in the pool. ### Funding @@ -52,15 +81,30 @@ Funding runs on the wall clock rather than the slot count, so what a position co ### 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, `liquidation_fee_bps` of the position's size, paid out of its remaining equity. Whatever part of the fee the equity cannot cover is forgiven, as in Percolator: neither the insurance fund nor the liquidity providers pay it, so a liquidator of a position whose equity is already below zero receives nothing, and the position still closes. The insurance fund pays its deficit first (see [the insurance fund](#the-insurance-fund)); `test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity` liquidates such a position and checks the exact split. + +`initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <= 10_000` and refuses anything else with `InitialMarginNotAboveMaintenance` (or `InvalidParameter` above 10,000). Every position therefore opens with more margin than it is liquidated at, so none can be liquidated in the slot it opened. ### Oracle The mark price comes from an oracle feed. This example validates the price for staleness (by slot), publication after the most recent cluster restart (the `LastRestartSlot` sysvar, because a halt passes hours of wall-clock time in zero slots), positivity, scale, and a [confidence band](https://docs.pyth.network/price-feeds/best-practices#confidence-intervals) that must stay within `max_confidence_bps` of the price: rejecting an uncertain price is one of the most common oracle-safety checks. +### Price band + +A single oracle print can be wrong while still being fresh, positive and confident: a publisher fault, or a thin market moved for a few seconds. To stop anyone trading against such a print, the pool keeps its own time-weighted moving average of the oracle price, `Pool.average_price`, and refuses prices too far from it. + +- `initialize_pool` reads the oracle and seeds both `average_price` and `last_oracle_price` with its price, stamping `average_price_timestamp` with the Clock's `unix_timestamp`. +- Every handler that reads the oracle credits the seconds since the previous read to the price that read saw, `last_oracle_price`, on the assumption that it held throughout: `average += (last_oracle_price - average) * min(elapsed, PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`. It then records the price it read as the new `last_oracle_price`. The window is 600 seconds, so a price seen at two reads six seconds apart moves the average by 1% of its gap from the average, and an interval of ten minutes or more replaces the average with the price seen at its start. +- The price read now only starts counting from now. A manipulated price moves the average only if the oracle still shows it at a later read, and only by the seconds between the two reads; a read of the real price in between replaces it. A pool left idle for longer than the window therefore cannot have its average set by a single read. +- `open_position`, `close_position`, `add_liquidity` and `remove_liquidity` first check the price against the stored average, before anything is folded in: `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. A price outside that band fails with `PriceOutsideBand`, and the pool is left unchanged. +- `liquidate_position` folds and records without the band check. A genuine crash is when positions go underwater, so liquidation keeps working through one. +- `update_price_average()` is permissionless: any signer passes the pool and its oracle feed, and the handler reads and validates the oracle with the same checks, accrues funding, folds the elapsed interval in and records the price, with no band check. After a genuine move takes the oracle outside the band, keepers call it repeatedly as time passes: the first call records the new price, and each later call credits the time since the previous one to it, until the average is close enough to the price for trading to resume. + +`max_price_deviation_bps` is fixed by `initialize_pool`, which refuses zero (every move would be refused) and 10,000 or more (a fall could never be refused, since prices are positive) with `InvalidPriceDeviation`. + ### 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 program. 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; `insurance_fee_bps` of each goes to the insurance fund and the rest accrues to the program. 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. --- @@ -74,7 +118,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; onchain 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; onchain they are base units (× 10⁶). The pool is configured with a 10% initial margin (10× leverage), 0.1% open/close fees with half of each paid into the insurance fund, a 5% maintenance margin, a 1% liquidation fee, a 1% maximum oracle confidence band, a 20% price band around its average price, and a 10-slot profit warm-up. --- @@ -82,9 +126,11 @@ Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). T **Instruction:** `initialize_pool(parameters)` +The handler validates the parameters, then reads the oracle once to seed the pool's average price at $100. + **Accounts created:** -- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, liquidity, reserved liquidity, collateral total, per-side open-interest accumulators, funding index, program fees. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA +- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, liquidity, collateral total, program fees, insurance fund, per-side open-interest accumulators, funding index, average oracle price. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA - `custody_vault` [token account](https://solana.com/docs/terminology#token-account) PDA, seeds `["vault", pool]`: all USDC, both provider liquidity and trader collateral; `pool` is its owner - `lp_mint` PDA, seeds `["lp_mint", pool]`: the share [mint](https://solana.com/docs/terminology#mint-account); `pool` is the mint authority @@ -109,16 +155,16 @@ The pool can now pay trader winnings, and Carol holds shares representing her sl **Instruction:** `open_position(side = Long, collateral_amount = 1,000 USDC, size = 5,000 USDC, acceptable_price)` -NVDAx is at $100. The 0.1% open fee ($5) comes out of her collateral, leaving $995 of net collateral backing the position. +NVDAx is at $100. The 0.1% open fee ($5) comes out of her collateral, leaving $995 of net collateral backing the position. Nothing is set aside from `Pool.liquidity` for her profit. **Accounts modified:** -- `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 (the current slot) - `alice_usdc`: −1,000 USDC - `custody_vault`: +1,000 USDC - `Pool.total_collateral`: +$995 -- `Pool.program_fees`: +$5 -- `Pool.reserved_liquidity`: +$5,000 (must stay ≤ liquidity) +- `Pool.program_fees`: +$2.50 +- `Pool.insurance_fund`: +$2.50 - `Pool` long open-interest accumulators: += this position --- @@ -127,7 +173,7 @@ NVDAx is at $100. The 0.1% open fee ($5) comes out of her collateral, leaving $9 **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.program_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.program_fees` +$2.50; `Pool.insurance_fund` +$2.50; 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. @@ -137,14 +183,16 @@ 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. +More than 10 slots have passed since she opened, so her profit has warmed up, and $116 is 16% above the pool's $100 average price, inside the 20% band, so the close goes through. Her profit is `5,000 × (116 − 100) / 100 = $800`, minus the $5 close fee. Bob's short is down the same $800, so traders are owed nothing in aggregate and the haircut `h` is one: she is paid her profit in full. **Accounts modified:** - `Pool.liquidity`: −$800 (providers pay her profit) -- `Pool.reserved_liquidity`: −$5,000 (reserve released) - `Pool.total_collateral`: −$995 -- `Pool.program_fees`: +$5 +- `Pool.program_fees`: +$2.50 +- `Pool.insurance_fund`: +$2.50 +- `Pool.average_price`: credits the time since the last read to $100, the price that read saw, so it stays at $100 +- `Pool.last_oracle_price`: $100 → $116, which the next read credits for the time in between - long open-interest accumulators: −= this position - `custody_vault` → `alice_usdc`: pays out $1,790 (net collateral + profit − close fee) - `Position` (Alice): closed; rent returned to Alice @@ -160,20 +208,21 @@ At $116 Bob's short has lost $800; his equity ($995 − $800 = $195) has fallen **Accounts modified:** - 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's equity was still positive, so the $50 fee came out of it and the insurance fund was untouched. Had the price gone far enough to take his equity below zero, Dave would have received nothing, the position would still have closed, and the insurance fund would have paid Bob's loss beyond his collateral before the providers bore any of it. + --- ### Step 7: Admin collects the program's fees **Instruction:** `collect_fees()` -**Accounts modified:** `Pool.program_fees` → 0; `custody_vault` pays that amount to `admin_usdc`. +**Accounts modified:** `Pool.program_fees`: $7.50 → 0; `custody_vault` pays $7.50 to `admin_usdc`. The $7.50 in `Pool.insurance_fund` stays in the vault. --- @@ -181,7 +230,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. A withdrawal can pay out at most `Pool.liquidity`, the tokens the providers own now; an open trader's unrealized loss counts toward her shares' value but is still in that trader's collateral. **Accounts modified:** `lp_mint` burns Carol's shares; `Pool.liquidity` falls; `custody_vault` pays out USDC to `carol_usdc`. @@ -191,11 +240,11 @@ Carol burns her shares and redeems USDC. Her balance now reflects the fees the p 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, Kani-checked 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: -- **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. +- **Realizable credit**: "protected principal is senior, positive PnL is junior, and source-domain positive credit cannot exceed realizable backing reserved for that domain." Here, trader collateral is senior and trader profit is junior: `close_position` pays a winner the haircut fraction `h` of their profit, so payouts never exceed `liquidity + insurance_fund`, and a profit is paid only after the position's warm-up. - **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. -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`) add that this example still leaves out: multi-asset custody with reserves in the payout token, utilization-based borrow fees, and valuing positions at the oracle's EMA rather than its spot price. This example keeps its own average only to decide when to refuse trading, and values positions at the spot price. --- @@ -204,15 +253,27 @@ 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. - 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, pre-restart-price, and wide-confidence rejection, funding accrual, funding-rate retuning (including that it settles elapsed seconds at the old rate, and that only the authority may call it), funding that follows seconds rather than slots, 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, and share inflation through a provider's own trades +- opening and closing longs and shorts in profit and loss +- the initial margin on both sides of its boundary, and slippage rejection +- stale-price, pre-restart-price, and wide-confidence rejection +- funding accrual, the funding-rate maximum, an operator's wallet on the lighter side earning only the fixed rate, and funding that follows seconds rather than slots +- the price band: opens, closes, deposits and withdrawals refused when the oracle jumps outside it (`test_open_rejected_when_oracle_jumps_outside_band`, `test_close_rejected_when_oracle_jumps_outside_band`, `test_liquidity_changes_rejected_when_oracle_jumps_outside_band`), liquidation running outside it (`test_liquidation_runs_outside_band`), the exact average after each `update_price_average` (`test_single_update_moves_average_by_elapsed_fraction`), repeated updates walking the average to a genuine move until trading resumes (`test_price_average_catches_up_after_genuine_move`), and one manipulated read after an idle window leaving the average where it was (`test_one_manipulated_read_after_idle_does_not_move_average`) +- liquidation, and the refusal to liquidate a healthy position +- the haircut: a position opening without full backing (`test_open_allowed_without_full_backing`), profit paid in full while the pool backs it (`test_profit_runs_uncapped_when_backed`), two winners each paid exactly half when the pool is stressed (`test_haircut_scales_profit_when_pool_stressed`), the insurance fund paying a profit beyond `liquidity` (`test_insurance_pays_profit_beyond_liquidity`), and a winner offset by an open loser paid the pool's whole backing rather than refused (`test_winner_offset_by_open_loser_is_paid_not_refused`) +- the profit warm-up on both sides of its boundary (`test_profit_blocked_before_maturation`, `test_profit_realized_after_maturation`), and a loss closing in the slot it opened (`test_loss_not_gated_by_maturation`) +- the insurance fund: its exact share of each fee (`test_insurance_fund_funded_by_fees`), a bankrupt position's deficit paid by the fund (`test_insurance_absorbs_bankruptcy_deficit`), and a bankrupt position liquidated for no fee with the fund paying before the providers (`test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity`) +- withdrawals capped at `liquidity` while traders are down (`test_remove_liquidity_capped_at_liquidity`) +- `initialize_pool`'s parameter checks, including an initial margin at or below the maintenance margin, a price band outside its range, and an insurance fee of 10,000 basis points or more +- fee collection ```bash anchor build diff --git a/finance/perpetual-futures/anchor-v1/TERMINOLOGY.md b/finance/perpetual-futures/anchor-v1/TERMINOLOGY.md index 26c7bd5e..0915f75b 100644 --- a/finance/perpetual-futures/anchor-v1/TERMINOLOGY.md +++ b/finance/perpetual-futures/anchor-v1/TERMINOLOGY.md @@ -2,36 +2,65 @@ Terms used in this example, in the sense they carry here. -- **Perpetual future (perp)** — a leveraged derivative position with no expiry +- **Perpetual future (perp)**: a leveraged derivative position with no expiry and no settlement date. Profit and loss is paid in the collateral token as the oracle price moves. -- **Long / short** — a long profits when the price rises, a short when it falls. +- **Long / short**: a long profits when the price rises, a short when it falls. Each is the opposite side of the pool's exposure. -- **Collateral** — the token a trader posts to back a position, and the token +- **Collateral**: the token a trader posts to back a position, and the token liquidity providers deposit. One pool uses one collateral token. -- **Notional size** — the position's exposure in collateral units. Profit and +- **Notional size**: the position's exposure in collateral units. Profit and loss scales with the notional, not with the collateral posted. -- **Leverage** — notional size divided by collateral. A pool caps it at - `max_leverage`. -- **Equity** — a position's current worth: net collateral plus unrealized profit +- **Leverage**: notional size divided by collateral. A pool caps it through its + initial margin: 1,000 basis points (10%) allows at most 10x. +- **Initial margin**: the net collateral, as a fraction of notional size, a + position must post to open (`initial_margin_bps`). Always above the + maintenance margin, so no position opens already liquidatable. +- **Equity**: a position's current worth: net collateral plus unrealized profit and loss, minus accrued funding. When equity falls to the maintenance margin, the position is liquidatable. -- **Maintenance margin** — the minimum equity, as a fraction of notional size, +- **Maintenance margin**: the minimum equity, as a fraction of notional size, 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. -- **Funding** — a periodic payment that anchors the pool's risk. The heavier +- **Liquidation**: closing an under-margined position. Permissionless here: any + caller can trigger it and earns the liquidation fee out of the position's + remaining equity. The part of the fee the equity cannot cover is forgiven. +- **Deficit**: what a liquidated position lost beyond its collateral, when its + equity is below zero. The insurance fund pays it first, and the liquidity + providers bear what the fund cannot. +- **Insurance fund**: the tokens the pool holds, in `insurance_fund`, from + `insurance_fee_bps` of every open and close fee. It pays deficits, pays a + winner's profit once `liquidity` is exhausted, and never pays a fee. +- **Senior / junior**: a trader's collateral is senior, always theirs to + reclaim less their losses. Their profit is junior: paid only as far as the + pool's liquidity and insurance fund can back it. +- **Haircut ratio (`h`)**: the fraction of their profit every winner closing at + a given moment is paid: one while `liquidity + insurance_fund` covers the + profit owed, and that backing divided by the profit owed when it does not. + The profit owed is the larger of traders' aggregate profit and the closing + position's own, so a winner who closes while open losers still offset them + is paid at most the backing, and never refused. +- **Profit warm-up**: the `profit_warmup_slots` a position must stay open before + it can be closed at a profit. A loss is never held back. +- **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. -- **Liquidity provider** — a depositor who funds the pool and is the counterparty +- **Open interest**: the total notional size currently open on a side. +- **Liquidity provider**: a depositor who funds the pool and is the counterparty to every trade, earning fees in exchange for taking the other side of trader profit and loss. -- **Assets-under-management** — the marked value of liquidity-provider holdings: +- **Assets-under-management**: the marked value of liquidity-provider holdings: pool liquidity minus the aggregate unrealized profit traders are owed. -- **Liquidity-provider share** — a token representing a pro-rata claim on +- **Liquidity-provider share**: a token representing a pro-rata claim on assets-under-management. -- **Oracle feed** — the account the pool reads its price from. This example uses +- **Oracle feed**: the account the pool reads its price from. This example uses a mock oracle price feed; production points at a real one, such as a Pyth price feed. -- **Mark price** — the price positions are valued at. Here it is the oracle +- **Mark price**: the price positions are valued at. Here it is the oracle price directly, with no separate mark/index distinction. +- **Average price**: the pool's time-weighted moving average of the oracle + price (`average_price`), which follows the last ten minutes of prices. Each + oracle read credits the seconds since the previous read to the price that + read saw (`last_oracle_price`), so a price counts only from the read that + first sees it. Positions are never valued at it. +- **Price band**: the range around the average price, `max_price_deviation_bps` + wide on each side, outside which the pool refuses to open or close positions + or move liquidity. Liquidation is not refused. diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs index 07bedc4f..8634e04c 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs +++ b/finance/perpetual-futures/anchor-v1/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 fraction of their +/// profit every closing winner is paid. `HAIRCUT_PRECISION` is `h = 1` (profit +/// paid in full); a smaller value pays that fraction of it. A winner's profit is +/// multiplied by `h` and divided by this, rounding down, so rounding never pays +/// a winner more than that fraction. +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, and both `add_liquidity` and @@ -33,10 +40,18 @@ pub const MINIMUM_LIQUIDITY: u64 = 1_000; /// lowers over time, so the window tightens on its own and never loosens. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; -/// Upper bound on the per-pool `max_leverage` parameter, so a pool cannot be -/// configured with an absurd leverage that makes every position instantly -/// liquidatable on the smallest price move. -pub const MAX_LEVERAGE_CEILING: u16 = 100; +/// How many seconds of oracle prices the pool's `average_price` follows. Each +/// fold moves the average toward the price seen at the previous read by +/// `elapsed / window` of the gap between them, and an interval of a full window +/// or more replaces the average with that price. Ten minutes is long enough +/// that a price seen at two reads six seconds apart, about as long as a faulty +/// or manipulated oracle print lasts, moves the average by one percent of its +/// jump, and short enough that a genuine move is back inside the band within +/// minutes of repeated reads. Counted on the Clock's `unix_timestamp`, +/// like funding: it is a span of wall-clock time, and the second or two of +/// leader drift changes a fold's weight by well under one percent. +#[constant] +pub const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; /// Upper bound on the per-pool `funding_rate_per_second` parameter, in /// `FUNDING_PRECISION` units: 277 billionths of a position's size per second, diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs index 3f33f0c2..ffb3e453 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs @@ -14,8 +14,8 @@ pub enum PerpError { #[msg("Arithmetic overflow")] MathOverflow, - #[msg("Requested leverage exceeds the pool maximum")] - LeverageTooHigh, + #[msg("Position is too large for its collateral: net collateral is below the pool's initial margin")] + InitialMarginNotMet, #[msg("Pool parameter is outside the allowed range")] InvalidParameter, @@ -38,7 +38,7 @@ pub enum PerpError { #[msg("Fill price is worse than the caller's acceptable price")] SlippageExceeded, - #[msg("Pool does not have enough free liquidity to satisfy this request")] + #[msg("Withdrawal is larger than the pool's liquidity: part of the shares' value is still in open positions")] InsufficientLiquidity, #[msg("Posted collateral does not cover the open fee")] @@ -58,4 +58,18 @@ pub enum PerpError { #[msg("Oracle price is stale: it predates the last cluster restart")] PricePredatesRestart, + + #[msg("Initial margin is at or below the maintenance margin: positions could open already liquidatable")] + InitialMarginNotAboveMaintenance, + + #[msg("Maximum price deviation is outside the allowed range: it must be above zero and below 10,000 basis points")] + InvalidPriceDeviation, + + #[msg("Oracle price is too far from the pool's average price: trading pauses until the average catches up")] + PriceOutsideBand, + + #[msg( + "Profit cannot be taken yet: the position has not been open for the pool's profit warm-up" + )] + ProfitNotMatured, } diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs index 78af513c..3ce6c4d3 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs @@ -8,7 +8,7 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, 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, refresh_price_and_funding_within_band}; use crate::state::Pool; pub fn handle_add_liquidity( @@ -19,7 +19,7 @@ pub fn handle_add_liquidity( require!(amount > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; let lp_supply = context.accounts.lp_mint.supply; let shares: u64 = if lp_supply == 0 && pool.liquidity == 0 { diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/close_position.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/close_position.rs index 61c74139..a2604763 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/close_position.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/close_position.rs @@ -6,7 +6,10 @@ 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, settle_position}; +use crate::instructions::shared::{ + apply_haircut, basis_points_of, credit_fee, haircut_ratio, position_pnl, + refresh_price_and_funding_within_band, settle_position, +}; use crate::state::{Pool, Position}; pub fn handle_close_position( @@ -14,17 +17,38 @@ pub fn handle_close_position( minimum_payout: u64, ) -> Result<()> { let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; - + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; + + // The haircut is computed while this position is still in the per-side + // accumulators, so its own profit counts toward the liability and it is + // paid the same fraction as any other winner closing at this price. Its + // own profit is passed too: if open losers offset it in the aggregate, + // the haircut is sized against that profit, so the payout is at most the + // backing and the close is never refused for lack of it. let position = &context.accounts.position; + let closing_profit = position_pnl(position.side, position.size, position.entry_price, price)?; + let haircut = haircut_ratio(pool, price, closing_profit)?; + 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); + // A profit is paid only once the position has been open for the pool's + // warm-up, and then only the haircut fraction of it. A loss settles in + // full, at any time. + let realized_pnl = if settlement.profit_and_loss > 0 { + let matured_at = entry_slot + .checked_add(pool.profit_warmup_slots) + .ok_or(PerpError::MathOverflow)?; + require!( + Clock::get()?.slot >= matured_at, + 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 +66,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)?; - - // Liquidity providers are the counterparty: they pay the trader's (capped) - // profit and receive their loss, and collect the funding the trader owed. + // 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 profit the haircut withholds stays in + // `liquidity`. A payment larger than `liquidity` takes the rest from the + // insurance fund, which the haircut counted as backing. The haircut keeps + // the profit within both; `PoolInsolvent` remains as a defensive check. let liquidity_delta = settlement .funding .checked_sub(realized_pnl) @@ -57,14 +79,22 @@ pub fn handle_close_position( let new_liquidity = (pool.liquidity as i128) .checked_add(liquidity_delta) .ok_or(PerpError::MathOverflow)?; - require!(new_liquidity >= 0, PerpError::PoolInsolvent); - pool.liquidity = new_liquidity - .try_into() - .map_err(|_| PerpError::MathOverflow)?; - pool.program_fees = pool - .program_fees - .checked_add(close_fee) - .ok_or(PerpError::MathOverflow)?; + if new_liquidity < 0 { + let shortfall: u64 = new_liquidity + .unsigned_abs() + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + pool.insurance_fund = pool + .insurance_fund + .checked_sub(shortfall) + .ok_or(PerpError::PoolInsolvent)?; + pool.liquidity = 0; + } else { + pool.liquidity = new_liquidity + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + } + credit_fee(pool, close_fee)?; // The pool signs the CPI below with its own seeds. let pool_seeds: &[&[u8]] = &[ diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs index f87dafa8..e451c55c 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs @@ -5,10 +5,10 @@ use anchor_spl::{ }; use crate::constants::{ - BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, MAX_LEVERAGE_CEILING, - POOL_SEED, VAULT_SEED, + BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, POOL_SEED, VAULT_SEED, }; use crate::errors::PerpError; +use crate::state::oracle::read_oracle_price; use crate::state::Pool; /// Trading parameters set once at pool creation. None of them can be changed @@ -25,12 +25,29 @@ pub struct PoolParameters { pub open_fee_bps: u16, pub close_fee_bps: u16, - pub max_leverage: u16, + + /// Net collateral a position must post to open, in basis points of its + /// notional size. Must be above `maintenance_margin_bps` and at most + /// 10_000 (no leverage). + pub initial_margin_bps: u16, + pub maintenance_margin_bps: u16, pub liquidation_fee_bps: u16, /// Maximum oracle confidence band tolerated, in basis points of the price. pub max_confidence_bps: u16, + + /// Widest gap, in basis points of the pool's average price, between the + /// oracle price and that average at which positions may still open or + /// close and liquidity may still move. + pub max_price_deviation_bps: u16, + + /// Fraction of each open and close fee, in basis points, paid into the + /// insurance fund; the rest goes to program fees. Must be below 10_000. + pub insurance_fee_bps: u16, + + /// Slots a position must stay open before it can be closed at a profit. + pub profit_warmup_slots: u64, } pub fn handle_initialize_pool( @@ -38,10 +55,6 @@ pub fn handle_initialize_pool( parameters: PoolParameters, ) -> Result<()> { let denominator = BASIS_POINTS_DENOMINATOR as u16; - require!( - parameters.max_leverage >= 1 && parameters.max_leverage <= MAX_LEVERAGE_CEILING, - PerpError::InvalidParameter - ); // The rate never changes after this, so bounding it here bounds it for the // life of the pool. require!( @@ -75,12 +88,46 @@ pub fn handle_initialize_pool( parameters.maintenance_margin_bps > parameters.close_fee_bps, PerpError::InvalidParameter ); + // A position must open with more margin than it is liquidated at, or it + // could be liquidated in the same slot it opened. At most 100% of + // notional: more than that would demand collateral above the position's + // size. + require!( + parameters.initial_margin_bps > parameters.maintenance_margin_bps, + PerpError::InitialMarginNotAboveMaintenance + ); + require!( + parameters.initial_margin_bps <= denominator, + PerpError::InvalidParameter + ); // Zero would reject every real feed (which always reports some uncertainty); // above 100% is meaningless. Anything in between is a valid risk choice. require!( parameters.max_confidence_bps > 0 && parameters.max_confidence_bps < denominator, PerpError::InvalidParameter ); + // At 10_000 every fee would go to the insurance fund and none to the + // program. + require!( + parameters.insurance_fee_bps < denominator, + PerpError::InvalidParameter + ); + // Zero would refuse every price move, however small. At 100% or more the + // band could never refuse a fall, since the oracle price is always + // positive. + require!( + parameters.max_price_deviation_bps > 0 && parameters.max_price_deviation_bps < denominator, + PerpError::InvalidPriceDeviation + ); + + // Seed the average with a validated oracle price, so the band is in force + // from the first trade. + let initial_price = read_oracle_price( + &context.accounts.oracle_feed, + parameters.oracle_scale, + parameters.max_confidence_bps, + )?; + let current_timestamp = Clock::get()?.unix_timestamp; let pool = &mut context.accounts.pool; pool.authority = context.accounts.authority.key(); @@ -90,22 +137,28 @@ 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.total_collateral = 0; pool.program_fees = 0; + pool.insurance_fund = 0; pool.long_size = 0; pool.short_size = 0; pool.long_size_scaled = 0; pool.short_size_scaled = 0; pool.cumulative_funding = 0; - pool.last_funding_timestamp = Clock::get()?.unix_timestamp; + pool.last_funding_timestamp = current_timestamp; + pool.average_price = initial_price; + pool.last_oracle_price = initial_price; + pool.average_price_timestamp = current_timestamp; pool.funding_rate_per_second = parameters.funding_rate_per_second; pool.open_fee_bps = parameters.open_fee_bps; pool.close_fee_bps = parameters.close_fee_bps; - pool.max_leverage = parameters.max_leverage; + pool.initial_margin_bps = parameters.initial_margin_bps; 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.max_price_deviation_bps = parameters.max_price_deviation_bps; + pool.insurance_fee_bps = parameters.insurance_fee_bps; + pool.profit_warmup_slots = parameters.profit_warmup_slots; pool.bump = context.bumps.pool; Ok(()) @@ -128,8 +181,9 @@ pub struct InitializePoolAccountConstraints<'info> { pub collateral_mint: Box>, /// CHECK: The oracle feed account. Its key is stored on the pool and every - /// read validates the layout, scale, and freshness; it is never trusted by - /// type. Swap for a real Pyth price feed in production. + /// read, including the one here that seeds the average price, validates + /// the layout, scale, and freshness; it is never trusted by type. Swap for + /// a real Pyth price feed in production. pub oracle_feed: UncheckedAccount<'info>, /// Liquidity-provider share mint. The pool account is its mint authority diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/liquidate_position.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/liquidate_position.rs index a6853729..c8cd38dc 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/liquidate_position.rs +++ b/finance/perpetual-futures/anchor-v1/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)?; @@ -33,24 +28,44 @@ pub fn handle_liquidate_position( PerpError::PositionHealthy ); - // The liquidator's reward comes out of whatever equity remains, capped so a - // position already past zero equity cannot pay out more than it has. + // The liquidator's reward comes out of whatever equity remains. Whatever + // part of the fee the equity cannot cover is forgiven: neither the + // insurance fund nor the liquidity providers pay it. let remaining_equity: u64 = settlement .equity .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 whose equity is below zero lost more than its collateral. The + // insurance fund pays that deficit as far as it can, and the liquidity + // providers bear only the rest. + let deficit: u64 = settlement + .equity + .min(0) + .unsigned_abs() + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + let insurance_payment = deficit.min(pool.insurance_fund); + pool.insurance_fund = pool + .insurance_fund + .checked_sub(insurance_payment) + .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) + // collateral minus whatever is paid out as equity, and the insurance + // fund's payment toward the deficit moves, inside the vault, from + // `insurance_fund` to `liquidity`. + let liquidity_delta = (position_collateral as i128) .checked_sub(remaining_equity as i128) + .ok_or(PerpError::MathOverflow)? + .checked_add(insurance_payment as i128) .ok_or(PerpError::MathOverflow)?; let new_liquidity = (pool.liquidity as i128) .checked_add(liquidity_delta) diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs index 88337e1d..33c621de 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/mod.rs @@ -6,6 +6,7 @@ pub mod liquidate_position; pub mod open_position; pub mod remove_liquidity; pub mod shared; +pub mod update_price_average; pub use add_liquidity::*; pub use close_position::*; @@ -14,3 +15,4 @@ pub use initialize_pool::*; pub use liquidate_position::*; pub use open_position::*; pub use remove_liquidity::*; +pub use update_price_average::*; diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/open_position.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/open_position.rs index 45b27ac4..fa0078f0 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/open_position.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/open_position.rs @@ -4,9 +4,11 @@ use anchor_spl::{ token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, }; -use crate::constants::{POOL_SEED, POSITION_SEED, VAULT_SEED}; +use crate::constants::{BASIS_POINTS_DENOMINATOR, 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, credit_fee, refresh_price_and_funding_within_band, scale_size, +}; use crate::state::{Pool, Position, Side}; pub fn handle_open_position( @@ -19,7 +21,7 @@ pub fn handle_open_position( require!(collateral_amount > 0 && size > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; // Slippage: a long must not fill above the caller's limit, a short not // below it. `0` opts out. @@ -32,35 +34,32 @@ pub fn handle_open_position( } // The open fee is taken out of the posted collateral; the rest backs the - // position. Leverage and margin are measured against this net collateral. + // position, and the initial margin is measured against this net collateral. let open_fee = basis_points_of(size, pool.open_fee_bps)?; let net_collateral = collateral_amount .checked_sub(open_fee) .ok_or(PerpError::InsufficientCollateral)?; require!(net_collateral > 0, PerpError::ZeroAmount); - let max_notional = (net_collateral as u128) - .checked_mul(pool.max_leverage as u128) + // Initial margin: net collateral must be at least `initial_margin_bps` of + // the notional size, compared as `net_collateral * 10_000 >= size * bps` + // so nothing is rounded. `initialize_pool` keeps the initial margin above + // the maintenance margin, so a position that passes this check opens with + // equity above the liquidation threshold. + let collateral_scaled = (net_collateral as u128) + .checked_mul(BASIS_POINTS_DENOMINATOR as u128) .ok_or(PerpError::MathOverflow)?; - require!(size as u128 <= max_notional, PerpError::LeverageTooHigh); - - // Refuse a position that would open already inside the liquidation band. - 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) + let required_scaled = (size as u128) + .checked_mul(pool.initial_margin_bps as u128) .ok_or(PerpError::MathOverflow)?; require!( - new_reserved <= pool.liquidity, - PerpError::InsufficientLiquidity + collateral_scaled >= required_scaled, + PerpError::InitialMarginNotMet ); - pool.reserved_liquidity = new_reserved; + // Nothing is set aside to back this position's profit, and the pool's + // liquidity does not limit its size: `close_position` pays each winner the + // fraction of their profit the pool can back (see `haircut_ratio`). let size_scaled = scale_size(size, price)?; // Effects: record the position and the pool's new aggregates before moving @@ -74,16 +73,14 @@ 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 .total_collateral .checked_add(net_collateral) .ok_or(PerpError::MathOverflow)?; - pool.program_fees = pool - .program_fees - .checked_add(open_fee) - .ok_or(PerpError::MathOverflow)?; + credit_fee(pool, open_fee)?; match side { Side::Long => { diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs index e3523727..95283b56 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs @@ -8,7 +8,7 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, 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, refresh_price_and_funding_within_band}; use crate::state::Pool; pub fn handle_remove_liquidity( @@ -19,7 +19,7 @@ pub fn handle_remove_liquidity( require!(shares > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; let lp_supply = context.accounts.lp_mint.supply; let aum = liquidity_provider_aum(pool, price)?; @@ -40,15 +40,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)?; + // Shares are priced against assets-under-management, which counts traders' + // unrealized losses as the providers' gain. Those losses are still in the + // traders' collateral until their positions close, so a withdrawal is + // capped at `liquidity`, the tokens the providers own now. While traders + // are up instead, the pricing already keeps a withdrawal below `liquidity` + // minus their profit, leaving that profit's backing in the pool. require!( - amount_out <= free_liquidity, + amount_out <= pool.liquidity, PerpError::InsufficientLiquidity ); require!( diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs index af20cdfb..e10fda46 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs @@ -1,6 +1,9 @@ use anchor_lang::prelude::*; -use crate::constants::{BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, SIZE_PRECISION}; +use crate::constants::{ + BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, HAIRCUT_PRECISION, PRICE_AVERAGE_WINDOW_SECONDS, + SIZE_PRECISION, +}; use crate::errors::PerpError; use crate::state::{Pool, Position, Side}; @@ -139,9 +142,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. +/// Profit is marked in full, before any haircut: while `haircut_ratio` is +/// below one, winners will be paid less than this, so assets-under-management +/// reads low by the withheld part until they close. pub fn traders_unrealized_pnl(pool: &Pool, price: u64) -> Result { let price = price as i128; let size_precision = SIZE_PRECISION as i128; @@ -180,6 +183,86 @@ pub fn liquidity_provider_aum(pool: &Pool, price: u64) -> Result { .ok_or(PerpError::MathOverflow.into()) } +/// The haircut ratio `h` at `price`, scaled by `HAIRCUT_PRECISION`: the +/// fraction of its profit a winning position is paid when it closes. +/// +/// `h = min(1, (liquidity + insurance_fund) / max(liability, closing_profit))` +/// +/// The liability is the traders' aggregate unrealized profit from the per-side +/// accumulators, floored at zero, so the caller computes `h` before the closing +/// position leaves them, and every winner closing at that moment is paid the +/// same fraction. While the backing covers it `h` is one. When a move leaves +/// traders owed more than the backing, `h` is the backing divided by the +/// liability, floored, and rises again as losing positions settle into +/// `liquidity`. +/// +/// Open losing positions offset winners in the aggregate, so one winner's +/// `closing_profit` can be larger than the liability. Dividing by the larger of +/// the two means a winner who closes while open losers still offset them is +/// paid at most the pool's backing, and is never refused; when the liability is +/// the larger, every other winner's fraction is unchanged. +pub fn haircut_ratio(pool: &Pool, price: u64, closing_profit: i128) -> Result { + let liability: u128 = traders_unrealized_pnl(pool, price)? + .max(closing_profit) + .max(0) + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + 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()) +} + +/// `profit * haircut / HAIRCUT_PRECISION`, rounded down: the part of a +/// winning position's profit the pool pays. `profit` is positive; a loss is +/// never haircut. +pub fn apply_haircut(profit: i128, haircut: u128) -> Result { + let profit: u128 = profit.try_into().map_err(|_| PerpError::MathOverflow)?; + profit + .checked_mul(haircut) + .ok_or(PerpError::MathOverflow)? + .checked_div(HAIRCUT_PRECISION) + .ok_or(PerpError::MathOverflow)? + .try_into() + .map_err(|_| PerpError::MathOverflow.into()) +} + +/// Split an open or close fee into `(insurance_cut, program_cut)`. The +/// insurance cut is `insurance_fee_bps` of the fee, rounded down, and the +/// program keeps the rest, so the two always add up to the whole fee. +pub fn split_fee(fee: u64, insurance_fee_bps: u16) -> Result<(u64, u64)> { + let insurance_cut = basis_points_of(fee, insurance_fee_bps)?; + let program_cut = fee + .checked_sub(insurance_cut) + .ok_or(PerpError::MathOverflow)?; + Ok((insurance_cut, program_cut)) +} + +/// Credit an open or close fee: `insurance_fee_bps` of it to the insurance +/// fund and the rest to program fees. +pub fn credit_fee(pool: &mut Pool, fee: u64) -> Result<()> { + let (insurance_cut, program_cut) = split_fee(fee, pool.insurance_fee_bps)?; + pool.insurance_fund = pool + .insurance_fund + .checked_add(insurance_cut) + .ok_or(PerpError::MathOverflow)?; + pool.program_fees = pool + .program_fees + .checked_add(program_cut) + .ok_or(PerpError::MathOverflow)?; + Ok(()) +} + /// 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( @@ -216,16 +299,102 @@ pub fn basis_points_of(amount: u64, basis_points: u16) -> Result { .map_err(|_| PerpError::MathOverflow.into()) } -/// The preamble every price-sensitive handler runs: read a validated oracle -/// price, then bring the pool's funding index up to the current time, so the -/// settlement that follows uses fresh numbers for both. Centralized so no -/// handler can settle a position against a stale funding index. +/// Fold the elapsed interval into the pool's `average_price`, then record +/// `price` as the latest observation. +/// +/// The interval since the last fold is credited to the price observed at +/// that fold, `last_oracle_price`, on the assumption that it held throughout: +/// +/// `average += (last_oracle_price - average) * min(elapsed, PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS` +/// +/// The price read now only starts counting from now, so it moves the average +/// only if it is still the oracle's price at a later read, weighted by the +/// seconds between the two reads; a read of a different price in between +/// replaces it. A pool left idle for a window or more therefore cannot have +/// its average set by one read. As with funding, a timestamp at or before the +/// stored one is treated as no time elapsed: the average and the stored stamp +/// stay where they are, and only `last_oracle_price` is updated. +pub fn fold_price_into_average(pool: &mut Pool, price: u64, current_timestamp: i64) -> Result<()> { + if current_timestamp <= pool.average_price_timestamp { + pool.last_oracle_price = price; + return Ok(()); + } + let elapsed = current_timestamp + .checked_sub(pool.average_price_timestamp) + .ok_or(PerpError::MathOverflow)?; + let weight = elapsed.min(PRICE_AVERAGE_WINDOW_SECONDS); + + let average = pool.average_price as i128; + // Multiply before dividing; the gap is signed, so the average moves down + // as readily as up. + let movement = (pool.last_oracle_price as i128) + .checked_sub(average) + .ok_or(PerpError::MathOverflow)? + .checked_mul(weight as i128) + .ok_or(PerpError::MathOverflow)? + .checked_div(PRICE_AVERAGE_WINDOW_SECONDS as i128) + .ok_or(PerpError::MathOverflow)?; + pool.average_price = average + .checked_add(movement) + .ok_or(PerpError::MathOverflow)? + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + pool.last_oracle_price = price; + pool.average_price_timestamp = current_timestamp; + Ok(()) +} + +/// Refuse an oracle `price` more than `max_price_deviation_bps` away from the +/// pool's stored `average_price`: +/// `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. +pub fn require_price_within_band(pool: &Pool, price: u64) -> Result<()> { + let deviation_scaled = (price.abs_diff(pool.average_price) as u128) + .checked_mul(BASIS_POINTS_DENOMINATOR as u128) + .ok_or(PerpError::MathOverflow)?; + let band_scaled = (pool.average_price as u128) + .checked_mul(pool.max_price_deviation_bps as u128) + .ok_or(PerpError::MathOverflow)?; + require!(deviation_scaled <= band_scaled, PerpError::PriceOutsideBand); + Ok(()) +} + +/// The preamble `liquidate_position` and `update_price_average` run: read a +/// validated oracle price, bring the pool's funding index up to the current +/// time, and fold the interval since the previous read into the pool's average +/// (see `fold_price_into_average`), so the settlement that follows uses fresh +/// numbers. Centralized so no handler can settle a position +/// against a stale funding index. +/// +/// No band check: liquidation has to keep working through a genuine price +/// move, because that is when positions go underwater, and +/// `update_price_average` is how the average catches up with one. pub fn refresh_price_and_funding(pool: &mut Pool, oracle_feed: &AccountInfo) -> Result { - let price = crate::state::oracle::read_oracle_price( - oracle_feed, - pool.oracle_scale, - pool.max_confidence_bps, - )?; - accrue_funding(pool, Clock::get()?.unix_timestamp)?; + let price = read_pool_oracle_price(pool, oracle_feed)?; + apply_price_and_funding(pool, price)?; Ok(price) } + +/// The preamble for every handler that opens or closes a position or moves +/// liquidity: the same as `refresh_price_and_funding`, but first refuses a +/// price outside the band around the stored average, before anything is +/// folded in or the price is recorded. A single oracle print far from the +/// average therefore cannot open, close, deposit, or withdraw at that price. +pub fn refresh_price_and_funding_within_band( + pool: &mut Pool, + oracle_feed: &AccountInfo, +) -> Result { + let price = read_pool_oracle_price(pool, oracle_feed)?; + require_price_within_band(pool, price)?; + apply_price_and_funding(pool, price)?; + Ok(price) +} + +fn read_pool_oracle_price(pool: &Pool, oracle_feed: &AccountInfo) -> Result { + crate::state::oracle::read_oracle_price(oracle_feed, pool.oracle_scale, pool.max_confidence_bps) +} + +fn apply_price_and_funding(pool: &mut Pool, price: u64) -> Result<()> { + let current_timestamp = Clock::get()?.unix_timestamp; + accrue_funding(pool, current_timestamp)?; + fold_price_into_average(pool, price, current_timestamp) +} diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/update_price_average.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/update_price_average.rs new file mode 100644 index 00000000..8e46535b --- /dev/null +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/update_price_average.rs @@ -0,0 +1,30 @@ +use anchor_lang::prelude::*; + +use crate::constants::POOL_SEED; +use crate::instructions::shared::refresh_price_and_funding; +use crate::state::Pool; + +pub fn handle_update_price_average( + context: Context, +) -> Result<()> { + refresh_price_and_funding(&mut context.accounts.pool, &context.accounts.oracle_feed)?; + Ok(()) +} + +#[derive(Accounts)] +pub struct UpdatePriceAverageAccountConstraints<'info> { + /// Anyone may update the average: the result depends only on the oracle + /// price and the clock, never on who calls. + pub caller: Signer<'info>, + + #[account( + mut, + seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], + bump = pool.bump, + has_one = oracle_feed, + )] + pub pool: Box>, + + /// CHECK: validated by the `has_one = oracle_feed` constraint on the pool. + pub oracle_feed: UncheckedAccount<'info>, +} diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs index a7487816..7c2bb1ed 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs @@ -1,9 +1,10 @@ use anchor_lang::prelude::*; mod constants; -mod errors; // Public so the LiteSVM integration tests can build instruction arguments -// (`PoolParameters`, `Side`) against the program's own types. +// (`PoolParameters`, `Side`) against the program's own types, and match +// failures against `PerpError` codes. +pub mod errors; pub mod instructions; pub mod state; @@ -75,6 +76,18 @@ pub mod perpetual_futures { instructions::handle_liquidate_position(context) } + /// Read the oracle, credit the seconds since the previous read to the + /// price that read saw, record the current price for the next read, and + /// accrue funding up to now. Permissionless: after a genuine price move + /// takes the oracle outside the pool's band, anyone can call this + /// repeatedly as time passes to walk the average toward the new price until + /// trading resumes. + pub fn update_price_average( + context: Context, + ) -> Result<()> { + instructions::handle_update_price_average(context) + } + /// The pool operator sweeps the accumulated program fees from the vault. pub fn collect_fees(context: Context) -> Result<()> { instructions::handle_collect_fees(context) diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs index 46f8831a..3de952be 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs @@ -30,22 +30,26 @@ 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 backs trader profit: when the two cannot cover the + /// profit traders are owed, every closing winner is paid the same fraction + /// of their profit (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, - /// Sum of every open position's posted collateral, held in the same vault. pub total_collateral: u64, /// Program fees accrued from open/close fees, awaiting `collect_fees`. pub program_fees: u64, + /// Funded by `insurance_fee_bps` of every open and close fee. It pays a + /// bankrupt position's deficit (its loss beyond its collateral) before + /// liquidity providers bear any of it, pays a winner's profit once + /// `liquidity` is exhausted, and counts alongside `liquidity` as backing in + /// the haircut. The vault holds `liquidity + total_collateral + + /// program_fees + insurance_fund`, plus any tokens sent to it directly. + pub insurance_fund: u64, + /// Aggregate long open interest (sum of position `size`), in collateral /// base units of notional. pub long_size: u128, @@ -71,6 +75,23 @@ pub struct Pool { /// the cluster's slot time. pub last_funding_timestamp: i64, + /// Time-weighted moving average of the oracle price, in the pool's + /// `oracle_scale` fixed point. Seeded with the oracle price when the pool is + /// created. Every handler that reads the oracle credits the seconds since + /// the previous read to `last_oracle_price`, the price that read saw. + /// Trading and liquidity handlers refuse an oracle price more than + /// `max_price_deviation_bps` away from it, so a sudden jump pauses them + /// until the average catches up. + pub average_price: u64, + + /// The oracle price at the most recent read, in `oracle_scale` fixed point. + /// The next read folds it into `average_price` for the seconds in between. + pub last_oracle_price: u64, + + /// The Clock's `unix_timestamp` of the most recent fold into + /// `average_price`. + pub average_price_timestamp: i64, + /// Funding accrued per second, in `FUNDING_PRECISION` units, applied to the /// heavier side. The funding paid by traders accrues to the pool. pub funding_rate_per_second: u64, @@ -80,11 +101,13 @@ pub struct Pool { pub close_fee_bps: u16, - /// Highest leverage a position may open at (`size <= collateral * max`). - pub max_leverage: u16, + /// Net collateral a position must post to open, in basis points of its + /// notional size: 1_000 allows at most 10x leverage. Always above + /// `maintenance_margin_bps`, so no position opens already liquidatable. + pub initial_margin_bps: u16, - /// Equity threshold, in basis points of notional, below which a position is - /// liquidatable. + /// Equity threshold, in basis points of notional, at or below which a + /// position is liquidatable. pub maintenance_margin_bps: u16, /// Reward paid to a liquidator, in basis points of the liquidated notional. @@ -94,6 +117,21 @@ pub struct Pool { /// pool will trade against. A wider band is rejected as untrustworthy. pub max_confidence_bps: u16, + /// Widest gap the pool trades across between the oracle price and + /// `average_price`, in basis points of `average_price`. + pub max_price_deviation_bps: u16, + + /// Fraction of each open and close fee, in basis points, paid into + /// `insurance_fund`; the rest goes to `program_fees`. + pub insurance_fee_bps: u16, + + /// Slots a position must stay open before `close_position` will pay it a + /// profit. Someone who pushes the oracle to a false price cannot open a + /// position and take its profit less than this many slots apart; by then the + /// price has had that long to correct. A losing position can close, and an + /// under-margined one be liquidated, at any time. + pub profit_warmup_slots: u64, + /// Bump of this account's own address. The pool owns the custody vault /// and is the LP mint's authority, so it signs vault transfers and /// mint/burn CPIs with `[POOL_SEED, collateral_mint, oracle_feed, bump]`; diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/position.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/position.rs index 29a0d708..7dbd9d44 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/position.rs +++ b/finance/perpetual-futures/anchor-v1/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 in. `close_position` pays a profit only from + /// slot `entry_slot + pool.profit_warmup_slots` on. + pub entry_slot: u64, + pub bump: u8, } diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs index 62876964..1b7202c3 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -4,7 +4,11 @@ use { AccountDeserialize, InstructionData, ToAccountMetas, }, litesvm::LiteSVM, - perpetual_futures::{instructions::initialize_pool::PoolParameters, state::Pool, state::Side}, + perpetual_futures::{ + errors::PerpError, + instructions::initialize_pool::PoolParameters, + state::{Pool, Position, Side}, + }, solana_keypair::Keypair, solana_kite::{ create_associated_token_account, create_token_mint, create_wallet, @@ -17,8 +21,16 @@ use { // Matches `MAX_FUNDING_RATE_PER_SECOND` in the program's constants: the // steepest funding rate `initialize_pool` accepts. const MAX_FUNDING_RATE_PER_SECOND: u64 = 277; +// Matches `PRICE_AVERAGE_WINDOW_SECONDS`: one fold after this many seconds +// replaces the pool's average price with the oracle price. +const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; // Ten years, in seconds. const TEN_YEARS: i64 = 315_360_000; +// The test market's profit warm-up: a position can be closed at a profit from +// this many slots after it opened. +const PROFIT_WARMUP_SLOTS: u64 = 10; +// Matches `HAIRCUT_PRECISION`: a haircut ratio of one. +const HAIRCUT_PRECISION: u64 = 1_000_000_000; // Collateral token has 6 decimals (like USDC), so one whole unit is 1_000_000 // base units. const ONE_USDC: u64 = 1_000_000; @@ -52,6 +64,40 @@ fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) } +/// The parameters every test market uses unless a test overrides one: 0.1% +/// open and close fees, half of each fee paid into the insurance fund, a 10% +/// initial margin (10x leverage), a 5% maintenance margin, a 1% liquidation +/// fee, a 1% maximum confidence band, a 20% price band around the pool's +/// average price, and a 10-slot profit warm-up. +fn default_parameters(funding_rate_per_second: u64) -> PoolParameters { + PoolParameters { + oracle_scale: ORACLE_SCALE, + funding_rate_per_second, + open_fee_bps: 10, + close_fee_bps: 10, + initial_margin_bps: 1_000, + maintenance_margin_bps: 500, + liquidation_fee_bps: 100, + max_confidence_bps: 100, + max_price_deviation_bps: 2_000, + insurance_fee_bps: 5_000, + profit_warmup_slots: PROFIT_WARMUP_SLOTS, + } +} + +/// Assert that `result` failed with the program's `expected` error. Anchor +/// reports a program error as `Custom(6000 + the variant's index)`. +fn assert_fails_with(result: Result, expected: PerpError) { + let code = expected as u32 + 6000; + let Err(error) = result else { + panic!("the transaction should have failed with error code {code}"); + }; + assert!( + error.contains(&format!("Custom({code})")), + "expected error code {code}, got: {error}" + ); +} + /// One deployed market plus the keys needed to drive it. struct Market { svm: LiteSVM, @@ -69,23 +115,14 @@ impl Market { /// funding rate. The admin is both the pool operator and the oracle feed /// authority. fn new(initial_price: i128, funding_rate_per_second: u64) -> Market { - let parameters = PoolParameters { - oracle_scale: ORACLE_SCALE, - funding_rate_per_second, - 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") + Market::try_new(initial_price, default_parameters(funding_rate_per_second)) + .expect("pool initialization should succeed") } /// Like `new`, but takes the full parameter set and surfaces an /// `initialize_pool` rejection instead of panicking, so tests can probe the /// parameter validation. - fn try_new(initial_price: i128, parameters: PoolParameters) -> Result { + fn try_new(initial_price: i128, parameters: PoolParameters) -> Result { let mut svm = LiteSVM::new(); svm.add_program( perpetual_futures::id(), @@ -167,7 +204,7 @@ impl Market { &[&admin], &admin.pubkey(), ) - .map_err(|_| ())?; + .map_err(|error| format!("{error:?}"))?; Ok(Market { svm, @@ -238,6 +275,45 @@ impl Market { self.svm.get_sysvar::().slot } + /// Let the profit warm-up pass, so a position opened in the current slot + /// can be closed at a profit. The caller republishes the price after. + fn pass_warmup(&mut self) { + let slot = self.current_slot(); + self.warp(slot + PROFIT_WARMUP_SLOTS); + } + + fn position_state(&self, owner: &Pubkey, side: Side) -> Position { + let account = self + .svm + .get_account(&self.position_pda(owner, side)) + .unwrap(); + Position::try_deserialize(&mut account.data.as_slice()).unwrap() + } + + /// Assert the custody vault holds exactly what the pool's ledger says it + /// does: liquidity, open positions' collateral, program fees and the + /// insurance fund. + fn assert_vault_matches_ledger(&self) { + let pool = self.pool_state(); + assert_eq!( + get_token_account_balance(&self.svm, &self.custody_vault).unwrap(), + pool.liquidity + pool.total_collateral + pool.program_fees + pool.insurance_fund + ); + } + + /// A wallet with an empty collateral token account, to liquidate from. + fn liquidator(&mut self) -> (Keypair, Pubkey) { + let liquidator = create_wallet(&mut self.svm, 100_000_000_000).unwrap(); + let liquidator_collateral = create_associated_token_account( + &mut self.svm, + &liquidator.pubkey(), + &self.collateral_mint, + &self.payer, + ) + .unwrap(); + (liquidator, liquidator_collateral) + } + /// Simulate a cluster restart at `slot`: prices stamped at or before it /// must be rejected until the publisher posts again. fn set_last_restart_slot(&mut self, slot: u64) { @@ -275,7 +351,7 @@ impl Market { provider_collateral: Pubkey, amount: u64, minimum_shares_out: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let provider_lp = derive_ata(&provider.pubkey(), &self.lp_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -305,8 +381,7 @@ impl Market { &[provider], &provider.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn remove_liquidity( @@ -315,7 +390,7 @@ impl Market { provider_collateral: Pubkey, shares: u64, minimum_amount_out: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let provider_lp = derive_ata(&provider.pubkey(), &self.lp_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -345,8 +420,7 @@ impl Market { &[provider], &provider.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn position_pda(&self, owner: &Pubkey, side: Side) -> Pubkey { @@ -369,7 +443,7 @@ impl Market { collateral_amount: u64, size: u64, acceptable_price: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let position = self.position_pda(&trader.pubkey(), side); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -400,8 +474,7 @@ impl Market { &[trader], &trader.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn close_position( @@ -410,7 +483,7 @@ impl Market { trader_collateral: Pubkey, side: Side, minimum_payout: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let position = self.position_pda(&trader.pubkey(), side); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -435,8 +508,7 @@ impl Market { &[trader], &trader.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn liquidate( @@ -445,7 +517,7 @@ impl Market { owner: &Pubkey, owner_collateral: Pubkey, side: Side, - ) -> Result<(), ()> { + ) -> Result<(), String> { let position = self.position_pda(owner, side); let liquidator_collateral = derive_ata(&liquidator.pubkey(), &self.collateral_mint); let instruction = Instruction::new_with_bytes( @@ -473,11 +545,10 @@ impl Market { &[liquidator], &liquidator.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } - fn collect_fees(&mut self, authority: &Keypair) -> Result<(), ()> { + fn collect_fees(&mut self, authority: &Keypair) -> Result<(), String> { let authority_collateral = derive_ata(&authority.pubkey(), &self.collateral_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -500,8 +571,40 @@ impl Market { &[authority], &authority.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) + } + + fn update_price_average(&mut self, caller: &Keypair) -> Result<(), String> { + let instruction = Instruction::new_with_bytes( + perpetual_futures::id(), + &perpetual_futures::instruction::UpdatePriceAverage {}.data(), + perpetual_futures::accounts::UpdatePriceAverageAccountConstraints { + caller: caller.pubkey(), + pool: self.pool, + oracle_feed: self.feed, + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[caller], + &caller.pubkey(), + ) + .map_err(|error| format!("{error:?}")) + } + + /// Hold the oracle at `price` while the pool's average catches up with + /// it: one update records `price` as the latest observation, then a full + /// averaging window passes with the price republished so it is fresh, and + /// a second update credits that window to `price`. A price more than the + /// band away from the average cannot be traded at until this has run. + fn settle_average_at(&mut self, price: i128) { + let caller = self.payer.insecure_clone(); + self.update_price_average(&caller).unwrap(); + self.pass_seconds(PRICE_AVERAGE_WINDOW_SECONDS); + self.set_price(price); + self.update_price_average(&caller).unwrap(); } /// Deposit a large amount of liquidity so the pool can pay trader profits, @@ -523,7 +626,17 @@ fn test_initialize_pool() { assert_eq!(pool.collateral_mint, market.collateral_mint); assert_eq!(pool.oracle_feed, market.feed); assert_eq!(pool.oracle_scale, ORACLE_SCALE); - assert_eq!(pool.max_leverage, 10); + assert_eq!(pool.initial_margin_bps, 1_000); + assert_eq!(pool.max_price_deviation_bps, 2_000); + // The average starts at the oracle price the pool was created against. + assert_eq!(pool.average_price, dollars(100) as u64); + assert_eq!( + pool.average_price_timestamp, + market + .svm + .get_sysvar::() + .unix_timestamp + ); assert_eq!(pool.liquidity, 0); assert_eq!(pool.total_collateral, 0); @@ -642,8 +755,7 @@ fn test_add_and_remove_liquidity_round_trip() { /// funding they paid in. #[test] fn test_inflating_liquidity_through_own_trades_does_not_pay() { - // The steepest rate a pool may have, held for ten years. The position is - // tiny because a pool holding 1_001 can back only 1_001 of notional. + // The steepest rate a pool may have, held for ten years. let mut market = Market::new(dollars(100), MAX_FUNDING_RATE_PER_SECOND); let (attacker, attacker_collateral) = market.funded_trader(10_000 * ONE_USDC); @@ -656,9 +768,8 @@ fn test_inflating_liquidity_through_own_trades_does_not_pay() { 1 ); - // The pool holds 1_001, so it can back a position of up to 1_001 notional. - // Heavy collateral keeps the position far from liquidation while funding - // drains it into `liquidity`. + // A 1_000 long. Heavy collateral keeps the position far from liquidation + // while funding drains it into `liquidity`. market .open_position( &attacker, @@ -728,10 +839,17 @@ fn test_open_long_updates_pool() { let pool = market.pool_state(); assert_eq!(pool.long_size, size as u128); assert_eq!(pool.short_size, 0); - // Collateral minus the 0.1% open fee is now tracked as trader collateral. + // Collateral minus the 0.1% open fee is now tracked as trader collateral, + // and the fee is split evenly between the insurance fund and the program. let open_fee = size / 1_000; assert_eq!(pool.total_collateral, collateral - open_fee); - assert_eq!(pool.program_fees, open_fee); + assert_eq!(pool.insurance_fund, open_fee / 2); + assert_eq!(pool.program_fees, open_fee / 2); + // Nothing is set aside from liquidity for the position. + assert_eq!(pool.liquidity, 100_000 * ONE_USDC); + let position = market.position_state(&trader.pubkey(), Side::Long); + assert_eq!(position.entry_slot, market.current_slot()); + market.assert_vault_matches_ledger(); } #[test] @@ -746,7 +864,9 @@ fn test_close_long_in_profit() { .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) .unwrap(); - // Price rises 20%: a $5,000 long earns $1,000. + // Price rises 20%: a $5,000 long earns $1,000, paid once the warm-up has + // passed. + market.pass_warmup(); market.set_price(dollars(120)); market .close_position(&trader, trader_collateral, Side::Long, 0) @@ -806,6 +926,7 @@ fn test_close_short_in_profit() { .unwrap(); // Price falls 10%: a $5,000 short earns $500. + market.pass_warmup(); market.set_price(dollars(90)); market .close_position(&trader, trader_collateral, Side::Short, 0) @@ -849,17 +970,53 @@ fn test_open_rejects_zero_amounts() { } #[test] -fn test_open_rejects_excess_leverage() { +fn test_open_rejects_position_below_initial_margin() { let mut market = Market::default_market(); market.seed_liquidity(100_000 * ONE_USDC); - let collateral = 1_000 * ONE_USDC; - let (trader, trader_collateral) = market.funded_trader(collateral); + let (trader, trader_collateral) = market.funded_trader(2_000 * ONE_USDC); - // max_leverage is 10x; 11x must be rejected. - let size = 11_000 * ONE_USDC; - assert!(market - .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) - .is_err()); + // The initial margin is 10% of notional. 1,000 USDC of collateral less + // the 11 USDC open fee leaves 989 USDC, short of the 1,100 USDC an 11,000 + // USDC position needs. + assert_fails_with( + market.open_position( + &trader, + trader_collateral, + Side::Long, + 1_000 * ONE_USDC, + 11_000 * ONE_USDC, + 0, + ), + PerpError::InitialMarginNotMet, + ); + + // A 10,000 USDC position needs 1,000 USDC net of its 10 USDC open fee. + // One minor unit short of 1,010 USDC is refused, and exactly 1,010 USDC + // opens at 10x. + let size = 10_000 * ONE_USDC; + let exact_collateral = 1_010 * ONE_USDC; + assert_fails_with( + market.open_position( + &trader, + trader_collateral, + Side::Long, + exact_collateral - 1, + size, + 0, + ), + PerpError::InitialMarginNotMet, + ); + market + .open_position( + &trader, + trader_collateral, + Side::Long, + exact_collateral, + size, + 0, + ) + .unwrap(); + assert_eq!(market.pool_state().total_collateral, size / 10); } #[test] @@ -1061,18 +1218,18 @@ fn test_funding_follows_seconds_not_slots() { #[test] fn test_initialize_pool_rejects_funding_rate_above_the_maximum() { // The rate is fixed at creation, so this is the only place it is checked. - let parameters = |funding_rate_per_second| PoolParameters { - oracle_scale: ORACLE_SCALE, - funding_rate_per_second, - open_fee_bps: 10, - close_fee_bps: 10, - max_leverage: 10, - maintenance_margin_bps: 500, - liquidation_fee_bps: 100, - max_confidence_bps: 100, - }; - assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND + 1)).is_err()); - assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND)).is_ok()); + assert_fails_with( + Market::try_new( + dollars(100), + default_parameters(MAX_FUNDING_RATE_PER_SECOND + 1), + ), + PerpError::InvalidParameter, + ); + assert!(Market::try_new( + dollars(100), + default_parameters(MAX_FUNDING_RATE_PER_SECOND) + ) + .is_ok()); } /// The pool operator trading against their own pool. The lighter side of open @@ -1270,26 +1427,28 @@ fn test_collect_fees_requires_authority() { assert!(market.collect_fees(&imposter).is_err()); } +/// Nothing is set aside to back a position's profit, so a position can open +/// against a pool that could not pay its full winnings: here a $10,000 long +/// against $6,000 of liquidity. #[test] -fn test_open_rejects_when_pool_cannot_back_it() { +fn test_open_allowed_without_full_backing() { 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 - .open_position( - &trader, - trader_collateral, - Side::Long, - 1_000 * ONE_USDC, - 5_000 * ONE_USDC, - 0 - ) - .is_err()); + 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(); + + let pool = market.pool_state(); + assert_eq!(pool.long_size, size as u128); + assert_eq!(pool.liquidity, 6_000 * ONE_USDC); + market.assert_vault_matches_ledger(); } #[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; @@ -1299,9 +1458,12 @@ 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, so the long's profit is twice its size. A move this + // large is far outside the price band, so the average has to catch up + // before the position can close, which also passes the warm-up. The + // $100,000 pool backs the whole $10,000 profit, so it is paid in full. market.set_price(dollars(300)); + market.settle_average_at(dollars(300)); market .close_position(&trader, trader_collateral, Side::Long, 0) .unwrap(); @@ -1309,15 +1471,218 @@ 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; + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + net_collateral + profit - close_fee + ); + assert_eq!(market.pool_state().liquidity, 100_000 * ONE_USDC - profit); + market.assert_vault_matches_ledger(); +} + +/// Two longs are owed $1,800 of profit between them, and the pool holds only +/// $900 to pay it with, so each is paid half of their profit: the first to +/// close is paid half of theirs, and the second, closing against what is left, +/// is paid half of theirs too. +#[test] +fn test_haircut_scales_profit_when_pool_stressed() { + // No fee goes to the insurance fund here, so the only backing is the $900 + // of liquidity and the first close adds nothing to it. + let mut market = Market::try_new( + dollars(100), + PoolParameters { + insurance_fee_bps: 0, + ..default_parameters(0) + }, + ) + .unwrap(); + market.seed_liquidity(900 * ONE_USDC); + + let first_collateral = 1_000 * ONE_USDC; + let first_size = 6_000 * ONE_USDC; + let (first, first_account) = market.funded_trader(first_collateral); + market + .open_position( + &first, + first_account, + Side::Long, + first_collateral, + first_size, + 0, + ) + .unwrap(); + let second_collateral = 800 * ONE_USDC; + let second_size = 4_000 * ONE_USDC; + let (second, second_account) = market.funded_trader(second_collateral); + market + .open_position( + &second, + second_account, + Side::Long, + second_collateral, + second_size, + 0, + ) + .unwrap(); + + // At $118 the first long is up $1,080 and the second $720: $1,800 owed + // against $900 of backing, so h = 900 / 1,800 = 0.5. + market.pass_warmup(); + market.set_price(dollars(118)); + let half = HAIRCUT_PRECISION / 2; + let first_profit = first_size * 18 / 100; + let second_profit = second_size * 18 / 100; + + market + .close_position(&first, first_account, Side::Long, 0) + .unwrap(); + let first_paid = first_profit * half / HAIRCUT_PRECISION; + assert_eq!(first_paid, 540 * ONE_USDC); + assert_eq!( + get_token_account_balance(&market.svm, &first_account).unwrap(), + first_collateral - first_size / 1_000 + first_paid - first_size / 1_000 + ); + // The $540 withheld from the first long stays with the providers. + assert_eq!(market.pool_state().liquidity, 360 * ONE_USDC); + + // The second long is now owed $720 against $360: h is still 0.5. + market + .close_position(&second, second_account, Side::Long, 0) + .unwrap(); + let second_paid = second_profit * half / HAIRCUT_PRECISION; + assert_eq!(second_paid, 360 * ONE_USDC); + assert_eq!( + get_token_account_balance(&market.svm, &second_account).unwrap(), + second_collateral - second_size / 1_000 + second_paid - second_size / 1_000 + ); + assert_eq!(market.pool_state().liquidity, 0); + market.assert_vault_matches_ledger(); +} + +/// Alice's long is up $1,000 while Bob's short, still open and healthy, is +/// down $900, so traders are owed only $100 in aggregate, and the pool's +/// backing is $300. Sized against the $100 alone the haircut would be one and +/// Alice's $1,000 would exceed the backing; it is sized against her $1,000 +/// instead, so she is paid exactly the $300 and the close goes through. Bob's +/// later close settles his loss into the pool in full. +#[test] +fn test_winner_offset_by_open_loser_is_paid_not_refused() { + let mut market = Market::default_market(); + // $290.50 of liquidity plus the $9.50 the two open fees put in the + // insurance fund is $300 of backing. + market.seed_liquidity(290_500_000); + + let alice_collateral = 1_100 * ONE_USDC; + let alice_size = 10_000 * ONE_USDC; + let (alice, alice_account) = market.funded_trader(alice_collateral); + market + .open_position( + &alice, + alice_account, + Side::Long, + alice_collateral, + alice_size, + 0, + ) + .unwrap(); + let bob_collateral = 2_000 * ONE_USDC; + let bob_size = 9_000 * ONE_USDC; + let (bob, bob_account) = market.funded_trader(bob_collateral); + market + .open_position(&bob, bob_account, Side::Short, bob_collateral, bob_size, 0) + .unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.liquidity + pool.insurance_fund, 300 * ONE_USDC); + + // At $110 Alice is up $1,000 and Bob down $900: h = 300 / 1,000 = 0.3. + market.pass_warmup(); + market.set_price(dollars(110)); + market + .close_position(&alice, alice_account, Side::Long, 0) + .unwrap(); + let alice_paid = 1_000 * ONE_USDC * (3 * HAIRCUT_PRECISION / 10) / HAIRCUT_PRECISION; + assert_eq!(alice_paid, 300 * ONE_USDC); + let alice_fee = alice_size / 1_000; + assert_eq!( + get_token_account_balance(&market.svm, &alice_account).unwrap(), + alice_collateral - alice_fee + alice_paid - alice_fee + ); + // The whole backing was paid out; the fund then took half of Alice's + // close fee. + let pool = market.pool_state(); + assert_eq!(pool.liquidity, 0); + assert_eq!(pool.insurance_fund, alice_fee / 2); + market.assert_vault_matches_ledger(); + + // Bob closes at the same price, losing $900 into the pool. + market + .close_position(&bob, bob_account, Side::Short, 0) + .unwrap(); + let bob_fee = bob_size / 1_000; + let bob_loss = 900 * ONE_USDC; + assert_eq!( + get_token_account_balance(&market.svm, &bob_account).unwrap(), + bob_collateral - bob_fee - bob_loss - bob_fee + ); + let pool = market.pool_state(); + assert_eq!(pool.liquidity, bob_loss); + assert_eq!(pool.insurance_fund, (alice_fee + bob_fee) / 2); + assert_eq!(pool.total_collateral, 0); + market.assert_vault_matches_ledger(); +} + +/// The haircut counts the insurance fund as backing, so a profit larger than +/// `liquidity` but within `liquidity + insurance_fund` is paid in full: the +/// pool's liquidity first, the insurance fund for the rest. +#[test] +fn test_insurance_pays_profit_beyond_liquidity() { + // A 5% open fee, half of which goes to the insurance fund. + let mut market = Market::try_new( + dollars(100), + PoolParameters { + open_fee_bps: 500, + ..default_parameters(0) + }, + ) + .unwrap(); + market.seed_liquidity(1_700 * ONE_USDC); + + // $500 open fee: $250 to the insurance fund, $1,100 of net collateral. + let collateral = 1_600 * 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(); + assert_eq!(market.pool_state().insurance_fund, 250 * ONE_USDC); + + // At $118 the long is up $1,800: more than the $1,700 of liquidity, within + // the $1,950 of liquidity plus insurance, so h = 1. + market.pass_warmup(); + market.set_price(dollars(118)); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + + let profit = 1_800 * ONE_USDC; + let close_fee = size / 1_000; assert_eq!( get_token_account_balance(&market.svm, &trader_collateral).unwrap(), - expected + 1_100 * ONE_USDC + profit - close_fee ); + let pool = market.pool_state(); + assert_eq!(pool.liquidity, 0); + // $100 of the profit came from the insurance fund, which then took half + // of the $10 close fee. + assert_eq!(pool.insurance_fund, 150 * ONE_USDC + close_fee / 2); + market.assert_vault_matches_ledger(); } +/// Shares are priced against assets-under-management, which counts a +/// trader's unrealized loss as the providers' gain, but that loss is still in +/// the trader's collateral. A withdrawal is capped at `liquidity`. #[test] -fn test_remove_liquidity_blocked_by_reserved() { +fn test_remove_liquidity_capped_at_liquidity() { 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); @@ -1332,16 +1697,265 @@ 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. - let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); - let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); - assert!(market - .remove_liquidity(&provider, provider_collateral, shares, 0) - .is_err()); + // At $80 the long is down $1,000, so assets-under-management is $11,000 + // against $10,000 of liquidity, and each share redeems 1.1 minor units + // (the provider's shares plus the withheld minimum are 10,000 USDC of + // shares). 9,090,909,092 shares would redeem 10,000,000,001, one minor + // unit more than `liquidity`, and are refused. + market.set_price(dollars(80)); + assert_fails_with( + market.remove_liquidity(&provider, provider_collateral, 9_090_909_092, 0), + PerpError::InsufficientLiquidity, + ); + + // One share fewer redeems exactly the pool's liquidity. + market + .remove_liquidity(&provider, provider_collateral, 9_090_909_091, 0) + .unwrap(); + assert_eq!( + get_token_account_balance(&market.svm, &provider_collateral).unwrap(), + 10_000 * ONE_USDC + ); + assert_eq!(market.pool_state().liquidity, 0); + market.assert_vault_matches_ledger(); +} + +/// One slot short of the warm-up, a profitable close is refused and the +/// position stays open. +#[test] +fn test_profit_blocked_before_maturation() { + let mut market = Market::default_market(); + 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 entry_slot = market + .position_state(&trader.pubkey(), Side::Long) + .entry_slot; + + market.warp(entry_slot + PROFIT_WARMUP_SLOTS - 1); + market.set_price(dollars(110)); + assert_fails_with( + market.close_position(&trader, trader_collateral, Side::Long, 0), + PerpError::ProfitNotMatured, + ); + assert_eq!(market.pool_state().long_size, size as u128); + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + 0 + ); +} + +/// From exactly `entry_slot + profit_warmup_slots`, the profit is paid. +#[test] +fn test_profit_realized_after_maturation() { + let mut market = Market::default_market(); + 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 entry_slot = market + .position_state(&trader.pubkey(), Side::Long) + .entry_slot; + + market.warp(entry_slot + PROFIT_WARMUP_SLOTS); + market.set_price(dollars(110)); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + + let fee = size / 1_000; + let profit = size / 10; + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + collateral - fee + profit - fee + ); +} + +/// The warm-up holds back profit only: a losing position closes in the slot +/// it opened. +#[test] +fn test_loss_not_gated_by_maturation() { + let mut market = Market::default_market(); + 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 entry_slot = market + .position_state(&trader.pubkey(), Side::Long) + .entry_slot; + + // Price falls 10% within the same slot: a $500 loss. + market.set_price(dollars(90)); + assert_eq!(market.current_slot(), entry_slot); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + + let fee = size / 1_000; + let loss = size / 10; + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + collateral - fee - loss - fee + ); + assert_eq!(market.pool_state().liquidity, 100_000 * ONE_USDC + loss); +} + +/// `insurance_fee_bps` of each open and close fee goes to the insurance fund, +/// rounded down, and the program keeps the rest, so no minor unit is lost. +#[test] +fn test_insurance_fund_funded_by_fees() { + let mut market = Market::try_new( + dollars(100), + PoolParameters { + insurance_fee_bps: 3_333, + ..default_parameters(0) + }, + ) + .unwrap(); + market.seed_liquidity(100_000 * ONE_USDC); + + // A size whose 0.1% fee is 1,234,567 minor units: 3,333 basis points of + // that is 411,481.18, so the insurance fund gets 411,481 and the program + // the other 823,086. + let size = 1_234_567_890; + let fee = 1_234_567; + let insurance_cut = 411_481; + assert_eq!(size / 1_000, fee); + let collateral = 200 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.insurance_fund, insurance_cut); + assert_eq!(pool.program_fees, fee - insurance_cut); + + // Closing at the open price charges the same fee again. + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.insurance_fund, 2 * insurance_cut); + assert_eq!(pool.program_fees, 2 * (fee - insurance_cut)); + market.assert_vault_matches_ledger(); +} + +/// A $1,000 long with $110 of net collateral, liquidated after a 15% fall: +/// its $150 loss leaves equity at -$40. +fn open_long_and_gap_through_zero(market: &mut Market) -> (Keypair, Pubkey) { + 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(); + market.set_price(dollars(85)); + (trader, trader_collateral) +} + +/// A bankrupt position's deficit, its loss beyond its collateral, is paid by +/// the insurance fund when the fund holds enough. +#[test] +fn test_insurance_absorbs_bankruptcy_deficit() { + // A 5% open fee, 90% of which goes to the insurance fund: $45 of the $50. + let mut market = Market::try_new( + dollars(100), + PoolParameters { + open_fee_bps: 500, + insurance_fee_bps: 9_000, + ..default_parameters(0) + }, + ) + .unwrap(); + let (trader, trader_collateral) = open_long_and_gap_through_zero(&mut market); + assert_eq!(market.pool_state().insurance_fund, 45 * ONE_USDC); + let liquidity_before = market.pool_state().liquidity; + + let (liquidator, liquidator_collateral) = market.liquidator(); + market + .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) + .unwrap(); + + // The fund pays the $40 deficit, so the providers keep the $110 of + // collateral and are credited the full $150 loss. + let pool = market.pool_state(); + assert_eq!(pool.insurance_fund, 5 * ONE_USDC); + assert_eq!(pool.liquidity, liquidity_before + 150 * ONE_USDC); + assert_eq!( + get_token_account_balance(&market.svm, &liquidator_collateral).unwrap(), + 0 + ); + market.assert_vault_matches_ledger(); +} + +/// A position already below zero equity can still be liquidated by anyone. +/// Its equity cannot pay the liquidation fee, so the fee is forgiven and the +/// liquidator receives nothing. The insurance fund pays as much of the deficit +/// as it holds, and the liquidity providers bear only the rest. +#[test] +fn test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity() { + // A 5% open fee, half of which goes to the insurance fund: $25 of the $50. + let mut market = Market::try_new( + dollars(100), + PoolParameters { + open_fee_bps: 500, + ..default_parameters(0) + }, + ) + .unwrap(); + let (trader, trader_collateral) = open_long_and_gap_through_zero(&mut market); + assert_eq!(market.pool_state().insurance_fund, 25 * ONE_USDC); + let liquidity_before = market.pool_state().liquidity; + + let (liquidator, liquidator_collateral) = market.liquidator(); + market + .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) + .unwrap(); + + // The $40 deficit: $25 from the insurance fund, $15 borne by the + // providers, who keep the $110 of collateral plus the fund's $25. + let pool = market.pool_state(); + assert_eq!(pool.insurance_fund, 0); + assert_eq!(pool.liquidity, liquidity_before + 135 * ONE_USDC); + assert_eq!(pool.long_size, 0); + assert_eq!(pool.total_collateral, 0); + assert_eq!( + get_token_account_balance(&market.svm, &liquidator_collateral).unwrap(), + 0 + ); + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + 0 + ); assert!(market - .remove_liquidity(&provider, provider_collateral, shares / 2, 0) - .is_ok()); + .svm + .get_account(&market.position_pda(&trader.pubkey(), Side::Long)) + .is_none()); + market.assert_vault_matches_ledger(); +} + +#[test] +fn test_initialize_pool_rejects_insurance_fee_at_or_above_full_fee() { + let with_insurance_fee = |insurance_fee_bps| PoolParameters { + insurance_fee_bps, + ..default_parameters(0) + }; + assert_fails_with( + Market::try_new(dollars(100), with_insurance_fee(10_000)), + PerpError::InvalidParameter, + ); + assert!(Market::try_new(dollars(100), with_insurance_fee(9_999)).is_ok()); } #[test] @@ -1350,14 +1964,306 @@ 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_second: 0, - open_fee_bps: 10, close_fee_bps: 600, - max_leverage: 10, - maintenance_margin_bps: 500, - liquidation_fee_bps: 100, - max_confidence_bps: 100, + ..default_parameters(0) + }; + assert_fails_with( + Market::try_new(dollars(100), parameters), + PerpError::InvalidParameter, + ); +} + +#[test] +fn test_initialize_pool_rejects_initial_margin_at_or_below_maintenance() { + // An initial margin at or below the 5% maintenance margin would let a + // position open already liquidatable. + let with_initial_margin = |initial_margin_bps| PoolParameters { + initial_margin_bps, + ..default_parameters(0) }; - assert!(Market::try_new(dollars(100), parameters).is_err()); + assert_fails_with( + Market::try_new(dollars(100), with_initial_margin(500)), + PerpError::InitialMarginNotAboveMaintenance, + ); + assert_fails_with( + Market::try_new(dollars(100), with_initial_margin(350)), + PerpError::InitialMarginNotAboveMaintenance, + ); + + // Above 100% of notional is refused too. One basis point above the + // maintenance margin, and exactly 100%, are accepted. + assert_fails_with( + Market::try_new(dollars(100), with_initial_margin(10_001)), + PerpError::InvalidParameter, + ); + assert!(Market::try_new(dollars(100), with_initial_margin(501)).is_ok()); + assert!(Market::try_new(dollars(100), with_initial_margin(10_000)).is_ok()); +} + +#[test] +fn test_initialize_pool_rejects_price_deviation_outside_range() { + let with_deviation = |max_price_deviation_bps| PoolParameters { + max_price_deviation_bps, + ..default_parameters(0) + }; + for rejected in [0, 10_000] { + assert_fails_with( + Market::try_new(dollars(100), with_deviation(rejected)), + PerpError::InvalidPriceDeviation, + ); + } + assert!(Market::try_new(dollars(100), with_deviation(1)).is_ok()); + assert!(Market::try_new(dollars(100), with_deviation(9_999)).is_ok()); +} + +/// A single oracle print far from the pool's average cannot be traded at: the +/// open is refused before the price is folded into the average. +#[test] +fn test_open_rejected_when_oracle_jumps_outside_band() { + let mut market = Market::default_market(); + 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); + + // The band is 20% around the $100 average: $125 and $79 are outside it. + for outside_price in [dollars(125), dollars(79)] { + market.set_price(outside_price); + // The two refused opens are otherwise byte-identical transactions. + market.svm.expire_blockhash(); + assert_fails_with( + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), + PerpError::PriceOutsideBand, + ); + // The refused open folded nothing into the average. + assert_eq!(market.pool_state().average_price, dollars(100) as u64); + } + + // $118 is inside the band, and opens at that price. + market.set_price(dollars(118)); + market.svm.expire_blockhash(); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + let position_account = market + .svm + .get_account(&market.position_pda(&trader.pubkey(), Side::Long)) + .unwrap(); + let position = Position::try_deserialize(&mut position_account.data.as_slice()).unwrap(); + assert_eq!(position.entry_price, dollars(118) as u64); +} + +#[test] +fn test_close_rejected_when_oracle_jumps_outside_band() { + let mut market = Market::default_market(); + 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(); + + // A jump to $125 would pay the long $1,250, but $125 is 25% from the + // $100 average, outside the 20% band. + market.set_price(dollars(125)); + assert_fails_with( + market.close_position(&trader, trader_collateral, Side::Long, 0), + PerpError::PriceOutsideBand, + ); + + // At $115, inside the band and after the warm-up, the close goes through + // and pays the 15% gain. + market.pass_warmup(); + market.set_price(dollars(115)); + market.svm.expire_blockhash(); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + let fee = size / 1_000; + let profit = size * 15 / 100; + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + collateral - fee + profit - fee + ); +} + +/// Liquidation has no band check: a genuine crash is when positions go +/// underwater, so the pool has to be able to liquidate through one. +#[test] +fn test_liquidation_runs_outside_band() { + let mut market = Market::default_market(); + market.seed_liquidity(100_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(); + + // $75 is 25% below the $100 average, so the owner cannot close there. + market.set_price(dollars(75)); + assert_fails_with( + market.close_position(&trader, trader_collateral, Side::Long, 0), + PerpError::PriceOutsideBand, + ); + + let liquidator = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + market + .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) + .unwrap(); + assert!(market + .svm + .get_account(&market.position_pda(&trader.pubkey(), Side::Long)) + .is_none()); + assert_eq!(market.pool_state().long_size, 0); +} + +#[test] +fn test_liquidity_changes_rejected_when_oracle_jumps_outside_band() { + let mut market = Market::default_market(); + let (provider, provider_collateral) = market.seed_liquidity(10_000 * ONE_USDC); + let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); + let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); + + // $76 is 24% below the $100 average. + market.set_price(dollars(76)); + let (depositor, depositor_collateral) = market.funded_trader(5_000 * ONE_USDC); + assert_fails_with( + market.add_liquidity(&depositor, depositor_collateral, 5_000 * ONE_USDC, 0), + PerpError::PriceOutsideBand, + ); + assert_fails_with( + market.remove_liquidity(&provider, provider_collateral, shares, 0), + PerpError::PriceOutsideBand, + ); +} + +/// After a genuine move outside the band, anyone can walk the average toward +/// the new price with `update_price_average`, and trading resumes once the +/// price is back inside the band. +#[test] +fn test_price_average_catches_up_after_genuine_move() { + let mut market = Market::default_market(); + 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); + let keeper = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + + // NVDAx reprices from $100 to $130, 30% away from the average. + let new_price = dollars(130); + market.set_price(new_price); + assert_fails_with( + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), + PerpError::PriceOutsideBand, + ); + + // Every two minutes the keeper calls `update_price_average`. Each call + // credits the two minutes since the previous read to the price that read + // saw, a fifth of the window. The first call credits $100, the price + // before the move, and records $130; each later call moves the average a + // fifth of the remaining gap to $130: $100, then $106, then $110.80. $130 + // is within 20% of any average from $108.34 up, so the third update + // reopens trading. + let mut updates = 0; + loop { + market.pass_seconds(120); + market.set_price(new_price); + market.update_price_average(&keeper).unwrap(); + updates += 1; + let opened = + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0); + if opened.is_ok() { + break; + } + assert_fails_with(opened, PerpError::PriceOutsideBand); + assert!(updates < 10, "the average never caught up"); + } + assert_eq!(updates, 3); + let pool = market.pool_state(); + assert_eq!(pool.average_price, 11_080_000_000); + assert_eq!(pool.last_oracle_price, new_price as u64); +} + +#[test] +fn test_single_update_moves_average_by_elapsed_fraction() { + let mut market = Market::default_market(); + let keeper = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + let created_at = market.pool_state().average_price_timestamp; + + // The first update after the oracle moves to $115 credits the four + // minutes since creation to $100, the price seen at creation, so the + // average stays at $100 and $115 is recorded for the next read. + market.pass_seconds(240); + market.set_price(dollars(115)); + market.update_price_average(&keeper).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(100) as u64); + assert_eq!(pool.last_oracle_price, dollars(115) as u64); + assert_eq!(pool.average_price_timestamp, created_at + 240); + + // Four more minutes at $115 are 240 of the 600-second window, so the next + // update moves the average 240/600 of the way from $100 to $115: to $106. + market.pass_seconds(240); + market.set_price(dollars(115)); + market.update_price_average(&keeper).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(106) as u64); + assert_eq!(pool.average_price_timestamp, created_at + 480); + + // Fifteen minutes is more than a full window, so the next update replaces + // the average with $115, the price at the previous read, and records the + // fall to $97. One more update credits $97 for a full window. + market.pass_seconds(900); + market.set_price(dollars(97)); + market.update_price_average(&keeper).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(115) as u64); + assert_eq!(pool.last_oracle_price, dollars(97) as u64); + market.pass_seconds(900); + market.set_price(dollars(97)); + market.update_price_average(&keeper).unwrap(); + assert_eq!(market.pool_state().average_price, dollars(97) as u64); +} + +/// A pool left idle for more than a window cannot have its average set by one +/// read of a manipulated price. The read only records the price; the interval +/// before it is credited to the price seen at the read before. Once a read of +/// the real price replaces it, the manipulated price has moved the average +/// only by the seconds between the two reads. +#[test] +fn test_one_manipulated_read_after_idle_does_not_move_average() { + let mut market = Market::default_market(); + 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); + let attacker = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + + // Fifteen idle minutes, then the oracle is pushed to $160 and the + // attacker calls `update_price_average`. The average stays at $100. + market.pass_seconds(900); + market.set_price(dollars(160)); + market.update_price_average(&attacker).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(100) as u64); + assert_eq!(pool.last_oracle_price, dollars(160) as u64); + + // Six seconds later the oracle is back at $100 and is read again. The six + // seconds are credited to $160: the average moves 6/600 of the $60 gap, + // to $100.60, and $100 replaces $160 as the latest observation. + market.pass_seconds(6); + market.set_price(dollars(100)); + market.update_price_average(&attacker).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, 10_060_000_000); + assert_eq!(pool.last_oracle_price, dollars(100) as u64); + + // An open at $160 is still refused. + market.set_price(dollars(160)); + assert_fails_with( + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), + PerpError::PriceOutsideBand, + ); } diff --git a/finance/perpetual-futures/anchor/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index d878f5d8..0320e8d6 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -1,5 +1,122 @@ # Changelog +## 2026-10-01 + +Replace the leverage cap with an initial margin. `max_leverage` on +`PoolParameters` and `Pool` is now `initial_margin_bps`, the net collateral a +position must post to open, in basis points of its size (1,000 is 10x). +`initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <= +10_000`, refusing an initial margin at or below the maintenance margin with the +new `InitialMarginNotAboveMaintenance` and one above 10,000 with +`InvalidParameter`; `MAX_LEVERAGE_CEILING` is removed. `open_position` checks +`net_collateral * 10_000 >= size * initial_margin_bps` and fails with +`InitialMarginNotMet`, which takes `LeverageTooHigh`'s place and its error code +(6004). Its separate check that a new position starts above the maintenance +margin is removed, because the initial margin implies it; `PositionNotHealthy` +remains for `close_position`. + +Add a price band around a program-maintained average price. A fresh, confident +oracle print could still be wrong, and every handler traded at it. The pool now +keeps `average_price`, a time-weighted moving average of the oracle price, +`last_oracle_price`, the price at the most recent oracle read, and +`average_price_timestamp`. `initialize_pool` seeds the average and +`last_oracle_price` from the oracle. Every handler that reads the oracle credits +the seconds since the previous read to the price that read saw, +`average += (last_oracle_price - average) * min(elapsed, +PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`, with the new +constant at 600 seconds, and then records the price it read as +`last_oracle_price`. The price read now only counts from now, so a pool left +idle for a window or more cannot have its average set by one read of a +manipulated price: that price moves the average only if the oracle still shows +it at a later read, weighted by the seconds between the two reads. +`open_position`, `close_position`, `add_liquidity` and `remove_liquidity` refuse +a price outside `|price - average_price| * 10_000 <= average_price * +max_price_deviation_bps` with the new `PriceOutsideBand`, checked against the +stored average before anything is folded in. `liquidate_position` folds and +records without the check. The new permissionless `update_price_average` +handler folds and records too, also without the check, so keepers calling it +repeatedly as time passes can walk the average to a genuine move. `max_price_deviation_bps` is a new +`PoolParameters` field, which `initialize_pool` requires to be above zero and +below 10,000 with the new `InvalidPriceDeviation`. `shared.rs` has +`refresh_price_and_funding_within_band` for the four band-checked handlers +beside `refresh_price_and_funding` for the other two. The `errors` module is +public so the tests can match `PerpError` codes. + +Tested by `test_open_rejects_position_below_initial_margin` (formerly +`test_open_rejects_excess_leverage`, now checking both sides of the boundary), +`test_initialize_pool_rejects_initial_margin_at_or_below_maintenance`, +`test_initialize_pool_rejects_price_deviation_outside_range`, +`test_open_rejected_when_oracle_jumps_outside_band`, +`test_close_rejected_when_oracle_jumps_outside_band`, +`test_liquidity_changes_rejected_when_oracle_jumps_outside_band`, +`test_liquidation_runs_outside_band`, +`test_price_average_catches_up_after_genuine_move`, +`test_single_update_moves_average_by_elapsed_fraction` and +`test_one_manipulated_read_after_idle_does_not_move_average`. The default test market +uses a 1,000 basis point initial margin and a 2,000 basis point band; +`test_profit_runs_uncapped_when_backed` triples the price, far outside the +band, so it now calls `update_price_average` to record the new price, lets a +full window pass, and calls it again before closing. + +Replace reserved liquidity with the haircut risk model from +[Percolator](https://github.com/aeyakovenko/percolator): trader collateral is +senior, and trader profit is junior, paid only as far as the pool can back it. +`Pool.reserved_liquidity` is removed, and with it `open_position`'s +`reserved + size <= liquidity` check, which failed with `InsufficientLiquidity`, +and `close_position`'s cap on profit at the position's size. A position opens +whatever the pool's liquidity, and profit has no cap. `close_position` computes +the haircut ratio `h = min(1, (liquidity + insurance_fund) / +max(0, traders' aggregate unrealized profit, closing position's profit))` from +the per-side accumulators, before the closing position leaves them, and pays a +winning position `profit * h / HAIRCUT_PRECISION`, rounded down, with the new +constant at 10^9, so every winner closing at the same moment is paid the same +fraction; a loss settles in full. A winner who closes while open losers still +offset them is paid at most the pool's backing rather than refused, and every +other winner's fraction is unchanged. The profit is paid from `liquidity` first +and from the insurance fund for the rest; `PoolInsolvent` remains as a +defensive check. +`remove_liquidity` caps a withdrawal at `liquidity` rather than `liquidity - +reserved_liquidity`, still failing with `InsufficientLiquidity`, whose message +now says the withdrawal is larger than the pool's liquidity. `shared.rs` has +the new `haircut_ratio` and `apply_haircut`. + +Add an insurance fund. `Pool.insurance_fund` is new, and so is +`PoolParameters.insurance_fee_bps`, which `initialize_pool` requires to be below +10,000 or fails with `InvalidParameter`. That fraction of every open and close +fee goes to the fund, rounded down, and the rest to `program_fees`, through the +new `split_fee` and `credit_fee` in `shared.rs`. `liquidate_position` takes a +position's deficit, its loss beyond its collateral, from the fund first and +credits what the fund pays to `liquidity`; the providers bear the rest. The +liquidation fee is still paid only out of the position's remaining equity: the +part the equity cannot cover is forgiven, as in Percolator, and neither the +insurance fund nor `liquidity` pays it. The vault holds `liquidity + +total_collateral + program_fees + insurance_fund`, plus any tokens sent to it +directly. + +Add a profit warm-up. `PoolParameters.profit_warmup_slots` and +`Position.entry_slot`, which `open_position` sets to the current slot, are new. +`close_position` refuses to pay a profit before slot `entry_slot + +profit_warmup_slots` with the new `ProfitNotMatured` (6022). A losing position +closes at any time, and liquidation is not delayed. + +Tested by `test_open_allowed_without_full_backing`, +`test_profit_runs_uncapped_when_backed`, +`test_haircut_scales_profit_when_pool_stressed`, +`test_insurance_pays_profit_beyond_liquidity`, +`test_winner_offset_by_open_loser_is_paid_not_refused`, +`test_remove_liquidity_capped_at_liquidity`, +`test_profit_blocked_before_maturation`, +`test_profit_realized_after_maturation`, `test_loss_not_gated_by_maturation`, +`test_insurance_fund_funded_by_fees`, `test_insurance_absorbs_bankruptcy_deficit`, +`test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity` and +`test_initialize_pool_rejects_insurance_fee_at_or_above_full_fee`. They replace +`test_open_rejects_when_pool_cannot_back_it`, +`test_profit_capped_at_reserved_notional` and +`test_remove_liquidity_blocked_by_reserved`. The default test market pays half +of each fee into the insurance fund and has a 10-slot warm-up, so the tests that +close at a profit first let the warm-up pass, and `test_open_long_updates_pool` +checks the fee split. + ## 2026-09-30 Remove `set_funding_rate`. The pool's authority could change the funding rate at diff --git a/finance/perpetual-futures/anchor/README.md b/finance/perpetual-futures/anchor/README.md index f048883b..866af035 100644 --- a/finance/perpetual-futures/anchor/README.md +++ b/finance/perpetual-futures/anchor/README.md @@ -29,7 +29,7 @@ All arithmetic is integer `u128` with `checked_*` operations, multiplying before ### Long and short, leverage, collateral -A trader goes [long](https://www.investopedia.com/terms/l/long.asp) if they think the price will rise or [short](https://www.investopedia.com/terms/s/short.asp) if they think it will fall. They post [collateral](https://www.investopedia.com/terms/c/collateral.asp) and choose a position size up to the pool's maximum [leverage](https://www.investopedia.com/terms/l/leverage.asp) (borrowing power). The [notional size](https://www.investopedia.com/terms/n/notionalvalue.asp) is the full exposure (e.g. $5,000 even if only $1,000 of collateral was posted) and profit or loss is the notional times the percentage change in price: +A trader goes [long](https://www.investopedia.com/terms/l/long.asp) if they think the price will rise or [short](https://www.investopedia.com/terms/s/short.asp) if they think it will fall. They post [collateral](https://www.investopedia.com/terms/c/collateral.asp) and choose a position size, and the pool's [initial margin](https://www.investopedia.com/terms/i/initialmargin.asp) caps their [leverage](https://www.investopedia.com/terms/l/leverage.asp) (borrowing power): `open_position` requires the collateral left after the open fee to be at least `initial_margin_bps` of the size, checked as `net_collateral * 10_000 >= size * initial_margin_bps`, and fails with `InitialMarginNotMet` otherwise. An initial margin of 1,000 basis points (10%) allows at most 10× leverage. The [notional size](https://www.investopedia.com/terms/n/notionalvalue.asp) is the full exposure (e.g. $5,000 even if only $1,000 of collateral was posted) and profit or loss is the notional times the percentage change in price: ``` long profit/loss = size * (price - entry_price) / entry_price @@ -40,9 +40,38 @@ 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, and both `add_liquidity` and `remove_liquidity` divide by the share supply plus `MINIMUM_LIQUIDITY`, so the withheld shares belong to nobody and their slice of the pool never leaves. That lock is what defeats share inflation here. Tokens sent straight to the vault move nothing, because shares are priced against `Pool.liquidity`, but `liquidity` grows with funding payments and trader losses, and a provider can also be the pool's only trader. An attacker holding one share who pays funding into the pool to make each share expensive owns 1 of 1,001 shares, so almost all of what they pay in stays with the withheld minimum. -### Reserved liquidity +### Profit is paid as far as the pool can back it: the haircut -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. +The risk model comes from Anatoly Yakovenko's [Percolator](https://github.com/aeyakovenko/percolator): a trader's collateral is **senior**, and their profit is **junior**, paid only as far as the pool holds the tokens to pay it. Nothing is set aside when a position opens, the pool's liquidity does not limit how large a position can be, and profit has no cap. The pool stays solvent at exit instead. When `close_position` settles a winning position it first computes the **haircut ratio** `h`: + +``` +backing = liquidity + insurance_fund +liability = max(0, traders' aggregate unrealized profit, closing position's profit) +h = min(1, backing / liability) +``` + +The liability comes from the same per-side accumulators that price provider shares, at the current price and before the closing position leaves them, so no handler iterates positions. While the backing covers the liability, `h` is one and every profit is paid in full. When a sharp move leaves traders owed more than the backing, every winner who closes is paid `profit * h`, rounded down, so each is paid the same fraction of their profit. The part a haircut withholds stays in `liquidity`, and `h` rises again as losing positions settle their losses into the pool. A loss is never haircut. `HAIRCUT_PRECISION` (10⁹) is the fixed point `h` is carried in. + +Open losing positions offset winners in the aggregate, so one winner's profit can be larger than what traders are owed in total. That is why the closing position's own profit is in the `max`: a winner who closes while open losers still offset them is paid at most the pool's backing, and the close is never refused for lack of it. Whenever the aggregate is the larger of the two, the closer's own profit changes nothing, and every other winner's fraction is unchanged. + +The profit is paid from `liquidity` first. If it is larger than `liquidity`, the insurance fund pays the rest, since the haircut counted the fund as backing. Because the haircut keeps the profit within both, `PoolInsolvent` remains only as a defensive check. + +`test_haircut_scales_profit_when_pool_stressed` opens two longs owed $1,800 between them against $900 of liquidity and checks that the first to close and the second are each paid exactly half of their profit, `test_insurance_pays_profit_beyond_liquidity` checks a profit larger than `liquidity` is paid in full with the insurance fund covering the difference, and `test_winner_offset_by_open_loser_is_paid_not_refused` closes a long up $1,000 while a short down $900 is still open, against $300 of backing, and checks the long is paid exactly $300 and the short's later close settles its loss in full. + +### Profit warm-up + +Every position records the slot it opened in, `Position.entry_slot`. `close_position` refuses to pay a profit before slot `entry_slot + profit_warmup_slots`, failing with `ProfitNotMatured`, so someone who pushes the oracle to a false price cannot open a position and take its profit less than `profit_warmup_slots` apart; by then the price has had that long to correct. A losing position can close in the slot it opened, and liquidation is never delayed. `profit_warmup_slots` is fixed by `initialize_pool`. + +### The insurance fund + +`insurance_fee_bps` of every open and close fee goes to `Pool.insurance_fund`, rounded down, and the rest to `Pool.program_fees`, so the two add up to the whole fee. `initialize_pool` refuses an `insurance_fee_bps` of 10,000 or more with `InvalidParameter`. The fund never pays a fee. It pays for two things: + +- When a liquidated position's equity is below zero, it lost more than its collateral. The fund pays that deficit as far as it can, and the liquidity providers bear only the rest. +- It pays a winner's profit once `liquidity` is exhausted, as above. + +The vault always holds `liquidity + total_collateral + program_fees + insurance_fund`, plus any tokens sent to it directly; the tests' `assert_vault_matches_ledger` checks that after the haircut, insurance-fund and withdrawal scenarios. + +Provider withdrawals are capped at `liquidity`. Shares are priced against assets-under-management, which counts traders' unrealized losses as the providers' gain, but those losses are still in the traders' collateral until their positions close, so `remove_liquidity` fails with `InsufficientLiquidity` when a redemption would pay out more than `liquidity`. While traders are up instead, share pricing already keeps a withdrawal below `liquidity` minus their profit, so the backing for that profit stays in the pool. ### Funding @@ -52,15 +81,30 @@ Funding runs on the wall clock rather than the slot count, so what a position co ### 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, `liquidation_fee_bps` of the position's size, paid out of its remaining equity. Whatever part of the fee the equity cannot cover is forgiven, as in Percolator: neither the insurance fund nor the liquidity providers pay it, so a liquidator of a position whose equity is already below zero receives nothing, and the position still closes. The insurance fund pays its deficit first (see [the insurance fund](#the-insurance-fund)); `test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity` liquidates such a position and checks the exact split. + +`initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <= 10_000` and refuses anything else with `InitialMarginNotAboveMaintenance` (or `InvalidParameter` above 10,000). Every position therefore opens with more margin than it is liquidated at, so none can be liquidated in the slot it opened. ### Oracle The mark price comes from an oracle feed. This example validates the price for staleness (by slot), publication after the most recent cluster restart (the `LastRestartSlot` sysvar, because a halt passes hours of wall-clock time in zero slots), positivity, scale, and a [confidence band](https://docs.pyth.network/price-feeds/best-practices#confidence-intervals) that must stay within `max_confidence_bps` of the price: rejecting an uncertain price is one of the most common oracle-safety checks. +### Price band + +A single oracle print can be wrong while still being fresh, positive and confident: a publisher fault, or a thin market moved for a few seconds. To stop anyone trading against such a print, the pool keeps its own time-weighted moving average of the oracle price, `Pool.average_price`, and refuses prices too far from it. + +- `initialize_pool` reads the oracle and seeds both `average_price` and `last_oracle_price` with its price, stamping `average_price_timestamp` with the Clock's `unix_timestamp`. +- Every handler that reads the oracle credits the seconds since the previous read to the price that read saw, `last_oracle_price`, on the assumption that it held throughout: `average += (last_oracle_price - average) * min(elapsed, PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`. It then records the price it read as the new `last_oracle_price`. The window is 600 seconds, so a price seen at two reads six seconds apart moves the average by 1% of its gap from the average, and an interval of ten minutes or more replaces the average with the price seen at its start. +- The price read now only starts counting from now. A manipulated price moves the average only if the oracle still shows it at a later read, and only by the seconds between the two reads; a read of the real price in between replaces it. A pool left idle for longer than the window therefore cannot have its average set by a single read. +- `open_position`, `close_position`, `add_liquidity` and `remove_liquidity` first check the price against the stored average, before anything is folded in: `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. A price outside that band fails with `PriceOutsideBand`, and the pool is left unchanged. +- `liquidate_position` folds and records without the band check. A genuine crash is when positions go underwater, so liquidation keeps working through one. +- `update_price_average()` is permissionless: any signer passes the pool and its oracle feed, and the handler reads and validates the oracle with the same checks, accrues funding, folds the elapsed interval in and records the price, with no band check. After a genuine move takes the oracle outside the band, keepers call it repeatedly as time passes: the first call records the new price, and each later call credits the time since the previous one to it, until the average is close enough to the price for trading to resume. + +`max_price_deviation_bps` is fixed by `initialize_pool`, which refuses zero (every move would be refused) and 10,000 or more (a fall could never be refused, since prices are positive) with `InvalidPriceDeviation`. + ### 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 program. 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; `insurance_fee_bps` of each goes to the insurance fund and the rest accrues to the program. 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. --- @@ -74,7 +118,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; onchain 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; onchain they are base units (× 10⁶). The pool is configured with a 10% initial margin (10× leverage), 0.1% open/close fees with half of each paid into the insurance fund, a 5% maintenance margin, a 1% liquidation fee, a 1% maximum oracle confidence band, a 20% price band around its average price, and a 10-slot profit warm-up. --- @@ -82,9 +126,11 @@ Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). T **Instruction:** `initialize_pool(parameters)` +The handler validates the parameters, then reads the oracle once to seed the pool's average price at $100. + **Accounts created:** -- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, liquidity, reserved liquidity, collateral total, per-side open-interest accumulators, funding index, program fees. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA +- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, liquidity, collateral total, program fees, insurance fund, per-side open-interest accumulators, funding index, average oracle price. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA - `custody_vault` [token account](https://solana.com/docs/terminology#token-account) PDA, seeds `["vault", pool]`: all USDC, both provider liquidity and trader collateral; `pool` is its owner - `lp_mint` PDA, seeds `["lp_mint", pool]`: the share [mint](https://solana.com/docs/terminology#mint-account); `pool` is the mint authority @@ -109,16 +155,16 @@ The pool can now pay trader winnings, and Carol holds shares representing her sl **Instruction:** `open_position(side = Long, collateral_amount = 1,000 USDC, size = 5,000 USDC, acceptable_price)` -NVDAx is at $100. The 0.1% open fee ($5) comes out of her collateral, leaving $995 of net collateral backing the position. +NVDAx is at $100. The 0.1% open fee ($5) comes out of her collateral, leaving $995 of net collateral backing the position. Nothing is set aside from `Pool.liquidity` for her profit. **Accounts modified:** -- `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 (the current slot) - `alice_usdc`: −1,000 USDC - `custody_vault`: +1,000 USDC - `Pool.total_collateral`: +$995 -- `Pool.program_fees`: +$5 -- `Pool.reserved_liquidity`: +$5,000 (must stay ≤ liquidity) +- `Pool.program_fees`: +$2.50 +- `Pool.insurance_fund`: +$2.50 - `Pool` long open-interest accumulators: += this position --- @@ -127,7 +173,7 @@ NVDAx is at $100. The 0.1% open fee ($5) comes out of her collateral, leaving $9 **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.program_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.program_fees` +$2.50; `Pool.insurance_fund` +$2.50; 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. @@ -137,14 +183,16 @@ 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. +More than 10 slots have passed since she opened, so her profit has warmed up, and $116 is 16% above the pool's $100 average price, inside the 20% band, so the close goes through. Her profit is `5,000 × (116 − 100) / 100 = $800`, minus the $5 close fee. Bob's short is down the same $800, so traders are owed nothing in aggregate and the haircut `h` is one: she is paid her profit in full. **Accounts modified:** - `Pool.liquidity`: −$800 (providers pay her profit) -- `Pool.reserved_liquidity`: −$5,000 (reserve released) - `Pool.total_collateral`: −$995 -- `Pool.program_fees`: +$5 +- `Pool.program_fees`: +$2.50 +- `Pool.insurance_fund`: +$2.50 +- `Pool.average_price`: credits the time since the last read to $100, the price that read saw, so it stays at $100 +- `Pool.last_oracle_price`: $100 → $116, which the next read credits for the time in between - long open-interest accumulators: −= this position - `custody_vault` → `alice_usdc`: pays out $1,790 (net collateral + profit − close fee) - `Position` (Alice): closed; rent returned to Alice @@ -160,20 +208,21 @@ At $116 Bob's short has lost $800; his equity ($995 − $800 = $195) has fallen **Accounts modified:** - 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's equity was still positive, so the $50 fee came out of it and the insurance fund was untouched. Had the price gone far enough to take his equity below zero, Dave would have received nothing, the position would still have closed, and the insurance fund would have paid Bob's loss beyond his collateral before the providers bore any of it. + --- ### Step 7: Admin collects the program's fees **Instruction:** `collect_fees()` -**Accounts modified:** `Pool.program_fees` → 0; `custody_vault` pays that amount to `admin_usdc`. +**Accounts modified:** `Pool.program_fees`: $7.50 → 0; `custody_vault` pays $7.50 to `admin_usdc`. The $7.50 in `Pool.insurance_fund` stays in the vault. --- @@ -181,7 +230,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. A withdrawal can pay out at most `Pool.liquidity`, the tokens the providers own now; an open trader's unrealized loss counts toward her shares' value but is still in that trader's collateral. **Accounts modified:** `lp_mint` burns Carol's shares; `Pool.liquidity` falls; `custody_vault` pays out USDC to `carol_usdc`. @@ -191,11 +240,11 @@ Carol burns her shares and redeems USDC. Her balance now reflects the fees the p 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, Kani-checked 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: -- **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. +- **Realizable credit**: "protected principal is senior, positive PnL is junior, and source-domain positive credit cannot exceed realizable backing reserved for that domain." Here, trader collateral is senior and trader profit is junior: `close_position` pays a winner the haircut fraction `h` of their profit, so payouts never exceed `liquidity + insurance_fund`, and a profit is paid only after the position's warm-up. - **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. -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`) add that this example still leaves out: multi-asset custody with reserves in the payout token, utilization-based borrow fees, and valuing positions at the oracle's EMA rather than its spot price. This example keeps its own average only to decide when to refuse trading, and values positions at the spot price. --- @@ -204,15 +253,27 @@ 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. - 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, pre-restart-price, and wide-confidence rejection, funding accrual, funding-rate retuning (including that it settles elapsed seconds at the old rate, and that only the authority may call it), funding that follows seconds rather than slots, 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, and share inflation through a provider's own trades +- opening and closing longs and shorts in profit and loss +- the initial margin on both sides of its boundary, and slippage rejection +- stale-price, pre-restart-price, and wide-confidence rejection +- funding accrual, the funding-rate maximum, an operator's wallet on the lighter side earning only the fixed rate, and funding that follows seconds rather than slots +- the price band: opens, closes, deposits and withdrawals refused when the oracle jumps outside it (`test_open_rejected_when_oracle_jumps_outside_band`, `test_close_rejected_when_oracle_jumps_outside_band`, `test_liquidity_changes_rejected_when_oracle_jumps_outside_band`), liquidation running outside it (`test_liquidation_runs_outside_band`), the exact average after each `update_price_average` (`test_single_update_moves_average_by_elapsed_fraction`), repeated updates walking the average to a genuine move until trading resumes (`test_price_average_catches_up_after_genuine_move`), and one manipulated read after an idle window leaving the average where it was (`test_one_manipulated_read_after_idle_does_not_move_average`) +- liquidation, and the refusal to liquidate a healthy position +- the haircut: a position opening without full backing (`test_open_allowed_without_full_backing`), profit paid in full while the pool backs it (`test_profit_runs_uncapped_when_backed`), two winners each paid exactly half when the pool is stressed (`test_haircut_scales_profit_when_pool_stressed`), the insurance fund paying a profit beyond `liquidity` (`test_insurance_pays_profit_beyond_liquidity`), and a winner offset by an open loser paid the pool's whole backing rather than refused (`test_winner_offset_by_open_loser_is_paid_not_refused`) +- the profit warm-up on both sides of its boundary (`test_profit_blocked_before_maturation`, `test_profit_realized_after_maturation`), and a loss closing in the slot it opened (`test_loss_not_gated_by_maturation`) +- the insurance fund: its exact share of each fee (`test_insurance_fund_funded_by_fees`), a bankrupt position's deficit paid by the fund (`test_insurance_absorbs_bankruptcy_deficit`), and a bankrupt position liquidated for no fee with the fund paying before the providers (`test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity`) +- withdrawals capped at `liquidity` while traders are down (`test_remove_liquidity_capped_at_liquidity`) +- `initialize_pool`'s parameter checks, including an initial margin at or below the maintenance margin, a price band outside its range, and an insurance fee of 10,000 basis points or more +- fee collection ```bash anchor build diff --git a/finance/perpetual-futures/anchor/TERMINOLOGY.md b/finance/perpetual-futures/anchor/TERMINOLOGY.md index 26c7bd5e..0915f75b 100644 --- a/finance/perpetual-futures/anchor/TERMINOLOGY.md +++ b/finance/perpetual-futures/anchor/TERMINOLOGY.md @@ -2,36 +2,65 @@ Terms used in this example, in the sense they carry here. -- **Perpetual future (perp)** — a leveraged derivative position with no expiry +- **Perpetual future (perp)**: a leveraged derivative position with no expiry and no settlement date. Profit and loss is paid in the collateral token as the oracle price moves. -- **Long / short** — a long profits when the price rises, a short when it falls. +- **Long / short**: a long profits when the price rises, a short when it falls. Each is the opposite side of the pool's exposure. -- **Collateral** — the token a trader posts to back a position, and the token +- **Collateral**: the token a trader posts to back a position, and the token liquidity providers deposit. One pool uses one collateral token. -- **Notional size** — the position's exposure in collateral units. Profit and +- **Notional size**: the position's exposure in collateral units. Profit and loss scales with the notional, not with the collateral posted. -- **Leverage** — notional size divided by collateral. A pool caps it at - `max_leverage`. -- **Equity** — a position's current worth: net collateral plus unrealized profit +- **Leverage**: notional size divided by collateral. A pool caps it through its + initial margin: 1,000 basis points (10%) allows at most 10x. +- **Initial margin**: the net collateral, as a fraction of notional size, a + position must post to open (`initial_margin_bps`). Always above the + maintenance margin, so no position opens already liquidatable. +- **Equity**: a position's current worth: net collateral plus unrealized profit and loss, minus accrued funding. When equity falls to the maintenance margin, the position is liquidatable. -- **Maintenance margin** — the minimum equity, as a fraction of notional size, +- **Maintenance margin**: the minimum equity, as a fraction of notional size, 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. -- **Funding** — a periodic payment that anchors the pool's risk. The heavier +- **Liquidation**: closing an under-margined position. Permissionless here: any + caller can trigger it and earns the liquidation fee out of the position's + remaining equity. The part of the fee the equity cannot cover is forgiven. +- **Deficit**: what a liquidated position lost beyond its collateral, when its + equity is below zero. The insurance fund pays it first, and the liquidity + providers bear what the fund cannot. +- **Insurance fund**: the tokens the pool holds, in `insurance_fund`, from + `insurance_fee_bps` of every open and close fee. It pays deficits, pays a + winner's profit once `liquidity` is exhausted, and never pays a fee. +- **Senior / junior**: a trader's collateral is senior, always theirs to + reclaim less their losses. Their profit is junior: paid only as far as the + pool's liquidity and insurance fund can back it. +- **Haircut ratio (`h`)**: the fraction of their profit every winner closing at + a given moment is paid: one while `liquidity + insurance_fund` covers the + profit owed, and that backing divided by the profit owed when it does not. + The profit owed is the larger of traders' aggregate profit and the closing + position's own, so a winner who closes while open losers still offset them + is paid at most the backing, and never refused. +- **Profit warm-up**: the `profit_warmup_slots` a position must stay open before + it can be closed at a profit. A loss is never held back. +- **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. -- **Liquidity provider** — a depositor who funds the pool and is the counterparty +- **Open interest**: the total notional size currently open on a side. +- **Liquidity provider**: a depositor who funds the pool and is the counterparty to every trade, earning fees in exchange for taking the other side of trader profit and loss. -- **Assets-under-management** — the marked value of liquidity-provider holdings: +- **Assets-under-management**: the marked value of liquidity-provider holdings: pool liquidity minus the aggregate unrealized profit traders are owed. -- **Liquidity-provider share** — a token representing a pro-rata claim on +- **Liquidity-provider share**: a token representing a pro-rata claim on assets-under-management. -- **Oracle feed** — the account the pool reads its price from. This example uses +- **Oracle feed**: the account the pool reads its price from. This example uses a mock oracle price feed; production points at a real one, such as a Pyth price feed. -- **Mark price** — the price positions are valued at. Here it is the oracle +- **Mark price**: the price positions are valued at. Here it is the oracle price directly, with no separate mark/index distinction. +- **Average price**: the pool's time-weighted moving average of the oracle + price (`average_price`), which follows the last ten minutes of prices. Each + oracle read credits the seconds since the previous read to the price that + read saw (`last_oracle_price`), so a price counts only from the read that + first sees it. Positions are never valued at it. +- **Price band**: the range around the average price, `max_price_deviation_bps` + wide on each side, outside which the pool refuses to open or close positions + or move liquidity. Liquidation is not refused. 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 07bedc4f..8634e04c 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 fraction of their +/// profit every closing winner is paid. `HAIRCUT_PRECISION` is `h = 1` (profit +/// paid in full); a smaller value pays that fraction of it. A winner's profit is +/// multiplied by `h` and divided by this, rounding down, so rounding never pays +/// a winner more than that fraction. +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, and both `add_liquidity` and @@ -33,10 +40,18 @@ pub const MINIMUM_LIQUIDITY: u64 = 1_000; /// lowers over time, so the window tightens on its own and never loosens. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; -/// Upper bound on the per-pool `max_leverage` parameter, so a pool cannot be -/// configured with an absurd leverage that makes every position instantly -/// liquidatable on the smallest price move. -pub const MAX_LEVERAGE_CEILING: u16 = 100; +/// How many seconds of oracle prices the pool's `average_price` follows. Each +/// fold moves the average toward the price seen at the previous read by +/// `elapsed / window` of the gap between them, and an interval of a full window +/// or more replaces the average with that price. Ten minutes is long enough +/// that a price seen at two reads six seconds apart, about as long as a faulty +/// or manipulated oracle print lasts, moves the average by one percent of its +/// jump, and short enough that a genuine move is back inside the band within +/// minutes of repeated reads. Counted on the Clock's `unix_timestamp`, +/// like funding: it is a span of wall-clock time, and the second or two of +/// leader drift changes a fold's weight by well under one percent. +#[constant] +pub const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; /// Upper bound on the per-pool `funding_rate_per_second` parameter, in /// `FUNDING_PRECISION` units: 277 billionths of a position's size per second, 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 3f33f0c2..ffb3e453 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs @@ -14,8 +14,8 @@ pub enum PerpError { #[msg("Arithmetic overflow")] MathOverflow, - #[msg("Requested leverage exceeds the pool maximum")] - LeverageTooHigh, + #[msg("Position is too large for its collateral: net collateral is below the pool's initial margin")] + InitialMarginNotMet, #[msg("Pool parameter is outside the allowed range")] InvalidParameter, @@ -38,7 +38,7 @@ pub enum PerpError { #[msg("Fill price is worse than the caller's acceptable price")] SlippageExceeded, - #[msg("Pool does not have enough free liquidity to satisfy this request")] + #[msg("Withdrawal is larger than the pool's liquidity: part of the shares' value is still in open positions")] InsufficientLiquidity, #[msg("Posted collateral does not cover the open fee")] @@ -58,4 +58,18 @@ pub enum PerpError { #[msg("Oracle price is stale: it predates the last cluster restart")] PricePredatesRestart, + + #[msg("Initial margin is at or below the maintenance margin: positions could open already liquidatable")] + InitialMarginNotAboveMaintenance, + + #[msg("Maximum price deviation is outside the allowed range: it must be above zero and below 10,000 basis points")] + InvalidPriceDeviation, + + #[msg("Oracle price is too far from the pool's average price: trading pauses until the average catches up")] + PriceOutsideBand, + + #[msg( + "Profit cannot be taken yet: the position has not been open for the pool's profit warm-up" + )] + ProfitNotMatured, } diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs index 7cbd26be..95955d80 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs @@ -8,7 +8,7 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, 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, refresh_price_and_funding_within_band}; use crate::state::Pool; pub fn handle_add_liquidity( @@ -19,7 +19,7 @@ pub fn handle_add_liquidity( require!(amount > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; let lp_supply = context.accounts.lp_mint.supply(); let shares: u64 = if lp_supply == 0 && pool.liquidity == 0 { 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 bbfebd9b..47ac2a43 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::{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, credit_fee, haircut_ratio, position_pnl, + refresh_price_and_funding_within_band, settle_position, +}; use crate::state::{Pool, Position}; pub fn handle_close_position( @@ -14,17 +17,38 @@ pub fn handle_close_position( minimum_payout: u64, ) -> Result<()> { let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; - + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; + + // The haircut is computed while this position is still in the per-side + // accumulators, so its own profit counts toward the liability and it is + // paid the same fraction as any other winner closing at this price. Its + // own profit is passed too: if open losers offset it in the aggregate, + // the haircut is sized against that profit, so the payout is at most the + // backing and the close is never refused for lack of it. let position = &context.accounts.position; + let closing_profit = position_pnl(position.side, position.size, position.entry_price, price)?; + let haircut = haircut_ratio(pool, price, closing_profit)?; + 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); + // A profit is paid only once the position has been open for the pool's + // warm-up, and then only the haircut fraction of it. A loss settles in + // full, at any time. + let realized_pnl = if settlement.profit_and_loss > 0 { + let matured_at = entry_slot + .checked_add(pool.profit_warmup_slots) + .ok_or(PerpError::MathOverflow)?; + require!( + Clock::get()?.slot >= matured_at, + 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 +66,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)?; - - // Liquidity providers are the counterparty: they pay the trader's (capped) - // profit and receive their loss, and collect the funding the trader owed. + // 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 profit the haircut withholds stays in + // `liquidity`. A payment larger than `liquidity` takes the rest from the + // insurance fund, which the haircut counted as backing. The haircut keeps + // the profit within both; `PoolInsolvent` remains as a defensive check. let liquidity_delta = settlement .funding .checked_sub(realized_pnl) @@ -57,14 +79,22 @@ pub fn handle_close_position( let new_liquidity = (pool.liquidity as i128) .checked_add(liquidity_delta) .ok_or(PerpError::MathOverflow)?; - require!(new_liquidity >= 0, PerpError::PoolInsolvent); - pool.liquidity = new_liquidity - .try_into() - .map_err(|_| PerpError::MathOverflow)?; - pool.program_fees = pool - .program_fees - .checked_add(close_fee) - .ok_or(PerpError::MathOverflow)?; + if new_liquidity < 0 { + let shortfall: u64 = new_liquidity + .unsigned_abs() + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + pool.insurance_fund = pool + .insurance_fund + .checked_sub(shortfall) + .ok_or(PerpError::PoolInsolvent)?; + pool.liquidity = 0; + } else { + pool.liquidity = new_liquidity + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + } + credit_fee(pool, close_fee)?; // The pool signs the CPI below with its own seeds. Copy them out first: a // data account holds a live borrow on its buffer, which the runtime 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 df3660d3..02fd0736 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 @@ -7,10 +7,10 @@ use anchor_spl::{ }; use crate::constants::{ - BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, MAX_LEVERAGE_CEILING, - POOL_SEED, VAULT_SEED, + BASIS_POINTS_DENOMINATOR, LP_MINT_SEED, MAX_FUNDING_RATE_PER_SECOND, POOL_SEED, VAULT_SEED, }; use crate::errors::PerpError; +use crate::state::oracle::read_oracle_price; use crate::state::Pool; /// Trading parameters set once at pool creation. None of them can be changed @@ -27,12 +27,29 @@ pub struct PoolParameters { pub open_fee_bps: u16, pub close_fee_bps: u16, - pub max_leverage: u16, + + /// Net collateral a position must post to open, in basis points of its + /// notional size. Must be above `maintenance_margin_bps` and at most + /// 10_000 (no leverage). + pub initial_margin_bps: u16, + pub maintenance_margin_bps: u16, pub liquidation_fee_bps: u16, /// Maximum oracle confidence band tolerated, in basis points of the price. pub max_confidence_bps: u16, + + /// Widest gap, in basis points of the pool's average price, between the + /// oracle price and that average at which positions may still open or + /// close and liquidity may still move. + pub max_price_deviation_bps: u16, + + /// Fraction of each open and close fee, in basis points, paid into the + /// insurance fund; the rest goes to program fees. Must be below 10_000. + pub insurance_fee_bps: u16, + + /// Slots a position must stay open before it can be closed at a profit. + pub profit_warmup_slots: u64, } pub fn handle_initialize_pool( @@ -40,10 +57,6 @@ pub fn handle_initialize_pool( parameters: PoolParameters, ) -> Result<()> { let denominator = BASIS_POINTS_DENOMINATOR as u16; - require!( - parameters.max_leverage >= 1 && parameters.max_leverage <= MAX_LEVERAGE_CEILING, - PerpError::InvalidParameter - ); // The rate never changes after this, so bounding it here bounds it for the // life of the pool. require!( @@ -77,12 +90,46 @@ pub fn handle_initialize_pool( parameters.maintenance_margin_bps > parameters.close_fee_bps, PerpError::InvalidParameter ); + // A position must open with more margin than it is liquidated at, or it + // could be liquidated in the same slot it opened. At most 100% of + // notional: more than that would demand collateral above the position's + // size. + require!( + parameters.initial_margin_bps > parameters.maintenance_margin_bps, + PerpError::InitialMarginNotAboveMaintenance + ); + require!( + parameters.initial_margin_bps <= denominator, + PerpError::InvalidParameter + ); // Zero would reject every real feed (which always reports some uncertainty); // above 100% is meaningless. Anything in between is a valid risk choice. require!( parameters.max_confidence_bps > 0 && parameters.max_confidence_bps < denominator, PerpError::InvalidParameter ); + // At 10_000 every fee would go to the insurance fund and none to the + // program. + require!( + parameters.insurance_fee_bps < denominator, + PerpError::InvalidParameter + ); + // Zero would refuse every price move, however small. At 100% or more the + // band could never refuse a fall, since the oracle price is always + // positive. + require!( + parameters.max_price_deviation_bps > 0 && parameters.max_price_deviation_bps < denominator, + PerpError::InvalidPriceDeviation + ); + + // Seed the average with a validated oracle price, so the band is in force + // from the first trade. + let initial_price = read_oracle_price( + &context.accounts.oracle_feed, + parameters.oracle_scale, + parameters.max_confidence_bps, + )?; + let current_timestamp = Clock::get()?.unix_timestamp; let pool = &mut context.accounts.pool; pool.authority = *context.accounts.authority.address(); @@ -92,22 +139,28 @@ pub fn handle_initialize_pool( pool.custody_vault = *context.accounts.custody_vault.address(); pool.lp_mint = *context.accounts.lp_mint.address(); pool.liquidity = 0; - pool.reserved_liquidity = 0; pool.total_collateral = 0; pool.program_fees = 0; + pool.insurance_fund = 0; pool.long_size = 0; pool.short_size = 0; pool.long_size_scaled = 0; pool.short_size_scaled = 0; pool.cumulative_funding = 0; - pool.last_funding_timestamp = Clock::get()?.unix_timestamp; + pool.last_funding_timestamp = current_timestamp; + pool.average_price = initial_price; + pool.last_oracle_price = initial_price; + pool.average_price_timestamp = current_timestamp; pool.funding_rate_per_second = parameters.funding_rate_per_second; pool.open_fee_bps = parameters.open_fee_bps; pool.close_fee_bps = parameters.close_fee_bps; - pool.max_leverage = parameters.max_leverage; + pool.initial_margin_bps = parameters.initial_margin_bps; 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.max_price_deviation_bps = parameters.max_price_deviation_bps; + pool.insurance_fee_bps = parameters.insurance_fee_bps; + pool.profit_warmup_slots = parameters.profit_warmup_slots; pool.bump = context.bumps.pool; Ok(()) @@ -130,8 +183,9 @@ pub struct InitializePoolAccountConstraints { pub collateral_mint: Box>, /// CHECK: The oracle feed account. Its key is stored on the pool and every - /// read validates the layout, scale, and freshness; it is never trusted by - /// type. Swap for a real Pyth price feed in production. + /// read, including the one here that seeds the average price, validates + /// the layout, scale, and freshness; it is never trusted by type. Swap for + /// a real Pyth price feed in production. pub oracle_feed: UncheckedAccount, /// Liquidity-provider share mint. The pool account is its mint 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 bd172de2..c7a07670 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)?; @@ -33,24 +28,44 @@ pub fn handle_liquidate_position( PerpError::PositionHealthy ); - // The liquidator's reward comes out of whatever equity remains, capped so a - // position already past zero equity cannot pay out more than it has. + // The liquidator's reward comes out of whatever equity remains. Whatever + // part of the fee the equity cannot cover is forgiven: neither the + // insurance fund nor the liquidity providers pay it. let remaining_equity: u64 = settlement .equity .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 whose equity is below zero lost more than its collateral. The + // insurance fund pays that deficit as far as it can, and the liquidity + // providers bear only the rest. + let deficit: u64 = settlement + .equity + .min(0) + .unsigned_abs() + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + let insurance_payment = deficit.min(pool.insurance_fund); + pool.insurance_fund = pool + .insurance_fund + .checked_sub(insurance_payment) + .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) + // collateral minus whatever is paid out as equity, and the insurance + // fund's payment toward the deficit moves, inside the vault, from + // `insurance_fund` to `liquidity`. + let liquidity_delta = (position_collateral as i128) .checked_sub(remaining_equity as i128) + .ok_or(PerpError::MathOverflow)? + .checked_add(insurance_payment 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/mod.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs index 88337e1d..33c621de 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs @@ -6,6 +6,7 @@ pub mod liquidate_position; pub mod open_position; pub mod remove_liquidity; pub mod shared; +pub mod update_price_average; pub use add_liquidity::*; pub use close_position::*; @@ -14,3 +15,4 @@ pub use initialize_pool::*; pub use liquidate_position::*; pub use open_position::*; pub use remove_liquidity::*; +pub use update_price_average::*; 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 e820f032..6c97c88a 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 @@ -4,9 +4,11 @@ use anchor_spl::{ token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, }; -use crate::constants::{POOL_SEED, POSITION_SEED, VAULT_SEED}; +use crate::constants::{BASIS_POINTS_DENOMINATOR, 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, credit_fee, refresh_price_and_funding_within_band, scale_size, +}; use crate::state::{Pool, Position, Side}; pub fn handle_open_position( @@ -19,7 +21,7 @@ pub fn handle_open_position( require!(collateral_amount > 0 && size > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; // Slippage: a long must not fill above the caller's limit, a short not // below it. `0` opts out. @@ -32,35 +34,32 @@ pub fn handle_open_position( } // The open fee is taken out of the posted collateral; the rest backs the - // position. Leverage and margin are measured against this net collateral. + // position, and the initial margin is measured against this net collateral. let open_fee = basis_points_of(size, pool.open_fee_bps)?; let net_collateral = collateral_amount .checked_sub(open_fee) .ok_or(PerpError::InsufficientCollateral)?; require!(net_collateral > 0, PerpError::ZeroAmount); - let max_notional = (net_collateral as u128) - .checked_mul(pool.max_leverage as u128) + // Initial margin: net collateral must be at least `initial_margin_bps` of + // the notional size, compared as `net_collateral * 10_000 >= size * bps` + // so nothing is rounded. `initialize_pool` keeps the initial margin above + // the maintenance margin, so a position that passes this check opens with + // equity above the liquidation threshold. + let collateral_scaled = (net_collateral as u128) + .checked_mul(BASIS_POINTS_DENOMINATOR as u128) .ok_or(PerpError::MathOverflow)?; - require!(size as u128 <= max_notional, PerpError::LeverageTooHigh); - - // Refuse a position that would open already inside the liquidation band. - 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) + let required_scaled = (size as u128) + .checked_mul(pool.initial_margin_bps as u128) .ok_or(PerpError::MathOverflow)?; require!( - new_reserved <= pool.liquidity, - PerpError::InsufficientLiquidity + collateral_scaled >= required_scaled, + PerpError::InitialMarginNotMet ); - pool.reserved_liquidity = new_reserved; + // Nothing is set aside to back this position's profit, and the pool's + // liquidity does not limit its size: `close_position` pays each winner the + // fraction of their profit the pool can back (see `haircut_ratio`). let size_scaled = scale_size(size, price)?; // Effects: record the position and the pool's new aggregates before moving @@ -74,16 +73,14 @@ 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 .total_collateral .checked_add(net_collateral) .ok_or(PerpError::MathOverflow)?; - pool.program_fees = pool - .program_fees - .checked_add(open_fee) - .ok_or(PerpError::MathOverflow)?; + credit_fee(pool, open_fee)?; match side { Side::Long => { 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 9d3227ca..68c74a46 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,7 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, 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, refresh_price_and_funding_within_band}; use crate::state::Pool; pub fn handle_remove_liquidity( @@ -19,7 +19,7 @@ pub fn handle_remove_liquidity( require!(shares > 0, PerpError::ZeroAmount); let pool = &mut context.accounts.pool; - let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?; + let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; let lp_supply = context.accounts.lp_mint.supply(); let aum = liquidity_provider_aum(pool, price)?; @@ -40,15 +40,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)?; + // Shares are priced against assets-under-management, which counts traders' + // unrealized losses as the providers' gain. Those losses are still in the + // traders' collateral until their positions close, so a withdrawal is + // capped at `liquidity`, the tokens the providers own now. While traders + // are up instead, the pricing already keeps a withdrawal below `liquidity` + // minus their profit, leaving that profit's backing in the pool. require!( - amount_out <= free_liquidity, + amount_out <= pool.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 4e47e323..23bb78de 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,9 @@ use anchor_lang::prelude::*; -use crate::constants::{BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, SIZE_PRECISION}; +use crate::constants::{ + BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, HAIRCUT_PRECISION, PRICE_AVERAGE_WINDOW_SECONDS, + SIZE_PRECISION, +}; use crate::errors::PerpError; use crate::state::{Pool, Position, Side}; @@ -139,9 +142,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. +/// Profit is marked in full, before any haircut: while `haircut_ratio` is +/// below one, winners will be paid less than this, so assets-under-management +/// reads low by the withheld part until they close. pub fn traders_unrealized_pnl(pool: &Pool, price: u64) -> Result { let price = price as i128; let size_precision = SIZE_PRECISION as i128; @@ -180,6 +183,86 @@ pub fn liquidity_provider_aum(pool: &Pool, price: u64) -> Result { .ok_or(PerpError::MathOverflow.into()) } +/// The haircut ratio `h` at `price`, scaled by `HAIRCUT_PRECISION`: the +/// fraction of its profit a winning position is paid when it closes. +/// +/// `h = min(1, (liquidity + insurance_fund) / max(liability, closing_profit))` +/// +/// The liability is the traders' aggregate unrealized profit from the per-side +/// accumulators, floored at zero, so the caller computes `h` before the closing +/// position leaves them, and every winner closing at that moment is paid the +/// same fraction. While the backing covers it `h` is one. When a move leaves +/// traders owed more than the backing, `h` is the backing divided by the +/// liability, floored, and rises again as losing positions settle into +/// `liquidity`. +/// +/// Open losing positions offset winners in the aggregate, so one winner's +/// `closing_profit` can be larger than the liability. Dividing by the larger of +/// the two means a winner who closes while open losers still offset them is +/// paid at most the pool's backing, and is never refused; when the liability is +/// the larger, every other winner's fraction is unchanged. +pub fn haircut_ratio(pool: &Pool, price: u64, closing_profit: i128) -> Result { + let liability: u128 = traders_unrealized_pnl(pool, price)? + .max(closing_profit) + .max(0) + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + 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()) +} + +/// `profit * haircut / HAIRCUT_PRECISION`, rounded down: the part of a +/// winning position's profit the pool pays. `profit` is positive; a loss is +/// never haircut. +pub fn apply_haircut(profit: i128, haircut: u128) -> Result { + let profit: u128 = profit.try_into().map_err(|_| PerpError::MathOverflow)?; + profit + .checked_mul(haircut) + .ok_or(PerpError::MathOverflow)? + .checked_div(HAIRCUT_PRECISION) + .ok_or(PerpError::MathOverflow)? + .try_into() + .map_err(|_| PerpError::MathOverflow.into()) +} + +/// Split an open or close fee into `(insurance_cut, program_cut)`. The +/// insurance cut is `insurance_fee_bps` of the fee, rounded down, and the +/// program keeps the rest, so the two always add up to the whole fee. +pub fn split_fee(fee: u64, insurance_fee_bps: u16) -> Result<(u64, u64)> { + let insurance_cut = basis_points_of(fee, insurance_fee_bps)?; + let program_cut = fee + .checked_sub(insurance_cut) + .ok_or(PerpError::MathOverflow)?; + Ok((insurance_cut, program_cut)) +} + +/// Credit an open or close fee: `insurance_fee_bps` of it to the insurance +/// fund and the rest to program fees. +pub fn credit_fee(pool: &mut Pool, fee: u64) -> Result<()> { + let (insurance_cut, program_cut) = split_fee(fee, pool.insurance_fee_bps)?; + pool.insurance_fund = pool + .insurance_fund + .checked_add(insurance_cut) + .ok_or(PerpError::MathOverflow)?; + pool.program_fees = pool + .program_fees + .checked_add(program_cut) + .ok_or(PerpError::MathOverflow)?; + Ok(()) +} + /// 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( @@ -216,16 +299,102 @@ pub fn basis_points_of(amount: u64, basis_points: u16) -> Result { .map_err(|_| PerpError::MathOverflow.into()) } -/// The preamble every price-sensitive handler runs: read a validated oracle -/// price, then bring the pool's funding index up to the current time, so the -/// settlement that follows uses fresh numbers for both. Centralized so no -/// handler can settle a position against a stale funding index. +/// Fold the elapsed interval into the pool's `average_price`, then record +/// `price` as the latest observation. +/// +/// The interval since the last fold is credited to the price observed at +/// that fold, `last_oracle_price`, on the assumption that it held throughout: +/// +/// `average += (last_oracle_price - average) * min(elapsed, PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS` +/// +/// The price read now only starts counting from now, so it moves the average +/// only if it is still the oracle's price at a later read, weighted by the +/// seconds between the two reads; a read of a different price in between +/// replaces it. A pool left idle for a window or more therefore cannot have +/// its average set by one read. As with funding, a timestamp at or before the +/// stored one is treated as no time elapsed: the average and the stored stamp +/// stay where they are, and only `last_oracle_price` is updated. +pub fn fold_price_into_average(pool: &mut Pool, price: u64, current_timestamp: i64) -> Result<()> { + if current_timestamp <= pool.average_price_timestamp { + pool.last_oracle_price = price; + return Ok(()); + } + let elapsed = current_timestamp + .checked_sub(pool.average_price_timestamp) + .ok_or(PerpError::MathOverflow)?; + let weight = elapsed.min(PRICE_AVERAGE_WINDOW_SECONDS); + + let average = pool.average_price as i128; + // Multiply before dividing; the gap is signed, so the average moves down + // as readily as up. + let movement = (pool.last_oracle_price as i128) + .checked_sub(average) + .ok_or(PerpError::MathOverflow)? + .checked_mul(weight as i128) + .ok_or(PerpError::MathOverflow)? + .checked_div(PRICE_AVERAGE_WINDOW_SECONDS as i128) + .ok_or(PerpError::MathOverflow)?; + pool.average_price = average + .checked_add(movement) + .ok_or(PerpError::MathOverflow)? + .try_into() + .map_err(|_| PerpError::MathOverflow)?; + pool.last_oracle_price = price; + pool.average_price_timestamp = current_timestamp; + Ok(()) +} + +/// Refuse an oracle `price` more than `max_price_deviation_bps` away from the +/// pool's stored `average_price`: +/// `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. +pub fn require_price_within_band(pool: &Pool, price: u64) -> Result<()> { + let deviation_scaled = (price.abs_diff(pool.average_price) as u128) + .checked_mul(BASIS_POINTS_DENOMINATOR as u128) + .ok_or(PerpError::MathOverflow)?; + let band_scaled = (pool.average_price as u128) + .checked_mul(pool.max_price_deviation_bps as u128) + .ok_or(PerpError::MathOverflow)?; + require!(deviation_scaled <= band_scaled, PerpError::PriceOutsideBand); + Ok(()) +} + +/// The preamble `liquidate_position` and `update_price_average` run: read a +/// validated oracle price, bring the pool's funding index up to the current +/// time, and fold the interval since the previous read into the pool's average +/// (see `fold_price_into_average`), so the settlement that follows uses fresh +/// numbers. Centralized so no handler can settle a position +/// against a stale funding index. +/// +/// No band check: liquidation has to keep working through a genuine price +/// move, because that is when positions go underwater, and +/// `update_price_average` is how the average catches up with one. pub fn refresh_price_and_funding(pool: &mut Pool, oracle_feed: &AccountView) -> Result { - let price = crate::state::oracle::read_oracle_price( - oracle_feed, - pool.oracle_scale, - pool.max_confidence_bps, - )?; - accrue_funding(pool, Clock::get()?.unix_timestamp)?; + let price = read_pool_oracle_price(pool, oracle_feed)?; + apply_price_and_funding(pool, price)?; Ok(price) } + +/// The preamble for every handler that opens or closes a position or moves +/// liquidity: the same as `refresh_price_and_funding`, but first refuses a +/// price outside the band around the stored average, before anything is +/// folded in or the price is recorded. A single oracle print far from the +/// average therefore cannot open, close, deposit, or withdraw at that price. +pub fn refresh_price_and_funding_within_band( + pool: &mut Pool, + oracle_feed: &AccountView, +) -> Result { + let price = read_pool_oracle_price(pool, oracle_feed)?; + require_price_within_band(pool, price)?; + apply_price_and_funding(pool, price)?; + Ok(price) +} + +fn read_pool_oracle_price(pool: &Pool, oracle_feed: &AccountView) -> Result { + crate::state::oracle::read_oracle_price(oracle_feed, pool.oracle_scale, pool.max_confidence_bps) +} + +fn apply_price_and_funding(pool: &mut Pool, price: u64) -> Result<()> { + let current_timestamp = Clock::get()?.unix_timestamp; + accrue_funding(pool, current_timestamp)?; + fold_price_into_average(pool, price, current_timestamp) +} diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/update_price_average.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/update_price_average.rs new file mode 100644 index 00000000..9da9fa57 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/update_price_average.rs @@ -0,0 +1,30 @@ +use anchor_lang::prelude::*; + +use crate::constants::POOL_SEED; +use crate::instructions::shared::refresh_price_and_funding; +use crate::state::Pool; + +pub fn handle_update_price_average( + context: &mut Context, +) -> Result<()> { + refresh_price_and_funding(&mut context.accounts.pool, &context.accounts.oracle_feed)?; + Ok(()) +} + +#[derive(Accounts)] +pub struct UpdatePriceAverageAccountConstraints { + /// Anyone may update the average: the result depends only on the oracle + /// price and the clock, never on who calls. + pub caller: Signer, + + #[account( + mut, + seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], + bump = pool.bump, + )] + pub pool: Box>, + + /// CHECK: validated by the `address = pool.oracle_feed` constraint below. + #[account(address = pool.oracle_feed)] + pub oracle_feed: UncheckedAccount, +} diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs index 6329dc85..723f03a8 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs @@ -1,10 +1,11 @@ use anchor_lang::prelude::*; mod constants; -mod errors; mod last_restart; // Public so the LiteSVM integration tests can build instruction arguments -// (`PoolParameters`, `Side`) against the program's own types. +// (`PoolParameters`, `Side`) against the program's own types, and match +// failures against `PerpError` codes. +pub mod errors; pub mod instructions; pub mod state; @@ -78,6 +79,18 @@ pub mod perpetual_futures { instructions::handle_liquidate_position(context) } + /// Read the oracle, credit the seconds since the previous read to the + /// price that read saw, record the current price for the next read, and + /// accrue funding up to now. Permissionless: after a genuine price move + /// takes the oracle outside the pool's band, anyone can call this + /// repeatedly as time passes to walk the average toward the new price until + /// trading resumes. + pub fn update_price_average( + context: &mut Context, + ) -> Result<()> { + instructions::handle_update_price_average(context) + } + /// The pool operator sweeps the accumulated program fees from the vault. pub fn collect_fees(context: &mut Context) -> Result<()> { instructions::handle_collect_fees(context) 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 a8df9b5c..ed995536 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,22 +30,26 @@ 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 backs trader profit: when the two cannot cover the + /// profit traders are owed, every closing winner is paid the same fraction + /// of their profit (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, - /// Sum of every open position's posted collateral, held in the same vault. pub total_collateral: u64, /// Program fees accrued from open/close fees, awaiting `collect_fees`. pub program_fees: u64, + /// Funded by `insurance_fee_bps` of every open and close fee. It pays a + /// bankrupt position's deficit (its loss beyond its collateral) before + /// liquidity providers bear any of it, pays a winner's profit once + /// `liquidity` is exhausted, and counts alongside `liquidity` as backing in + /// the haircut. The vault holds `liquidity + total_collateral + + /// program_fees + insurance_fund`, plus any tokens sent to it directly. + pub insurance_fund: u64, + /// Aggregate long open interest (sum of position `size`), in collateral /// base units of notional. pub long_size: u128, @@ -71,6 +75,23 @@ pub struct Pool { /// the cluster's slot time. pub last_funding_timestamp: i64, + /// Time-weighted moving average of the oracle price, in the pool's + /// `oracle_scale` fixed point. Seeded with the oracle price when the pool is + /// created. Every handler that reads the oracle credits the seconds since + /// the previous read to `last_oracle_price`, the price that read saw. + /// Trading and liquidity handlers refuse an oracle price more than + /// `max_price_deviation_bps` away from it, so a sudden jump pauses them + /// until the average catches up. + pub average_price: u64, + + /// The oracle price at the most recent read, in `oracle_scale` fixed point. + /// The next read folds it into `average_price` for the seconds in between. + pub last_oracle_price: u64, + + /// The Clock's `unix_timestamp` of the most recent fold into + /// `average_price`. + pub average_price_timestamp: i64, + /// Funding accrued per second, in `FUNDING_PRECISION` units, applied to the /// heavier side. The funding paid by traders accrues to the pool. pub funding_rate_per_second: u64, @@ -80,11 +101,13 @@ pub struct Pool { pub close_fee_bps: u16, - /// Highest leverage a position may open at (`size <= collateral * max`). - pub max_leverage: u16, + /// Net collateral a position must post to open, in basis points of its + /// notional size: 1_000 allows at most 10x leverage. Always above + /// `maintenance_margin_bps`, so no position opens already liquidatable. + pub initial_margin_bps: u16, - /// Equity threshold, in basis points of notional, below which a position is - /// liquidatable. + /// Equity threshold, in basis points of notional, at or below which a + /// position is liquidatable. pub maintenance_margin_bps: u16, /// Reward paid to a liquidator, in basis points of the liquidated notional. @@ -94,6 +117,21 @@ pub struct Pool { /// pool will trade against. A wider band is rejected as untrustworthy. pub max_confidence_bps: u16, + /// Widest gap the pool trades across between the oracle price and + /// `average_price`, in basis points of `average_price`. + pub max_price_deviation_bps: u16, + + /// Fraction of each open and close fee, in basis points, paid into + /// `insurance_fund`; the rest goes to `program_fees`. + pub insurance_fee_bps: u16, + + /// Slots a position must stay open before `close_position` will pay it a + /// profit. Someone who pushes the oracle to a false price cannot open a + /// position and take its profit less than this many slots apart; by then the + /// price has had that long to correct. A losing position can close, and an + /// under-margined one be liquidated, at any time. + pub profit_warmup_slots: u64, + /// Bump of this account's own address. The pool owns the custody vault /// and is the LP mint's authority, so it signs vault transfers and /// mint/burn CPIs with `[POOL_SEED, collateral_mint, oracle_feed, bump]`; 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 1f55efe6..db6774cb 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 @@ -50,5 +50,9 @@ pub struct Position { /// Pool `cumulative_funding` at open. Funding owed is the change since. pub entry_funding: i128, + /// Slot the position opened in. `close_position` pays a profit only from + /// slot `entry_slot + pool.profit_warmup_slots` on. + 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 9cfc0584..6d7af33e 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 @@ -4,7 +4,11 @@ use { InstructionData, ToAccountMetas, }, anchor_v2_testing::{Keypair, LiteSVM, Signer}, - perpetual_futures::{instructions::initialize_pool::PoolParameters, state::Pool, state::Side}, + perpetual_futures::{ + errors::PerpError, + instructions::initialize_pool::PoolParameters, + state::{Pool, Position, Side}, + }, solana_kite::{ create_associated_token_account, create_token_mint, create_wallet, get_token_account_balance, mint_tokens_to_token_account, @@ -15,8 +19,16 @@ use { // Matches `MAX_FUNDING_RATE_PER_SECOND` in the program's constants: the // steepest funding rate `initialize_pool` accepts. const MAX_FUNDING_RATE_PER_SECOND: u64 = 277; +// Matches `PRICE_AVERAGE_WINDOW_SECONDS`: one fold after this many seconds +// replaces the pool's average price with the oracle price. +const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; // Ten years, in seconds. const TEN_YEARS: i64 = 315_360_000; +// The test market's profit warm-up: a position can be closed at a profit from +// this many slots after it opened. +const PROFIT_WARMUP_SLOTS: u64 = 10; +// Matches `HAIRCUT_PRECISION`: a haircut ratio of one. +const HAIRCUT_PRECISION: u64 = 1_000_000_000; // Collateral token has 6 decimals (like USDC), so one whole unit is 1_000_000 // base units. const ONE_USDC: u64 = 1_000_000; @@ -50,6 +62,40 @@ fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) } +/// The parameters every test market uses unless a test overrides one: 0.1% +/// open and close fees, half of each fee paid into the insurance fund, a 10% +/// initial margin (10x leverage), a 5% maintenance margin, a 1% liquidation +/// fee, a 1% maximum confidence band, a 20% price band around the pool's +/// average price, and a 10-slot profit warm-up. +fn default_parameters(funding_rate_per_second: u64) -> PoolParameters { + PoolParameters { + oracle_scale: ORACLE_SCALE, + funding_rate_per_second, + open_fee_bps: 10, + close_fee_bps: 10, + initial_margin_bps: 1_000, + maintenance_margin_bps: 500, + liquidation_fee_bps: 100, + max_confidence_bps: 100, + max_price_deviation_bps: 2_000, + insurance_fee_bps: 5_000, + profit_warmup_slots: PROFIT_WARMUP_SLOTS, + } +} + +/// Assert that `result` failed with the program's `expected` error. Anchor +/// reports a program error as `Custom(6000 + the variant's index)`. +fn assert_fails_with(result: Result, expected: PerpError) { + let code = expected as u32 + 6000; + let Err(error) = result else { + panic!("the transaction should have failed with error code {code}"); + }; + assert!( + error.contains(&format!("Custom({code})")), + "expected error code {code}, got: {error}" + ); +} + /// One deployed market plus the keys needed to drive it. struct Market { svm: LiteSVM, @@ -67,23 +113,14 @@ impl Market { /// funding rate. The admin is both the pool operator and the oracle feed /// authority. fn new(initial_price: i128, funding_rate_per_second: u64) -> Market { - let parameters = PoolParameters { - oracle_scale: ORACLE_SCALE, - funding_rate_per_second, - 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") + Market::try_new(initial_price, default_parameters(funding_rate_per_second)) + .expect("pool initialization should succeed") } /// Like `new`, but takes the full parameter set and surfaces an /// `initialize_pool` rejection instead of panicking, so tests can probe the /// parameter validation. - fn try_new(initial_price: i128, parameters: PoolParameters) -> Result { + fn try_new(initial_price: i128, parameters: PoolParameters) -> Result { let mut svm = anchor_v2_testing::svm(); svm.add_program( perpetual_futures::id(), @@ -165,7 +202,7 @@ impl Market { &[&admin], &admin.pubkey(), ) - .map_err(|_| ())?; + .map_err(|error| format!("{error:?}"))?; Ok(Market { svm, @@ -236,6 +273,45 @@ impl Market { self.svm.get_sysvar::().slot } + /// Let the profit warm-up pass, so a position opened in the current slot + /// can be closed at a profit. The caller republishes the price after. + fn pass_warmup(&mut self) { + let slot = self.current_slot(); + self.warp(slot + PROFIT_WARMUP_SLOTS); + } + + fn position_state(&self, owner: &Address, side: Side) -> Position { + let account = self + .svm + .get_account(&self.position_pda(owner, side)) + .unwrap(); + Position::try_deserialize(&mut account.data.as_slice()).unwrap() + } + + /// Assert the custody vault holds exactly what the pool's ledger says it + /// does: liquidity, open positions' collateral, program fees and the + /// insurance fund. + fn assert_vault_matches_ledger(&self) { + let pool = self.pool_state(); + assert_eq!( + get_token_account_balance(&self.svm, &self.custody_vault).unwrap(), + pool.liquidity + pool.total_collateral + pool.program_fees + pool.insurance_fund + ); + } + + /// A wallet with an empty collateral token account, to liquidate from. + fn liquidator(&mut self) -> (Keypair, Address) { + let liquidator = create_wallet(&mut self.svm, 100_000_000_000).unwrap(); + let liquidator_collateral = create_associated_token_account( + &mut self.svm, + &liquidator.pubkey(), + &self.collateral_mint, + &self.payer, + ) + .unwrap(); + (liquidator, liquidator_collateral) + } + /// Simulate a cluster restart at `slot`: prices stamped at or before it /// must be rejected until the publisher posts again. fn set_last_restart_slot(&mut self, slot: u64) { @@ -273,7 +349,7 @@ impl Market { provider_collateral: Address, amount: u64, minimum_shares_out: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let provider_lp = derive_ata(&provider.pubkey(), &self.lp_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -303,8 +379,7 @@ impl Market { &[provider], &provider.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn remove_liquidity( @@ -313,7 +388,7 @@ impl Market { provider_collateral: Address, shares: u64, minimum_amount_out: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let provider_lp = derive_ata(&provider.pubkey(), &self.lp_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -343,8 +418,7 @@ impl Market { &[provider], &provider.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn position_pda(&self, owner: &Address, side: Side) -> Address { @@ -367,7 +441,7 @@ impl Market { collateral_amount: u64, size: u64, acceptable_price: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let position = self.position_pda(&trader.pubkey(), side); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -398,8 +472,7 @@ impl Market { &[trader], &trader.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn close_position( @@ -408,7 +481,7 @@ impl Market { trader_collateral: Address, side: Side, minimum_payout: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let position = self.position_pda(&trader.pubkey(), side); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -433,8 +506,7 @@ impl Market { &[trader], &trader.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn liquidate( @@ -443,7 +515,7 @@ impl Market { owner: &Address, owner_collateral: Address, side: Side, - ) -> Result<(), ()> { + ) -> Result<(), String> { let position = self.position_pda(owner, side); let liquidator_collateral = derive_ata(&liquidator.pubkey(), &self.collateral_mint); let instruction = Instruction::new_with_bytes( @@ -471,11 +543,10 @@ impl Market { &[liquidator], &liquidator.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } - fn collect_fees(&mut self, authority: &Keypair) -> Result<(), ()> { + fn collect_fees(&mut self, authority: &Keypair) -> Result<(), String> { let authority_collateral = derive_ata(&authority.pubkey(), &self.collateral_mint); let instruction = Instruction::new_with_bytes( perpetual_futures::id(), @@ -498,8 +569,40 @@ impl Market { &[authority], &authority.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) + } + + fn update_price_average(&mut self, caller: &Keypair) -> Result<(), String> { + let instruction = Instruction::new_with_bytes( + perpetual_futures::id(), + &perpetual_futures::instruction::UpdatePriceAverage {}.data(), + perpetual_futures::accounts::UpdatePriceAverageAccountConstraints { + caller: caller.pubkey(), + pool: self.pool, + oracle_feed: self.feed, + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[caller], + &caller.pubkey(), + ) + .map_err(|error| format!("{error:?}")) + } + + /// Hold the oracle at `price` while the pool's average catches up with + /// it: one update records `price` as the latest observation, then a full + /// averaging window passes with the price republished so it is fresh, and + /// a second update credits that window to `price`. A price more than the + /// band away from the average cannot be traded at until this has run. + fn settle_average_at(&mut self, price: i128) { + let caller = self.payer.insecure_clone(); + self.update_price_average(&caller).unwrap(); + self.pass_seconds(PRICE_AVERAGE_WINDOW_SECONDS); + self.set_price(price); + self.update_price_average(&caller).unwrap(); } /// Deposit a large amount of liquidity so the pool can pay trader profits, @@ -521,7 +624,17 @@ fn test_initialize_pool() { assert_eq!(pool.collateral_mint, market.collateral_mint); assert_eq!(pool.oracle_feed, market.feed); assert_eq!(pool.oracle_scale, ORACLE_SCALE); - assert_eq!(pool.max_leverage, 10); + assert_eq!(pool.initial_margin_bps, 1_000); + assert_eq!(pool.max_price_deviation_bps, 2_000); + // The average starts at the oracle price the pool was created against. + assert_eq!(pool.average_price, dollars(100) as u64); + assert_eq!( + pool.average_price_timestamp, + market + .svm + .get_sysvar::() + .unix_timestamp + ); assert_eq!(pool.liquidity, 0); assert_eq!(pool.total_collateral, 0); @@ -640,8 +753,7 @@ fn test_add_and_remove_liquidity_round_trip() { /// funding they paid in. #[test] fn test_inflating_liquidity_through_own_trades_does_not_pay() { - // The steepest rate a pool may have, held for ten years. The position is - // tiny because a pool holding 1_001 can back only 1_001 of notional. + // The steepest rate a pool may have, held for ten years. let mut market = Market::new(dollars(100), MAX_FUNDING_RATE_PER_SECOND); let (attacker, attacker_collateral) = market.funded_trader(10_000 * ONE_USDC); @@ -654,9 +766,8 @@ fn test_inflating_liquidity_through_own_trades_does_not_pay() { 1 ); - // The pool holds 1_001, so it can back a position of up to 1_001 notional. - // Heavy collateral keeps the position far from liquidation while funding - // drains it into `liquidity`. + // A 1_000 long. Heavy collateral keeps the position far from liquidation + // while funding drains it into `liquidity`. market .open_position( &attacker, @@ -726,10 +837,17 @@ fn test_open_long_updates_pool() { let pool = market.pool_state(); assert_eq!(pool.long_size, size as u128); assert_eq!(pool.short_size, 0); - // Collateral minus the 0.1% open fee is now tracked as trader collateral. + // Collateral minus the 0.1% open fee is now tracked as trader collateral, + // and the fee is split evenly between the insurance fund and the program. let open_fee = size / 1_000; assert_eq!(pool.total_collateral, collateral - open_fee); - assert_eq!(pool.program_fees, open_fee); + assert_eq!(pool.insurance_fund, open_fee / 2); + assert_eq!(pool.program_fees, open_fee / 2); + // Nothing is set aside from liquidity for the position. + assert_eq!(pool.liquidity, 100_000 * ONE_USDC); + let position = market.position_state(&trader.pubkey(), Side::Long); + assert_eq!(position.entry_slot, market.current_slot()); + market.assert_vault_matches_ledger(); } #[test] @@ -744,7 +862,9 @@ fn test_close_long_in_profit() { .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) .unwrap(); - // Price rises 20%: a $5,000 long earns $1,000. + // Price rises 20%: a $5,000 long earns $1,000, paid once the warm-up has + // passed. + market.pass_warmup(); market.set_price(dollars(120)); market .close_position(&trader, trader_collateral, Side::Long, 0) @@ -804,6 +924,7 @@ fn test_close_short_in_profit() { .unwrap(); // Price falls 10%: a $5,000 short earns $500. + market.pass_warmup(); market.set_price(dollars(90)); market .close_position(&trader, trader_collateral, Side::Short, 0) @@ -847,17 +968,53 @@ fn test_open_rejects_zero_amounts() { } #[test] -fn test_open_rejects_excess_leverage() { +fn test_open_rejects_position_below_initial_margin() { let mut market = Market::default_market(); market.seed_liquidity(100_000 * ONE_USDC); - let collateral = 1_000 * ONE_USDC; - let (trader, trader_collateral) = market.funded_trader(collateral); + let (trader, trader_collateral) = market.funded_trader(2_000 * ONE_USDC); - // max_leverage is 10x; 11x must be rejected. - let size = 11_000 * ONE_USDC; - assert!(market - .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) - .is_err()); + // The initial margin is 10% of notional. 1,000 USDC of collateral less + // the 11 USDC open fee leaves 989 USDC, short of the 1,100 USDC an 11,000 + // USDC position needs. + assert_fails_with( + market.open_position( + &trader, + trader_collateral, + Side::Long, + 1_000 * ONE_USDC, + 11_000 * ONE_USDC, + 0, + ), + PerpError::InitialMarginNotMet, + ); + + // A 10,000 USDC position needs 1,000 USDC net of its 10 USDC open fee. + // One minor unit short of 1,010 USDC is refused, and exactly 1,010 USDC + // opens at 10x. + let size = 10_000 * ONE_USDC; + let exact_collateral = 1_010 * ONE_USDC; + assert_fails_with( + market.open_position( + &trader, + trader_collateral, + Side::Long, + exact_collateral - 1, + size, + 0, + ), + PerpError::InitialMarginNotMet, + ); + market + .open_position( + &trader, + trader_collateral, + Side::Long, + exact_collateral, + size, + 0, + ) + .unwrap(); + assert_eq!(market.pool_state().total_collateral, size / 10); } #[test] @@ -1056,18 +1213,18 @@ fn test_funding_follows_seconds_not_slots() { #[test] fn test_initialize_pool_rejects_funding_rate_above_the_maximum() { // The rate is fixed at creation, so this is the only place it is checked. - let parameters = |funding_rate_per_second| PoolParameters { - oracle_scale: ORACLE_SCALE, - funding_rate_per_second, - open_fee_bps: 10, - close_fee_bps: 10, - max_leverage: 10, - maintenance_margin_bps: 500, - liquidation_fee_bps: 100, - max_confidence_bps: 100, - }; - assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND + 1)).is_err()); - assert!(Market::try_new(dollars(100), parameters(MAX_FUNDING_RATE_PER_SECOND)).is_ok()); + assert_fails_with( + Market::try_new( + dollars(100), + default_parameters(MAX_FUNDING_RATE_PER_SECOND + 1), + ), + PerpError::InvalidParameter, + ); + assert!(Market::try_new( + dollars(100), + default_parameters(MAX_FUNDING_RATE_PER_SECOND) + ) + .is_ok()); } /// The pool operator trading against their own pool. The lighter side of open @@ -1265,26 +1422,28 @@ fn test_collect_fees_requires_authority() { assert!(market.collect_fees(&imposter).is_err()); } +/// Nothing is set aside to back a position's profit, so a position can open +/// against a pool that could not pay its full winnings: here a $10,000 long +/// against $6,000 of liquidity. #[test] -fn test_open_rejects_when_pool_cannot_back_it() { +fn test_open_allowed_without_full_backing() { 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 - .open_position( - &trader, - trader_collateral, - Side::Long, - 1_000 * ONE_USDC, - 5_000 * ONE_USDC, - 0 - ) - .is_err()); + 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(); + + let pool = market.pool_state(); + assert_eq!(pool.long_size, size as u128); + assert_eq!(pool.liquidity, 6_000 * ONE_USDC); + market.assert_vault_matches_ledger(); } #[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; @@ -1294,9 +1453,12 @@ 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, so the long's profit is twice its size. A move this + // large is far outside the price band, so the average has to catch up + // before the position can close, which also passes the warm-up. The + // $100,000 pool backs the whole $10,000 profit, so it is paid in full. market.set_price(dollars(300)); + market.settle_average_at(dollars(300)); market .close_position(&trader, trader_collateral, Side::Long, 0) .unwrap(); @@ -1304,15 +1466,218 @@ 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; + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + net_collateral + profit - close_fee + ); + assert_eq!(market.pool_state().liquidity, 100_000 * ONE_USDC - profit); + market.assert_vault_matches_ledger(); +} + +/// Two longs are owed $1,800 of profit between them, and the pool holds only +/// $900 to pay it with, so each is paid half of their profit: the first to +/// close is paid half of theirs, and the second, closing against what is left, +/// is paid half of theirs too. +#[test] +fn test_haircut_scales_profit_when_pool_stressed() { + // No fee goes to the insurance fund here, so the only backing is the $900 + // of liquidity and the first close adds nothing to it. + let mut market = Market::try_new( + dollars(100), + PoolParameters { + insurance_fee_bps: 0, + ..default_parameters(0) + }, + ) + .unwrap(); + market.seed_liquidity(900 * ONE_USDC); + + let first_collateral = 1_000 * ONE_USDC; + let first_size = 6_000 * ONE_USDC; + let (first, first_account) = market.funded_trader(first_collateral); + market + .open_position( + &first, + first_account, + Side::Long, + first_collateral, + first_size, + 0, + ) + .unwrap(); + let second_collateral = 800 * ONE_USDC; + let second_size = 4_000 * ONE_USDC; + let (second, second_account) = market.funded_trader(second_collateral); + market + .open_position( + &second, + second_account, + Side::Long, + second_collateral, + second_size, + 0, + ) + .unwrap(); + + // At $118 the first long is up $1,080 and the second $720: $1,800 owed + // against $900 of backing, so h = 900 / 1,800 = 0.5. + market.pass_warmup(); + market.set_price(dollars(118)); + let half = HAIRCUT_PRECISION / 2; + let first_profit = first_size * 18 / 100; + let second_profit = second_size * 18 / 100; + + market + .close_position(&first, first_account, Side::Long, 0) + .unwrap(); + let first_paid = first_profit * half / HAIRCUT_PRECISION; + assert_eq!(first_paid, 540 * ONE_USDC); + assert_eq!( + get_token_account_balance(&market.svm, &first_account).unwrap(), + first_collateral - first_size / 1_000 + first_paid - first_size / 1_000 + ); + // The $540 withheld from the first long stays with the providers. + assert_eq!(market.pool_state().liquidity, 360 * ONE_USDC); + + // The second long is now owed $720 against $360: h is still 0.5. + market + .close_position(&second, second_account, Side::Long, 0) + .unwrap(); + let second_paid = second_profit * half / HAIRCUT_PRECISION; + assert_eq!(second_paid, 360 * ONE_USDC); + assert_eq!( + get_token_account_balance(&market.svm, &second_account).unwrap(), + second_collateral - second_size / 1_000 + second_paid - second_size / 1_000 + ); + assert_eq!(market.pool_state().liquidity, 0); + market.assert_vault_matches_ledger(); +} + +/// Alice's long is up $1,000 while Bob's short, still open and healthy, is +/// down $900, so traders are owed only $100 in aggregate, and the pool's +/// backing is $300. Sized against the $100 alone the haircut would be one and +/// Alice's $1,000 would exceed the backing; it is sized against her $1,000 +/// instead, so she is paid exactly the $300 and the close goes through. Bob's +/// later close settles his loss into the pool in full. +#[test] +fn test_winner_offset_by_open_loser_is_paid_not_refused() { + let mut market = Market::default_market(); + // $290.50 of liquidity plus the $9.50 the two open fees put in the + // insurance fund is $300 of backing. + market.seed_liquidity(290_500_000); + + let alice_collateral = 1_100 * ONE_USDC; + let alice_size = 10_000 * ONE_USDC; + let (alice, alice_account) = market.funded_trader(alice_collateral); + market + .open_position( + &alice, + alice_account, + Side::Long, + alice_collateral, + alice_size, + 0, + ) + .unwrap(); + let bob_collateral = 2_000 * ONE_USDC; + let bob_size = 9_000 * ONE_USDC; + let (bob, bob_account) = market.funded_trader(bob_collateral); + market + .open_position(&bob, bob_account, Side::Short, bob_collateral, bob_size, 0) + .unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.liquidity + pool.insurance_fund, 300 * ONE_USDC); + + // At $110 Alice is up $1,000 and Bob down $900: h = 300 / 1,000 = 0.3. + market.pass_warmup(); + market.set_price(dollars(110)); + market + .close_position(&alice, alice_account, Side::Long, 0) + .unwrap(); + let alice_paid = 1_000 * ONE_USDC * (3 * HAIRCUT_PRECISION / 10) / HAIRCUT_PRECISION; + assert_eq!(alice_paid, 300 * ONE_USDC); + let alice_fee = alice_size / 1_000; + assert_eq!( + get_token_account_balance(&market.svm, &alice_account).unwrap(), + alice_collateral - alice_fee + alice_paid - alice_fee + ); + // The whole backing was paid out; the fund then took half of Alice's + // close fee. + let pool = market.pool_state(); + assert_eq!(pool.liquidity, 0); + assert_eq!(pool.insurance_fund, alice_fee / 2); + market.assert_vault_matches_ledger(); + + // Bob closes at the same price, losing $900 into the pool. + market + .close_position(&bob, bob_account, Side::Short, 0) + .unwrap(); + let bob_fee = bob_size / 1_000; + let bob_loss = 900 * ONE_USDC; + assert_eq!( + get_token_account_balance(&market.svm, &bob_account).unwrap(), + bob_collateral - bob_fee - bob_loss - bob_fee + ); + let pool = market.pool_state(); + assert_eq!(pool.liquidity, bob_loss); + assert_eq!(pool.insurance_fund, (alice_fee + bob_fee) / 2); + assert_eq!(pool.total_collateral, 0); + market.assert_vault_matches_ledger(); +} + +/// The haircut counts the insurance fund as backing, so a profit larger than +/// `liquidity` but within `liquidity + insurance_fund` is paid in full: the +/// pool's liquidity first, the insurance fund for the rest. +#[test] +fn test_insurance_pays_profit_beyond_liquidity() { + // A 5% open fee, half of which goes to the insurance fund. + let mut market = Market::try_new( + dollars(100), + PoolParameters { + open_fee_bps: 500, + ..default_parameters(0) + }, + ) + .unwrap(); + market.seed_liquidity(1_700 * ONE_USDC); + + // $500 open fee: $250 to the insurance fund, $1,100 of net collateral. + let collateral = 1_600 * 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(); + assert_eq!(market.pool_state().insurance_fund, 250 * ONE_USDC); + + // At $118 the long is up $1,800: more than the $1,700 of liquidity, within + // the $1,950 of liquidity plus insurance, so h = 1. + market.pass_warmup(); + market.set_price(dollars(118)); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + + let profit = 1_800 * ONE_USDC; + let close_fee = size / 1_000; assert_eq!( get_token_account_balance(&market.svm, &trader_collateral).unwrap(), - expected + 1_100 * ONE_USDC + profit - close_fee ); + let pool = market.pool_state(); + assert_eq!(pool.liquidity, 0); + // $100 of the profit came from the insurance fund, which then took half + // of the $10 close fee. + assert_eq!(pool.insurance_fund, 150 * ONE_USDC + close_fee / 2); + market.assert_vault_matches_ledger(); } +/// Shares are priced against assets-under-management, which counts a +/// trader's unrealized loss as the providers' gain, but that loss is still in +/// the trader's collateral. A withdrawal is capped at `liquidity`. #[test] -fn test_remove_liquidity_blocked_by_reserved() { +fn test_remove_liquidity_capped_at_liquidity() { 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); @@ -1327,16 +1692,265 @@ 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. - let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); - let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); - assert!(market - .remove_liquidity(&provider, provider_collateral, shares, 0) - .is_err()); + // At $80 the long is down $1,000, so assets-under-management is $11,000 + // against $10,000 of liquidity, and each share redeems 1.1 minor units + // (the provider's shares plus the withheld minimum are 10,000 USDC of + // shares). 9,090,909,092 shares would redeem 10,000,000,001, one minor + // unit more than `liquidity`, and are refused. + market.set_price(dollars(80)); + assert_fails_with( + market.remove_liquidity(&provider, provider_collateral, 9_090_909_092, 0), + PerpError::InsufficientLiquidity, + ); + + // One share fewer redeems exactly the pool's liquidity. + market + .remove_liquidity(&provider, provider_collateral, 9_090_909_091, 0) + .unwrap(); + assert_eq!( + get_token_account_balance(&market.svm, &provider_collateral).unwrap(), + 10_000 * ONE_USDC + ); + assert_eq!(market.pool_state().liquidity, 0); + market.assert_vault_matches_ledger(); +} + +/// One slot short of the warm-up, a profitable close is refused and the +/// position stays open. +#[test] +fn test_profit_blocked_before_maturation() { + let mut market = Market::default_market(); + 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 entry_slot = market + .position_state(&trader.pubkey(), Side::Long) + .entry_slot; + + market.warp(entry_slot + PROFIT_WARMUP_SLOTS - 1); + market.set_price(dollars(110)); + assert_fails_with( + market.close_position(&trader, trader_collateral, Side::Long, 0), + PerpError::ProfitNotMatured, + ); + assert_eq!(market.pool_state().long_size, size as u128); + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + 0 + ); +} + +/// From exactly `entry_slot + profit_warmup_slots`, the profit is paid. +#[test] +fn test_profit_realized_after_maturation() { + let mut market = Market::default_market(); + 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 entry_slot = market + .position_state(&trader.pubkey(), Side::Long) + .entry_slot; + + market.warp(entry_slot + PROFIT_WARMUP_SLOTS); + market.set_price(dollars(110)); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + + let fee = size / 1_000; + let profit = size / 10; + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + collateral - fee + profit - fee + ); +} + +/// The warm-up holds back profit only: a losing position closes in the slot +/// it opened. +#[test] +fn test_loss_not_gated_by_maturation() { + let mut market = Market::default_market(); + 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 entry_slot = market + .position_state(&trader.pubkey(), Side::Long) + .entry_slot; + + // Price falls 10% within the same slot: a $500 loss. + market.set_price(dollars(90)); + assert_eq!(market.current_slot(), entry_slot); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + + let fee = size / 1_000; + let loss = size / 10; + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + collateral - fee - loss - fee + ); + assert_eq!(market.pool_state().liquidity, 100_000 * ONE_USDC + loss); +} + +/// `insurance_fee_bps` of each open and close fee goes to the insurance fund, +/// rounded down, and the program keeps the rest, so no minor unit is lost. +#[test] +fn test_insurance_fund_funded_by_fees() { + let mut market = Market::try_new( + dollars(100), + PoolParameters { + insurance_fee_bps: 3_333, + ..default_parameters(0) + }, + ) + .unwrap(); + market.seed_liquidity(100_000 * ONE_USDC); + + // A size whose 0.1% fee is 1,234,567 minor units: 3,333 basis points of + // that is 411,481.18, so the insurance fund gets 411,481 and the program + // the other 823,086. + let size = 1_234_567_890; + let fee = 1_234_567; + let insurance_cut = 411_481; + assert_eq!(size / 1_000, fee); + let collateral = 200 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.insurance_fund, insurance_cut); + assert_eq!(pool.program_fees, fee - insurance_cut); + + // Closing at the open price charges the same fee again. + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.insurance_fund, 2 * insurance_cut); + assert_eq!(pool.program_fees, 2 * (fee - insurance_cut)); + market.assert_vault_matches_ledger(); +} + +/// A $1,000 long with $110 of net collateral, liquidated after a 15% fall: +/// its $150 loss leaves equity at -$40. +fn open_long_and_gap_through_zero(market: &mut Market) -> (Keypair, Address) { + 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(); + market.set_price(dollars(85)); + (trader, trader_collateral) +} + +/// A bankrupt position's deficit, its loss beyond its collateral, is paid by +/// the insurance fund when the fund holds enough. +#[test] +fn test_insurance_absorbs_bankruptcy_deficit() { + // A 5% open fee, 90% of which goes to the insurance fund: $45 of the $50. + let mut market = Market::try_new( + dollars(100), + PoolParameters { + open_fee_bps: 500, + insurance_fee_bps: 9_000, + ..default_parameters(0) + }, + ) + .unwrap(); + let (trader, trader_collateral) = open_long_and_gap_through_zero(&mut market); + assert_eq!(market.pool_state().insurance_fund, 45 * ONE_USDC); + let liquidity_before = market.pool_state().liquidity; + + let (liquidator, liquidator_collateral) = market.liquidator(); + market + .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) + .unwrap(); + + // The fund pays the $40 deficit, so the providers keep the $110 of + // collateral and are credited the full $150 loss. + let pool = market.pool_state(); + assert_eq!(pool.insurance_fund, 5 * ONE_USDC); + assert_eq!(pool.liquidity, liquidity_before + 150 * ONE_USDC); + assert_eq!( + get_token_account_balance(&market.svm, &liquidator_collateral).unwrap(), + 0 + ); + market.assert_vault_matches_ledger(); +} + +/// A position already below zero equity can still be liquidated by anyone. +/// Its equity cannot pay the liquidation fee, so the fee is forgiven and the +/// liquidator receives nothing. The insurance fund pays as much of the deficit +/// as it holds, and the liquidity providers bear only the rest. +#[test] +fn test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity() { + // A 5% open fee, half of which goes to the insurance fund: $25 of the $50. + let mut market = Market::try_new( + dollars(100), + PoolParameters { + open_fee_bps: 500, + ..default_parameters(0) + }, + ) + .unwrap(); + let (trader, trader_collateral) = open_long_and_gap_through_zero(&mut market); + assert_eq!(market.pool_state().insurance_fund, 25 * ONE_USDC); + let liquidity_before = market.pool_state().liquidity; + + let (liquidator, liquidator_collateral) = market.liquidator(); + market + .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) + .unwrap(); + + // The $40 deficit: $25 from the insurance fund, $15 borne by the + // providers, who keep the $110 of collateral plus the fund's $25. + let pool = market.pool_state(); + assert_eq!(pool.insurance_fund, 0); + assert_eq!(pool.liquidity, liquidity_before + 135 * ONE_USDC); + assert_eq!(pool.long_size, 0); + assert_eq!(pool.total_collateral, 0); + assert_eq!( + get_token_account_balance(&market.svm, &liquidator_collateral).unwrap(), + 0 + ); + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + 0 + ); assert!(market - .remove_liquidity(&provider, provider_collateral, shares / 2, 0) - .is_ok()); + .svm + .get_account(&market.position_pda(&trader.pubkey(), Side::Long)) + .is_none()); + market.assert_vault_matches_ledger(); +} + +#[test] +fn test_initialize_pool_rejects_insurance_fee_at_or_above_full_fee() { + let with_insurance_fee = |insurance_fee_bps| PoolParameters { + insurance_fee_bps, + ..default_parameters(0) + }; + assert_fails_with( + Market::try_new(dollars(100), with_insurance_fee(10_000)), + PerpError::InvalidParameter, + ); + assert!(Market::try_new(dollars(100), with_insurance_fee(9_999)).is_ok()); } #[test] @@ -1345,14 +1959,306 @@ 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_second: 0, - open_fee_bps: 10, close_fee_bps: 600, - max_leverage: 10, - maintenance_margin_bps: 500, - liquidation_fee_bps: 100, - max_confidence_bps: 100, + ..default_parameters(0) + }; + assert_fails_with( + Market::try_new(dollars(100), parameters), + PerpError::InvalidParameter, + ); +} + +#[test] +fn test_initialize_pool_rejects_initial_margin_at_or_below_maintenance() { + // An initial margin at or below the 5% maintenance margin would let a + // position open already liquidatable. + let with_initial_margin = |initial_margin_bps| PoolParameters { + initial_margin_bps, + ..default_parameters(0) }; - assert!(Market::try_new(dollars(100), parameters).is_err()); + assert_fails_with( + Market::try_new(dollars(100), with_initial_margin(500)), + PerpError::InitialMarginNotAboveMaintenance, + ); + assert_fails_with( + Market::try_new(dollars(100), with_initial_margin(350)), + PerpError::InitialMarginNotAboveMaintenance, + ); + + // Above 100% of notional is refused too. One basis point above the + // maintenance margin, and exactly 100%, are accepted. + assert_fails_with( + Market::try_new(dollars(100), with_initial_margin(10_001)), + PerpError::InvalidParameter, + ); + assert!(Market::try_new(dollars(100), with_initial_margin(501)).is_ok()); + assert!(Market::try_new(dollars(100), with_initial_margin(10_000)).is_ok()); +} + +#[test] +fn test_initialize_pool_rejects_price_deviation_outside_range() { + let with_deviation = |max_price_deviation_bps| PoolParameters { + max_price_deviation_bps, + ..default_parameters(0) + }; + for rejected in [0, 10_000] { + assert_fails_with( + Market::try_new(dollars(100), with_deviation(rejected)), + PerpError::InvalidPriceDeviation, + ); + } + assert!(Market::try_new(dollars(100), with_deviation(1)).is_ok()); + assert!(Market::try_new(dollars(100), with_deviation(9_999)).is_ok()); +} + +/// A single oracle print far from the pool's average cannot be traded at: the +/// open is refused before the price is folded into the average. +#[test] +fn test_open_rejected_when_oracle_jumps_outside_band() { + let mut market = Market::default_market(); + 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); + + // The band is 20% around the $100 average: $125 and $79 are outside it. + for outside_price in [dollars(125), dollars(79)] { + market.set_price(outside_price); + // The two refused opens are otherwise byte-identical transactions. + market.svm.expire_blockhash(); + assert_fails_with( + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), + PerpError::PriceOutsideBand, + ); + // The refused open folded nothing into the average. + assert_eq!(market.pool_state().average_price, dollars(100) as u64); + } + + // $118 is inside the band, and opens at that price. + market.set_price(dollars(118)); + market.svm.expire_blockhash(); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + let position_account = market + .svm + .get_account(&market.position_pda(&trader.pubkey(), Side::Long)) + .unwrap(); + let position = Position::try_deserialize(&mut position_account.data.as_slice()).unwrap(); + assert_eq!(position.entry_price, dollars(118) as u64); +} + +#[test] +fn test_close_rejected_when_oracle_jumps_outside_band() { + let mut market = Market::default_market(); + 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(); + + // A jump to $125 would pay the long $1,250, but $125 is 25% from the + // $100 average, outside the 20% band. + market.set_price(dollars(125)); + assert_fails_with( + market.close_position(&trader, trader_collateral, Side::Long, 0), + PerpError::PriceOutsideBand, + ); + + // At $115, inside the band and after the warm-up, the close goes through + // and pays the 15% gain. + market.pass_warmup(); + market.set_price(dollars(115)); + market.svm.expire_blockhash(); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + let fee = size / 1_000; + let profit = size * 15 / 100; + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + collateral - fee + profit - fee + ); +} + +/// Liquidation has no band check: a genuine crash is when positions go +/// underwater, so the pool has to be able to liquidate through one. +#[test] +fn test_liquidation_runs_outside_band() { + let mut market = Market::default_market(); + market.seed_liquidity(100_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(); + + // $75 is 25% below the $100 average, so the owner cannot close there. + market.set_price(dollars(75)); + assert_fails_with( + market.close_position(&trader, trader_collateral, Side::Long, 0), + PerpError::PriceOutsideBand, + ); + + let liquidator = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + market + .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) + .unwrap(); + assert!(market + .svm + .get_account(&market.position_pda(&trader.pubkey(), Side::Long)) + .is_none()); + assert_eq!(market.pool_state().long_size, 0); +} + +#[test] +fn test_liquidity_changes_rejected_when_oracle_jumps_outside_band() { + let mut market = Market::default_market(); + let (provider, provider_collateral) = market.seed_liquidity(10_000 * ONE_USDC); + let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); + let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); + + // $76 is 24% below the $100 average. + market.set_price(dollars(76)); + let (depositor, depositor_collateral) = market.funded_trader(5_000 * ONE_USDC); + assert_fails_with( + market.add_liquidity(&depositor, depositor_collateral, 5_000 * ONE_USDC, 0), + PerpError::PriceOutsideBand, + ); + assert_fails_with( + market.remove_liquidity(&provider, provider_collateral, shares, 0), + PerpError::PriceOutsideBand, + ); +} + +/// After a genuine move outside the band, anyone can walk the average toward +/// the new price with `update_price_average`, and trading resumes once the +/// price is back inside the band. +#[test] +fn test_price_average_catches_up_after_genuine_move() { + let mut market = Market::default_market(); + 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); + let keeper = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + + // NVDAx reprices from $100 to $130, 30% away from the average. + let new_price = dollars(130); + market.set_price(new_price); + assert_fails_with( + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), + PerpError::PriceOutsideBand, + ); + + // Every two minutes the keeper calls `update_price_average`. Each call + // credits the two minutes since the previous read to the price that read + // saw, a fifth of the window. The first call credits $100, the price + // before the move, and records $130; each later call moves the average a + // fifth of the remaining gap to $130: $100, then $106, then $110.80. $130 + // is within 20% of any average from $108.34 up, so the third update + // reopens trading. + let mut updates = 0; + loop { + market.pass_seconds(120); + market.set_price(new_price); + market.update_price_average(&keeper).unwrap(); + updates += 1; + let opened = + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0); + if opened.is_ok() { + break; + } + assert_fails_with(opened, PerpError::PriceOutsideBand); + assert!(updates < 10, "the average never caught up"); + } + assert_eq!(updates, 3); + let pool = market.pool_state(); + assert_eq!(pool.average_price, 11_080_000_000); + assert_eq!(pool.last_oracle_price, new_price as u64); +} + +#[test] +fn test_single_update_moves_average_by_elapsed_fraction() { + let mut market = Market::default_market(); + let keeper = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + let created_at = market.pool_state().average_price_timestamp; + + // The first update after the oracle moves to $115 credits the four + // minutes since creation to $100, the price seen at creation, so the + // average stays at $100 and $115 is recorded for the next read. + market.pass_seconds(240); + market.set_price(dollars(115)); + market.update_price_average(&keeper).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(100) as u64); + assert_eq!(pool.last_oracle_price, dollars(115) as u64); + assert_eq!(pool.average_price_timestamp, created_at + 240); + + // Four more minutes at $115 are 240 of the 600-second window, so the next + // update moves the average 240/600 of the way from $100 to $115: to $106. + market.pass_seconds(240); + market.set_price(dollars(115)); + market.update_price_average(&keeper).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(106) as u64); + assert_eq!(pool.average_price_timestamp, created_at + 480); + + // Fifteen minutes is more than a full window, so the next update replaces + // the average with $115, the price at the previous read, and records the + // fall to $97. One more update credits $97 for a full window. + market.pass_seconds(900); + market.set_price(dollars(97)); + market.update_price_average(&keeper).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(115) as u64); + assert_eq!(pool.last_oracle_price, dollars(97) as u64); + market.pass_seconds(900); + market.set_price(dollars(97)); + market.update_price_average(&keeper).unwrap(); + assert_eq!(market.pool_state().average_price, dollars(97) as u64); +} + +/// A pool left idle for more than a window cannot have its average set by one +/// read of a manipulated price. The read only records the price; the interval +/// before it is credited to the price seen at the read before. Once a read of +/// the real price replaces it, the manipulated price has moved the average +/// only by the seconds between the two reads. +#[test] +fn test_one_manipulated_read_after_idle_does_not_move_average() { + let mut market = Market::default_market(); + 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); + let attacker = create_wallet(&mut market.svm, 100_000_000_000).unwrap(); + + // Fifteen idle minutes, then the oracle is pushed to $160 and the + // attacker calls `update_price_average`. The average stays at $100. + market.pass_seconds(900); + market.set_price(dollars(160)); + market.update_price_average(&attacker).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, dollars(100) as u64); + assert_eq!(pool.last_oracle_price, dollars(160) as u64); + + // Six seconds later the oracle is back at $100 and is read again. The six + // seconds are credited to $160: the average moves 6/600 of the $60 gap, + // to $100.60, and $100 replaces $160 as the latest observation. + market.pass_seconds(6); + market.set_price(dollars(100)); + market.update_price_average(&attacker).unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.average_price, 10_060_000_000); + assert_eq!(pool.last_oracle_price, dollars(100) as u64); + + // An open at $160 is still refused. + market.set_price(dollars(160)); + assert_fails_with( + market.open_position(&trader, trader_collateral, Side::Long, collateral, size, 0), + PerpError::PriceOutsideBand, + ); } diff --git a/finance/perpetual-futures/quasar/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index 47453e0d..b25a9511 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -1,5 +1,122 @@ # Changelog +## 2026-10-01 + +Replace the leverage cap with an initial margin. `initialize_pool`'s +`max_leverage` argument and `Pool::max_leverage` are now `initial_margin_bps`, +the net collateral a position must post to open, in basis points of its size +(1,000 is 10x). `initialize_pool` requires `maintenance_margin_bps < +initial_margin_bps <= 10_000`, refusing an initial margin at or below the +maintenance margin with the new `INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE` (19) and +one above 10,000 with `INVALID_PARAMETER`; `MAX_LEVERAGE_CEILING` is removed. +`open_position` checks `net_collateral * 10_000 >= size * initial_margin_bps` +and fails with `INITIAL_MARGIN_NOT_MET`, which takes `LEVERAGE_TOO_HIGH`'s code +(2). Its separate check that a new position starts above the maintenance margin +is removed, because the initial margin implies it; `POSITION_NOT_HEALTHY` +remains for `close_position`. An open fee larger than the posted collateral now +fails with `INSUFFICIENT_COLLATERAL` (17), as in the Anchor version, rather than +`INSUFFICIENT_LIQUIDITY`. + +Add a price band around a program-maintained average price. A fresh, confident +oracle print could still be wrong, and every handler traded at it. The pool now +keeps `average_price`, a time-weighted moving average of the oracle price, +`last_oracle_price`, the price at the most recent oracle read, and +`average_price_timestamp`. `initialize_pool` seeds the average and +`last_oracle_price` from the oracle. Every handler that reads the oracle credits +the seconds since the previous read to the price that read saw, +`average += (last_oracle_price - average) * min(elapsed, +PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`, with the new +constant at 600 seconds, and then records the price it read as +`last_oracle_price`. The price read now only counts from now, so a pool left +idle for a window or more cannot have its average set by one read of a +manipulated price: that price moves the average only if the oracle still shows +it at a later read, weighted by the seconds between the two reads. +`open_position`, `close_position`, `add_liquidity` and `remove_liquidity` refuse +a price outside `|price - average_price| * 10_000 <= average_price * +max_price_deviation_bps` with the new `PRICE_OUTSIDE_BAND` (21), checked against +the stored average before anything is folded in. `liquidate_position` folds and +records without the check. The new permissionless `update_price_average` +handler (discriminator 7) folds and records too, also without the check, so +keepers calling it repeatedly as time passes can walk the average to a genuine +move. `max_price_deviation_bps` is a +new `initialize_pool` argument, which must be above zero and below 10,000, or +the handler fails with the new `INVALID_PRICE_DEVIATION` (20). `shared.rs` has +`refresh_price_and_funding_within_band` for the four band-checked handlers +beside `refresh_price_and_funding` for the other two. + +Tested by `open_rejects_position_below_initial_margin` (formerly +`open_rejects_excess_leverage`, now checking both sides of the boundary), +`initialize_pool_records_the_margins_band_and_average`, +`initialize_pool_rejects_initial_margin_at_or_below_maintenance`, +`initialize_pool_rejects_price_deviation_outside_range`, +`open_rejected_when_oracle_jumps_outside_band`, +`close_rejected_when_oracle_jumps_outside_band`, +`liquidity_changes_rejected_when_oracle_jumps_outside_band`, +`liquidation_runs_outside_band`, `price_average_catches_up_after_genuine_move`, +`single_update_moves_average_by_elapsed_fraction` and +`one_manipulated_read_after_idle_does_not_move_average`. The default test pool +uses a 1,000 basis point initial margin and a 2,000 basis point band; +`profit_runs_uncapped_when_backed` triples the price, far outside the +band, so it now calls `update_price_average` to record the new price, lets a +full window pass, and calls it again before closing. + +Replace reserved liquidity with the haircut risk model from +[Percolator](https://github.com/aeyakovenko/percolator): trader collateral is +senior, and trader profit is junior, paid only as far as the pool can back it. +`Pool::reserved_liquidity` is removed, and with it `open_position`'s +`reserved + size <= liquidity` check, which failed with +`INSUFFICIENT_LIQUIDITY`, and `close_position`'s cap on profit at the +position's size. A position opens whatever the pool's liquidity, and profit has +no cap. `close_position` computes the haircut ratio `h = min(1, (liquidity + +insurance_fund) / max(0, traders' aggregate unrealized profit, closing +position's profit))` from the per-side accumulators, before the closing +position leaves them, and pays a winning position `profit * h / +HAIRCUT_PRECISION`, rounded down, with the new constant at 10^9, so every +winner closing at the same moment is paid the same fraction; a loss settles in +full. A winner who closes while open losers still offset them is paid at most +the pool's backing rather than refused, and every other winner's fraction is +unchanged. The profit is paid from `liquidity` first and from the insurance +fund for the rest; `POOL_INSOLVENT` remains as a defensive check. `remove_liquidity` caps a withdrawal at `liquidity` rather +than `liquidity - reserved_liquidity`, still failing with +`INSUFFICIENT_LIQUIDITY`. `shared.rs` has the new `haircut_ratio` and +`apply_haircut`. + +Add an insurance fund. `Pool::insurance_fund` is new, and so is the +`initialize_pool` argument `insurance_fee_bps`, which must be below 10,000 or +the handler fails with `INVALID_PARAMETER`. That fraction of every open and +close fee goes to the fund, rounded down, and the rest to `program_fees`, +through the new `split_fee` and `credit_fee` in `shared.rs`. +`liquidate_position` takes a position's deficit, its loss beyond its +collateral, from the fund first and credits what the fund pays to `liquidity`; +the providers bear the rest. The liquidation fee is still paid only out of the +position's remaining equity: the part the equity cannot cover is forgiven, as +in Percolator, and neither the insurance fund nor `liquidity` pays it. The +vault holds `liquidity + total_collateral + program_fees + insurance_fund`, +plus any tokens sent to it directly. + +Add a profit warm-up. The `initialize_pool` argument `profit_warmup_slots`, +after `insurance_fee_bps`, and `Position::entry_slot`, which `open_position` +sets to the current slot, are new. `close_position` refuses to pay a profit +before slot `entry_slot + profit_warmup_slots` with the new +`PROFIT_NOT_MATURED` (22). A losing position closes at any time, and +liquidation is not delayed. + +Tested by `open_allowed_without_full_backing`, +`profit_runs_uncapped_when_backed`, `haircut_scales_profit_when_pool_stressed`, +`insurance_pays_profit_beyond_liquidity`, +`winner_offset_by_open_loser_is_paid_not_refused`, +`remove_liquidity_capped_at_liquidity`, `profit_blocked_before_maturation`, +`profit_realized_after_maturation`, `loss_not_gated_by_maturation`, +`insurance_fund_funded_by_fees`, `insurance_absorbs_bankruptcy_deficit`, +`liquidation_of_bankrupt_position_charges_insurance_before_liquidity` and +`initialize_pool_rejects_insurance_fee_at_or_above_full_fee`. They replace +`open_rejects_when_pool_cannot_back_it`, +`profit_is_capped_at_the_reserved_notional` and +`remove_liquidity_is_blocked_by_reserved_notional`. The default test pool pays +half of each fee into the insurance fund and has a 10-slot warm-up, so the +tests that close at a profit first let the warm-up pass, and +`collect_fees_sweeps_the_open_fee_to_the_admin` sweeps the program's half. + ## 2026-09-30 Remove `set_funding_rate` (discriminator 7). The pool's authority could change diff --git a/finance/perpetual-futures/quasar/README.md b/finance/perpetual-futures/quasar/README.md index 4d533d4d..7f7b246d 100644 --- a/finance/perpetual-futures/quasar/README.md +++ b/finance/perpetual-futures/quasar/README.md @@ -30,10 +30,50 @@ 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, the funding-rate maximum, an -operator's wallet on the lighter side earning only the fixed rate, funding that -follows seconds rather than slots, liquidation, and fee collection. +wallets, then exercise: + +- pool initialization, including its checks on the initial margin (above the + maintenance margin, at most 10,000 basis points) and the price band (above + zero, below 10,000 basis points) +- liquidity add/remove, and share inflation through a provider's own trades +- opening and closing a long in profit, and the initial margin on both sides + of its boundary +- stale-price, pre-restart-price, and wide-confidence rejection +- the funding-rate maximum, an operator's wallet on the lighter side earning + only the fixed rate, and funding that follows seconds rather than slots +- the price band: opens, closes, deposits and withdrawals refused when the + oracle jumps outside it, liquidation running outside it, the exact average + after one `update_price_average` + (`single_update_moves_average_by_elapsed_fraction`), and repeated updates + walking the average to a genuine move until trading resumes + (`price_average_catches_up_after_genuine_move`), and one manipulated read + after an idle window leaving the average where it was + (`one_manipulated_read_after_idle_does_not_move_average`) +- liquidation, and fee collection +- the haircut: a position opening without full backing, profit paid in full + while the pool backs it, two winners each paid exactly half when the pool is + stressed (`haircut_scales_profit_when_pool_stressed`), the insurance fund + paying a profit beyond `liquidity`, and a winner offset by an open loser paid + the pool's whole backing rather than refused + (`winner_offset_by_open_loser_is_paid_not_refused`) +- the profit warm-up on both sides of its boundary + (`profit_blocked_before_maturation`, `profit_realized_after_maturation`), + and a loss closing in the slot it opened +- the insurance fund: its exact share of each fee, a bankrupt position's + deficit paid by the fund, and a bankrupt position liquidated for no fee with + the fund paying before the providers + (`liquidation_of_bankrupt_position_charges_insurance_before_liquidity`) +- withdrawals capped at `liquidity` while traders are down + (`remove_liquidity_capped_at_liquidity`) + +Program errors are `ProgramError::Custom` codes listed in +`instructions/shared.rs`, with the same names as the Anchor version's +`PerpError` variants in upper snake case: `INITIAL_MARGIN_NOT_MET` (2), +`INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE` (19), `INVALID_PRICE_DEVIATION` (20), +`PRICE_OUTSIDE_BAND` (21) and `PROFIT_NOT_MATURED` (22) among them. +`update_price_average` is discriminator 7. `initialize_pool` takes the Anchor +version's `PoolParameters` fields as separate arguments, ending with +`insurance_fee_bps` and `profit_warmup_slots`. ```bash cargo build-sbf diff --git a/finance/perpetual-futures/quasar/src/constants.rs b/finance/perpetual-futures/quasar/src/constants.rs index c0ff398d..da266225 100644 --- a/finance/perpetual-futures/quasar/src/constants.rs +++ b/finance/perpetual-futures/quasar/src/constants.rs @@ -10,6 +10,11 @@ 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`, the fraction of their +/// profit every closing winner is paid. `HAIRCUT_PRECISION` is `h = 1` (profit +/// paid in full); a smaller value pays that fraction of it. +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. Both `add_liquidity` and /// `remove_liquidity` divide by the share supply plus this minimum, so the @@ -21,8 +26,17 @@ pub const MINIMUM_LIQUIDITY: u64 = 1_000; /// the cluster's slot time, which the protocol lowers over time. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; -/// Upper bound on a pool's configurable `max_leverage`. -pub const MAX_LEVERAGE_CEILING: u16 = 100; +/// How many seconds of oracle prices the pool's `average_price` follows. Each +/// fold moves the average toward the price seen at the previous read by +/// `elapsed / window` of the gap between them, and an interval of a full window +/// or more replaces the average with that price. Ten minutes is long enough +/// that a price seen at two reads six seconds apart, about as long as a faulty +/// or manipulated oracle print lasts, moves the average by one percent of its +/// jump, and short enough that a genuine move is back inside the band within +/// minutes of repeated reads. Counted on the Clock's `unix_timestamp`, +/// like funding: it is a span of wall-clock time, and the second or two of +/// leader drift changes a fold's weight by well under one percent. +pub const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; /// Upper bound on a pool's `funding_rate_per_second`, in `FUNDING_PRECISION` /// units: 277 billionths of a position's size per second, just under 0.1% of diff --git a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs index 0f0b1daa..148d44d1 100644 --- a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs @@ -1,7 +1,9 @@ use { crate::{ constants::MINIMUM_LIQUIDITY, - instructions::shared::{err, error, refresh_price_and_funding, traders_unrealized_pnl}, + instructions::shared::{ + err, error, refresh_price_and_funding_within_band, traders_unrealized_pnl, + }, state::Pool, LpMintPda, }, @@ -54,7 +56,7 @@ pub fn handle_add_liquidity( let slot = accounts.clock.slot.get(); let unix_timestamp = accounts.clock.unix_timestamp.get(); - let price = refresh_price_and_funding( + let price = refresh_price_and_funding_within_band( &mut accounts.pool, &accounts.oracle_feed, slot, diff --git a/finance/perpetual-futures/quasar/src/instructions/close_position.rs b/finance/perpetual-futures/quasar/src/instructions/close_position.rs index 006513e1..6c8b0bdd 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, credit_fee, err, error, haircut_ratio, + position_funding, position_pnl, refresh_price_and_funding_within_band, }, state::{Pool, Position}, }, @@ -48,7 +49,7 @@ pub fn handle_close_position( ) -> Result<(), ProgramError> { let slot = accounts.clock.slot.get(); let unix_timestamp = accounts.clock.unix_timestamp.get(); - let price = refresh_price_and_funding( + let price = refresh_price_and_funding_within_band( &mut accounts.pool, &accounts.oracle_feed, slot, @@ -61,17 +62,36 @@ pub fn handle_close_position( 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)?; + // The haircut is computed while this position is still in the per-side + // accumulators, so its own profit counts toward the liability and it is + // paid the same fraction as any other winner closing at this price. Its + // own profit is passed too: if open losers offset it in the aggregate, + // the haircut is sized against that profit, so the payout is at most the + // backing and the close is never refused for lack of it. + let haircut = haircut_ratio(&accounts.pool, price, pnl)?; let funding = position_funding( side, size, 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); + // A profit is paid only once the position has been open for the pool's + // warm-up, and then only the haircut fraction of it. A loss settles in + // full, at any time. + let realized_pnl = if pnl > 0 { + let matured_at = entry_slot + .checked_add(accounts.pool.profit_warmup_slots.get()) + .ok_or(ProgramError::ArithmeticOverflow)?; + if slot < matured_at { + 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)? @@ -92,15 +112,6 @@ pub fn handle_close_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 @@ -109,6 +120,12 @@ pub fn handle_close_position( .ok_or(ProgramError::ArithmeticOverflow)?; accounts.pool.total_collateral.set(new_total_collateral); + // 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 profit the haircut withholds stays in + // `liquidity`. A payment larger than `liquidity` takes the rest from the + // insurance fund, which the haircut counted as backing. The haircut keeps + // the profit within both; `POOL_INSOLVENT` remains as a defensive check. let liquidity_delta = funding .checked_sub(realized_pnl) .ok_or(ProgramError::ArithmeticOverflow)?; @@ -116,20 +133,23 @@ pub fn handle_close_position( .checked_add(liquidity_delta) .ok_or(ProgramError::ArithmeticOverflow)?; if new_liquidity < 0 { - return Err(err(error::POOL_INSOLVENT)); + let shortfall = u64::try_from(new_liquidity.unsigned_abs()) + .map_err(|_| ProgramError::ArithmeticOverflow)?; + let new_insurance_fund = accounts + .pool + .insurance_fund + .get() + .checked_sub(shortfall) + .ok_or_else(|| err(error::POOL_INSOLVENT))?; + accounts.pool.insurance_fund.set(new_insurance_fund); + accounts.pool.liquidity.set(0); + } else { + accounts + .pool + .liquidity + .set(u64::try_from(new_liquidity).map_err(|_| ProgramError::ArithmeticOverflow)?); } - accounts - .pool - .liquidity - .set(u64::try_from(new_liquidity).map_err(|_| ProgramError::ArithmeticOverflow)?); - - let new_program_fees = accounts - .pool - .program_fees - .get() - .checked_add(close_fee) - .ok_or(ProgramError::ArithmeticOverflow)?; - accounts.pool.program_fees.set(new_program_fees); + credit_fee(&mut accounts.pool, close_fee)?; // The pool signs the CPI below with its own seeds. let bump = [bumps.pool]; diff --git a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs index 6e0f5735..7cbc09aa 100644 --- a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs @@ -1,7 +1,7 @@ use { crate::{ - constants::{BASIS_POINTS_DENOMINATOR, MAX_FUNDING_RATE_PER_SECOND, MAX_LEVERAGE_CEILING}, - instructions::shared::{err, error}, + constants::{BASIS_POINTS_DENOMINATOR, MAX_FUNDING_RATE_PER_SECOND}, + instructions::shared::{err, error, read_feed_price}, state::{Pool, PoolInner}, LpMintPda, VaultPda, }, @@ -21,7 +21,8 @@ pub struct InitializePool { )] pub pool: Account, pub collateral_mint: Account, - /// CHECK: stored on the pool; every read validates layout, scale, freshness. + /// CHECK: stored on the pool; every read, including the one here that seeds + /// the average price, validates layout, scale, freshness. pub oracle_feed: UncheckedAccount, /// Liquidity-provider share mint; the pool account is its mint authority. #[account( @@ -55,10 +56,13 @@ pub fn handle_initialize_pool( funding_rate_per_second: u64, open_fee_bps: u16, close_fee_bps: u16, - max_leverage: u16, + initial_margin_bps: u16, maintenance_margin_bps: u16, liquidation_fee_bps: u16, max_confidence_bps: u16, + max_price_deviation_bps: u16, + insurance_fee_bps: u16, + profit_warmup_slots: u64, bumps: &InitializePoolBumps, ) -> Result<(), ProgramError> { let denominator = BASIS_POINTS_DENOMINATOR as u16; @@ -67,9 +71,6 @@ pub fn handle_initialize_pool( if funding_rate_per_second > MAX_FUNDING_RATE_PER_SECOND { return Err(err(error::INVALID_PARAMETER)); } - if !(1..=MAX_LEVERAGE_CEILING).contains(&max_leverage) { - return Err(err(error::INVALID_PARAMETER)); - } if open_fee_bps >= denominator || close_fee_bps >= denominator || liquidation_fee_bps >= denominator @@ -87,10 +88,39 @@ pub fn handle_initialize_pool( if maintenance_margin_bps <= close_fee_bps { return Err(err(error::INVALID_PARAMETER)); } + // A position must open with more margin than it is liquidated at, or it + // could be liquidated in the same slot it opened. At most 100% of + // notional: more than that would demand collateral above the position's + // size. + if initial_margin_bps <= maintenance_margin_bps { + return Err(err(error::INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE)); + } + if initial_margin_bps > denominator { + return Err(err(error::INVALID_PARAMETER)); + } if max_confidence_bps == 0 || max_confidence_bps >= denominator { return Err(err(error::INVALID_PARAMETER)); } + // At 10_000 every fee would go to the insurance fund and none to the + // program. + if insurance_fee_bps >= denominator { + return Err(err(error::INVALID_PARAMETER)); + } + // Zero would refuse every price move, however small. At 100% or more the + // band could never refuse a fall, since the oracle price is always + // positive. + if max_price_deviation_bps == 0 || max_price_deviation_bps >= denominator { + return Err(err(error::INVALID_PRICE_DEVIATION)); + } + // Seed the average with a validated oracle price, so the band is in force + // from the first trade. + let initial_price = read_feed_price( + &accounts.oracle_feed, + oracle_scale, + accounts.clock.slot.get(), + max_confidence_bps, + )?; let unix_timestamp = accounts.clock.unix_timestamp.get(); accounts.pool.set_inner(PoolInner { authority: *accounts.authority.address(), @@ -100,22 +130,28 @@ pub fn handle_initialize_pool( lp_mint: *accounts.lp_mint.address(), oracle_scale, liquidity: 0, - reserved_liquidity: 0, total_collateral: 0, program_fees: 0, + insurance_fund: 0, long_size: 0, short_size: 0, long_size_scaled: 0, short_size_scaled: 0, cumulative_funding: 0, last_funding_timestamp: unix_timestamp, + average_price: initial_price, + last_oracle_price: initial_price, + average_price_timestamp: unix_timestamp, funding_rate_per_second, open_fee_bps, close_fee_bps, - max_leverage, + initial_margin_bps, maintenance_margin_bps, liquidation_fee_bps, max_confidence_bps, + max_price_deviation_bps, + insurance_fee_bps, + profit_warmup_slots, bump: bumps.pool, }); Ok(()) diff --git a/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs b/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs index 7195468f..eca444c3 100644 --- a/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs @@ -92,6 +92,9 @@ pub fn handle_liquidate_position( return Err(err(error::POSITION_HEALTHY)); } + // The liquidator's reward comes out of whatever equity remains. Whatever + // part of the fee the equity cannot cover is forgiven: neither the + // insurance fund nor the liquidity providers pay it. let remaining_equity = u64::try_from(equity.max(0)).map_err(|_| ProgramError::ArithmeticOverflow)?; let liquidation_fee = basis_points_of(size, accounts.pool.liquidation_fee_bps.get())?; @@ -102,15 +105,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 @@ -119,9 +113,27 @@ 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 whose equity is below zero lost more than its collateral. The + // insurance fund pays that deficit as far as it can, and the liquidity + // providers bear only the rest. + let deficit = u64::try_from(equity.min(0).unsigned_abs()) + .map_err(|_| ProgramError::ArithmeticOverflow)?; + let insurance_payment = deficit.min(accounts.pool.insurance_fund.get()); + let new_insurance_fund = accounts + .pool + .insurance_fund + .get() + .checked_sub(insurance_payment) + .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, and the insurance fund's payment toward the deficit moves, inside + // the vault, from `insurance_fund` to `liquidity`. let liquidity_delta = (collateral as i128) .checked_sub(remaining_equity as i128) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_add(insurance_payment 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/mod.rs b/finance/perpetual-futures/quasar/src/instructions/mod.rs index 00453e62..7026141f 100644 --- a/finance/perpetual-futures/quasar/src/instructions/mod.rs +++ b/finance/perpetual-futures/quasar/src/instructions/mod.rs @@ -6,6 +6,7 @@ mod liquidate_position; mod open_position; mod remove_liquidity; pub mod shared; +mod update_price_average; pub use add_liquidity::*; pub use close_position::*; @@ -14,3 +15,4 @@ pub use initialize_pool::*; pub use liquidate_position::*; pub use open_position::*; pub use remove_liquidity::*; +pub use update_price_average::*; diff --git a/finance/perpetual-futures/quasar/src/instructions/open_position.rs b/finance/perpetual-futures/quasar/src/instructions/open_position.rs index 61d0d557..9b954951 100644 --- a/finance/perpetual-futures/quasar/src/instructions/open_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/open_position.rs @@ -1,8 +1,9 @@ use { crate::{ - constants::{SIDE_LONG, SIDE_SHORT}, + constants::{BASIS_POINTS_DENOMINATOR, SIDE_LONG, SIDE_SHORT}, instructions::shared::{ - basis_points_of, err, error, refresh_price_and_funding, scale_size, + basis_points_of, credit_fee, err, error, refresh_price_and_funding_within_band, + scale_size, }, state::{Pool, Position, PositionInner}, }, @@ -58,7 +59,7 @@ pub fn handle_open_position( let slot = accounts.clock.slot.get(); let unix_timestamp = accounts.clock.unix_timestamp.get(); - let price = refresh_price_and_funding( + let price = refresh_price_and_funding_within_band( &mut accounts.pool, &accounts.oracle_feed, slot, @@ -76,40 +77,34 @@ pub fn handle_open_position( } } + // The open fee is taken out of the posted collateral; the rest backs the + // position, and the initial margin is measured against this net collateral. let open_fee = basis_points_of(size, accounts.pool.open_fee_bps.get())?; let net_collateral = collateral_amount .checked_sub(open_fee) - .ok_or_else(|| err(error::INSUFFICIENT_LIQUIDITY))?; + .ok_or_else(|| err(error::INSUFFICIENT_COLLATERAL))?; if net_collateral == 0 { return Err(err(error::ZERO_AMOUNT)); } - let max_notional = (net_collateral as u128) - .checked_mul(accounts.pool.max_leverage.get() as u128) + // Initial margin: net collateral must be at least `initial_margin_bps` of + // the notional size, compared as `net_collateral * 10_000 >= size * bps` + // so nothing is rounded. `initialize_pool` keeps the initial margin above + // the maintenance margin, so a position that passes this check opens with + // equity above the liquidation threshold. + let collateral_scaled = (net_collateral as u128) + .checked_mul(BASIS_POINTS_DENOMINATOR as u128) .ok_or(ProgramError::ArithmeticOverflow)?; - if size as u128 > max_notional { - return Err(err(error::LEVERAGE_TOO_HIGH)); - } - - let maintenance = basis_points_of(size, accounts.pool.maintenance_margin_bps.get())?; - if net_collateral <= maintenance { - 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) + let required_scaled = (size as u128) + .checked_mul(accounts.pool.initial_margin_bps.get() as u128) .ok_or(ProgramError::ArithmeticOverflow)?; - if new_reserved > accounts.pool.liquidity.get() { - return Err(err(error::INSUFFICIENT_LIQUIDITY)); + if collateral_scaled < required_scaled { + return Err(err(error::INITIAL_MARGIN_NOT_MET)); } - accounts.pool.reserved_liquidity.set(new_reserved); + // Nothing is set aside to back this position's profit, and the pool's + // liquidity does not limit its size: `close_position` pays each winner the + // fraction of their profit the pool can back (see `haircut_ratio`). let size_scaled = scale_size(size, price)?; accounts.position.set_inner(PositionInner { @@ -121,6 +116,7 @@ pub fn handle_open_position( entry_price: price, size_scaled, entry_funding: accounts.pool.cumulative_funding.get(), + entry_slot: slot, bump: bumps.position, }); @@ -132,13 +128,7 @@ pub fn handle_open_position( .ok_or(ProgramError::ArithmeticOverflow)?; accounts.pool.total_collateral.set(new_total_collateral); - let new_program_fees = accounts - .pool - .program_fees - .get() - .checked_add(open_fee) - .ok_or(ProgramError::ArithmeticOverflow)?; - accounts.pool.program_fees.set(new_program_fees); + credit_fee(&mut accounts.pool, open_fee)?; if side == SIDE_LONG { let long_size = accounts diff --git a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs index 20ce56e4..8d2178e2 100644 --- a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs @@ -1,7 +1,9 @@ use { crate::{ constants::MINIMUM_LIQUIDITY, - instructions::shared::{err, error, refresh_price_and_funding, traders_unrealized_pnl}, + instructions::shared::{ + err, error, refresh_price_and_funding_within_band, traders_unrealized_pnl, + }, state::Pool, LpMintPda, }, @@ -54,7 +56,7 @@ pub fn handle_remove_liquidity( let slot = accounts.clock.slot.get(); let unix_timestamp = accounts.clock.unix_timestamp.get(); - let price = refresh_price_and_funding( + let price = refresh_price_and_funding_within_band( &mut accounts.pool, &accounts.oracle_feed, slot, @@ -91,14 +93,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 { + // Shares are priced against assets-under-management, which counts traders' + // unrealized losses as the providers' gain. Those losses are still in the + // traders' collateral until their positions close, so a withdrawal is + // capped at `liquidity`, the tokens the providers own now. While traders + // are up instead, the pricing already keeps a withdrawal below `liquidity` + // minus their profit, leaving that profit's backing in the pool. + if amount_out > accounts.pool.liquidity.get() { 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 f4a08c1a..917fc222 100644 --- a/finance/perpetual-futures/quasar/src/instructions/shared.rs +++ b/finance/perpetual-futures/quasar/src/instructions/shared.rs @@ -7,14 +7,14 @@ use quasar_lang::{prelude::*, sysvars::Sysvar}; use crate::last_restart::LastRestartSlot; 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, + PRICE_AVERAGE_WINDOW_SECONDS, SIDE_LONG, SIZE_PRECISION, }; use crate::state::Pool; pub mod error { pub const ZERO_AMOUNT: u32 = 0; - pub const LEVERAGE_TOO_HIGH: u32 = 2; + pub const INITIAL_MARGIN_NOT_MET: u32 = 2; pub const INVALID_PARAMETER: u32 = 3; pub const STALE_PRICE: u32 = 4; pub const NON_POSITIVE_PRICE: u32 = 5; @@ -31,6 +31,10 @@ pub mod error { pub const ORACLE_CONFIDENCE_TOO_WIDE: u32 = 16; pub const INSUFFICIENT_COLLATERAL: u32 = 17; pub const PRICE_PREDATES_RESTART: u32 = 18; + pub const INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE: u32 = 19; + pub const INVALID_PRICE_DEVIATION: u32 = 20; + pub const PRICE_OUTSIDE_BAND: u32 = 21; + pub const PROFIT_NOT_MATURED: u32 = 22; } #[inline(always)] @@ -235,6 +239,94 @@ pub fn traders_unrealized_pnl( long_pnl.checked_add(short_pnl).ok_or_else(overflow) } +/// The haircut ratio `h` at `price`, scaled by `HAIRCUT_PRECISION`: the +/// fraction of its profit a winning position is paid when it closes. +/// +/// `h = min(1, (liquidity + insurance_fund) / max(liability, closing_profit))` +/// +/// The liability is the traders' aggregate unrealized profit from the per-side +/// accumulators, floored at zero, so the caller computes `h` before the closing +/// position leaves them, and every winner closing at that moment is paid the +/// same fraction. While the backing covers it `h` is one. When a move leaves +/// traders owed more than the backing, `h` is the backing divided by the +/// liability, floored, and rises again as losing positions settle into +/// `liquidity`. +/// +/// Open losing positions offset winners in the aggregate, so one winner's +/// `closing_profit` can be larger than the liability. Dividing by the larger of +/// the two means a winner who closes while open losers still offset them is +/// paid at most the pool's backing, and is never refused; when the liability is +/// the larger, every other winner's fraction is unchanged. +pub fn haircut_ratio( + pool: &Account, + price: u64, + closing_profit: i128, +) -> Result { + let traders = traders_unrealized_pnl( + pool.long_size.get(), + pool.long_size_scaled.get(), + pool.short_size.get(), + pool.short_size_scaled.get(), + price, + )?; + let liability = u128::try_from(traders.max(closing_profit).max(0)).map_err(|_| overflow())?; + if liability == 0 { + return Ok(HAIRCUT_PRECISION); + } + let backing = (pool.liquidity.get() as u128) + .checked_add(pool.insurance_fund.get() 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) +} + +/// `profit * haircut / HAIRCUT_PRECISION`, rounded down: the part of a +/// winning position's profit the pool pays. `profit` is positive; a loss is +/// never haircut. +pub fn apply_haircut(profit: i128, haircut: u128) -> Result { + let profit = u128::try_from(profit).map_err(|_| overflow())?; + let paid = profit + .checked_mul(haircut) + .ok_or_else(overflow)? + .checked_div(HAIRCUT_PRECISION) + .ok_or_else(overflow)?; + i128::try_from(paid).map_err(|_| overflow()) +} + +/// Split an open or close fee into `(insurance_cut, program_cut)`. The +/// insurance cut is `insurance_fee_bps` of the fee, rounded down, and the +/// program keeps the rest, so the two always add up to the whole fee. +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 program_cut = fee.checked_sub(insurance_cut).ok_or_else(overflow)?; + Ok((insurance_cut, program_cut)) +} + +/// Credit an open or close fee: `insurance_fee_bps` of it to the insurance +/// fund and the rest to program fees. +pub fn credit_fee(pool: &mut Account, fee: u64) -> Result<(), ProgramError> { + let (insurance_cut, program_cut) = split_fee(fee, pool.insurance_fee_bps.get())?; + let insurance_fund = pool + .insurance_fund + .get() + .checked_add(insurance_cut) + .ok_or_else(overflow)?; + pool.insurance_fund.set(insurance_fund); + let program_fees = pool + .program_fees + .get() + .checked_add(program_cut) + .ok_or_else(overflow)?; + pool.program_fees.set(program_fees); + Ok(()) +} + pub fn position_funding( side: u8, size: u64, @@ -267,30 +359,136 @@ pub fn basis_points_of(amount: u64, basis_points: u16) -> Result, + price: u64, + current_timestamp: i64, +) -> Result<(), ProgramError> { + let average_price_timestamp = pool.average_price_timestamp.get(); + if current_timestamp <= average_price_timestamp { + pool.last_oracle_price.set(price); + return Ok(()); + } + let elapsed = current_timestamp + .checked_sub(average_price_timestamp) + .ok_or_else(overflow)?; + let weight = elapsed.min(PRICE_AVERAGE_WINDOW_SECONDS); + + let average = pool.average_price.get() as i128; + // Multiply before dividing; the gap is signed, so the average moves down + // as readily as up. + let movement = (pool.last_oracle_price.get() as i128) + .checked_sub(average) + .ok_or_else(overflow)? + .checked_mul(weight as i128) + .ok_or_else(overflow)? + .checked_div(PRICE_AVERAGE_WINDOW_SECONDS as i128) + .ok_or_else(overflow)?; + let new_average = average.checked_add(movement).ok_or_else(overflow)?; + pool.average_price + .set(u64::try_from(new_average).map_err(|_| overflow())?); + pool.last_oracle_price.set(price); + pool.average_price_timestamp.set(current_timestamp); + Ok(()) +} + +/// Refuse an oracle `price` more than `max_price_deviation_bps` away from the +/// pool's stored `average_price`: +/// `|price - average_price| * 10_000 <= average_price * max_price_deviation_bps`. +pub fn require_price_within_band(pool: &Account, price: u64) -> Result<(), ProgramError> { + let average_price = pool.average_price.get(); + let deviation_scaled = (price.abs_diff(average_price) as u128) + .checked_mul(BASIS_POINTS_DENOMINATOR as u128) + .ok_or_else(overflow)?; + let band_scaled = (average_price as u128) + .checked_mul(pool.max_price_deviation_bps.get() as u128) + .ok_or_else(overflow)?; + if deviation_scaled > band_scaled { + return Err(err(error::PRICE_OUTSIDE_BAND)); + } + Ok(()) +} + +/// Read and validate the oracle price from the feed account, checked for +/// freshness against `slot`. +pub fn read_feed_price( + oracle_feed: &UncheckedAccount, + expected_scale: u32, + slot: u64, + max_confidence_bps: u16, +) -> Result { + let view = oracle_feed.to_account_view(); + let data = view + .try_borrow() + .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; + read_oracle_price(&data, expected_scale, slot, max_confidence_bps) +} + +/// The preamble `liquidate_position` and `update_price_average` run: read a +/// validated oracle price, checked for freshness against `slot`, bring the +/// pool's funding index up to `unix_timestamp`, and fold the interval since the +/// previous read into the pool's average (see `fold_price_into_average`), so +/// the settlement that follows uses fresh numbers. +/// Centralized so no handler can settle a position against a stale funding +/// index. +/// +/// No band check: liquidation has to keep working through a genuine price +/// move, because that is when positions go underwater, and +/// `update_price_average` is how the average catches up with one. pub fn refresh_price_and_funding( pool: &mut Account, oracle_feed: &UncheckedAccount, slot: u64, unix_timestamp: i64, ) -> Result { - let price = { - let view = oracle_feed.to_account_view(); - let data = view - .try_borrow() - .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; - read_oracle_price( - &data, - pool.oracle_scale.get(), - slot, - pool.max_confidence_bps.get(), - )? - }; + let price = read_pool_oracle_price(pool, oracle_feed, slot)?; + accrue_funding(pool, unix_timestamp)?; + fold_price_into_average(pool, price, unix_timestamp)?; + Ok(price) +} +/// The preamble for every handler that opens or closes a position or moves +/// liquidity: the same as `refresh_price_and_funding`, but first refuses a +/// price outside the band around the stored average, before anything is +/// folded in or the price is recorded. A single oracle print far from the +/// average therefore cannot open, close, deposit, or withdraw at that price. +pub fn refresh_price_and_funding_within_band( + pool: &mut Account, + oracle_feed: &UncheckedAccount, + slot: u64, + unix_timestamp: i64, +) -> Result { + let price = read_pool_oracle_price(pool, oracle_feed, slot)?; + require_price_within_band(pool, price)?; accrue_funding(pool, unix_timestamp)?; + fold_price_into_average(pool, price, unix_timestamp)?; Ok(price) } + +fn read_pool_oracle_price( + pool: &Account, + oracle_feed: &UncheckedAccount, + slot: u64, +) -> Result { + read_feed_price( + oracle_feed, + pool.oracle_scale.get(), + slot, + pool.max_confidence_bps.get(), + ) +} diff --git a/finance/perpetual-futures/quasar/src/instructions/update_price_average.rs b/finance/perpetual-futures/quasar/src/instructions/update_price_average.rs new file mode 100644 index 00000000..f7e82be1 --- /dev/null +++ b/finance/perpetual-futures/quasar/src/instructions/update_price_average.rs @@ -0,0 +1,34 @@ +use { + crate::{instructions::shared::refresh_price_and_funding, state::Pool}, + quasar_lang::{prelude::*, sysvars::clock::Clock}, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct UpdatePriceAverage { + /// Anyone may update the average: the result depends only on the oracle + /// price and the clock, never on who calls. + pub caller: Signer, + #[account( + mut, + address = Pool::seeds(collateral_mint.address(), oracle_feed.address()), + )] + pub pool: Account, + /// CHECK: bound to the pool via its seeds. + pub oracle_feed: UncheckedAccount, + pub collateral_mint: Account, + pub clock: Sysvar, +} + +#[inline(always)] +pub fn handle_update_price_average(accounts: &mut UpdatePriceAverage) -> Result<(), ProgramError> { + let slot = accounts.clock.slot.get(); + let unix_timestamp = accounts.clock.unix_timestamp.get(); + refresh_price_and_funding( + &mut accounts.pool, + &accounts.oracle_feed, + slot, + unix_timestamp, + )?; + Ok(()) +} diff --git a/finance/perpetual-futures/quasar/src/lib.rs b/finance/perpetual-futures/quasar/src/lib.rs index b21d656c..cf02ab82 100644 --- a/finance/perpetual-futures/quasar/src/lib.rs +++ b/finance/perpetual-futures/quasar/src/lib.rs @@ -40,10 +40,13 @@ mod quasar_perpetual_futures { funding_rate_per_second: u64, open_fee_bps: u16, close_fee_bps: u16, - max_leverage: u16, + initial_margin_bps: u16, maintenance_margin_bps: u16, liquidation_fee_bps: u16, max_confidence_bps: u16, + max_price_deviation_bps: u16, + insurance_fee_bps: u16, + profit_warmup_slots: u64, ) -> Result<(), ProgramError> { instructions::handle_initialize_pool( &mut ctx.accounts, @@ -51,10 +54,13 @@ mod quasar_perpetual_futures { funding_rate_per_second, open_fee_bps, close_fee_bps, - max_leverage, + initial_margin_bps, maintenance_margin_bps, liquidation_fee_bps, max_confidence_bps, + max_price_deviation_bps, + insurance_fee_bps, + profit_warmup_slots, &ctx.bumps, ) } @@ -122,4 +128,15 @@ mod quasar_perpetual_futures { pub fn collect_fees(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_collect_fees(&mut ctx.accounts, &ctx.bumps) } + + /// Read the oracle, credit the seconds since the previous read to the + /// price that read saw, record the current price for the next read, and + /// accrue funding up to now. Permissionless: after a genuine price move + /// takes the oracle outside the pool's band, anyone can call this + /// repeatedly as time passes to walk the average toward the new price until + /// trading resumes. + #[instruction(discriminator = 7)] + pub fn update_price_average(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_update_price_average(&mut ctx.accounts) + } } diff --git a/finance/perpetual-futures/quasar/src/state.rs b/finance/perpetual-futures/quasar/src/state.rs index b3828bbe..4aa63577 100644 --- a/finance/perpetual-futures/quasar/src/state.rs +++ b/finance/perpetual-futures/quasar/src/state.rs @@ -15,14 +15,20 @@ pub struct Pool { pub custody_vault: Address, pub lp_mint: Address, pub oracle_scale: u32, + /// Liquidity-provider-owned tokens. Together with `insurance_fund` it backs + /// trader profit: when the two cannot cover the profit traders are owed, + /// every closing winner is paid the same fraction of their profit (see + /// `instructions::shared::haircut_ratio`). 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, pub total_collateral: u64, pub program_fees: u64, + /// Funded by `insurance_fee_bps` of every open and close fee. It pays a + /// bankrupt position's deficit (its loss beyond its collateral) before + /// liquidity providers bear any of it, pays a winner's profit once + /// `liquidity` is exhausted, and counts alongside `liquidity` as backing in + /// the haircut. The vault holds `liquidity + total_collateral + + /// program_fees + insurance_fund`, plus any tokens sent to it directly. + pub insurance_fund: u64, pub long_size: u128, pub short_size: u128, pub long_size_scaled: u128, @@ -32,17 +38,46 @@ pub struct Pool { /// the wall clock, so what a position costs per hour does not depend on /// the cluster's slot time. pub last_funding_timestamp: i64, + /// Time-weighted moving average of the oracle price, in the pool's + /// `oracle_scale` fixed point. Seeded with the oracle price when the pool is + /// created. Every handler that reads the oracle credits the seconds since + /// the previous read to `last_oracle_price`, the price that read saw. + /// Trading and liquidity handlers refuse an oracle price more than + /// `max_price_deviation_bps` away from it, so a sudden jump pauses them + /// until the average catches up. + pub average_price: u64, + /// The oracle price at the most recent read, in `oracle_scale` fixed point. + /// The next read folds it into `average_price` for the seconds in between. + pub last_oracle_price: u64, + /// The Clock's `unix_timestamp` of the most recent fold into + /// `average_price`. + pub average_price_timestamp: i64, /// Funding accrued per second, in `FUNDING_PRECISION` units, applied to the /// heavier side. The funding paid by traders accrues to the pool. pub funding_rate_per_second: u64, pub open_fee_bps: u16, pub close_fee_bps: u16, - pub max_leverage: u16, + /// Net collateral a position must post to open, in basis points of its + /// notional size: 1_000 allows at most 10x leverage. Always above + /// `maintenance_margin_bps`, so no position opens already liquidatable. + pub initial_margin_bps: u16, pub maintenance_margin_bps: u16, pub liquidation_fee_bps: u16, /// 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, + /// Widest gap the pool trades across between the oracle price and + /// `average_price`, in basis points of `average_price`. + pub max_price_deviation_bps: u16, + /// Fraction of each open and close fee, in basis points, paid into + /// `insurance_fund`; the rest goes to `program_fees`. + pub insurance_fee_bps: u16, + /// Slots a position must stay open before `close_position` will pay it a + /// profit. Someone who pushes the oracle to a false price cannot open a + /// position and take its profit less than this many slots apart; by then the + /// price has had that long to correct. A losing position can close, and an + /// under-margined one be liquidated, at any time. + pub profit_warmup_slots: u64, pub bump: u8, } @@ -62,5 +97,8 @@ pub struct Position { pub entry_price: u64, pub size_scaled: u128, pub entry_funding: i128, + /// Slot the position opened in. `close_position` pays a profit only from + /// slot `entry_slot + pool.profit_warmup_slots` on. + 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 e51ba944..17407306 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -1,6 +1,7 @@ //! quasar-test integration tests. They exercise the full lifecycle: pool //! initialization, liquidity add/remove, opening/closing/liquidating leveraged -//! positions, fee collection, and the oracle/leverage/reserve guard rails. +//! positions, fee collection, the price average and its band, the +//! oracle/margin checks, and the haircut, profit warm-up and insurance fund. use { crate::{ @@ -8,8 +9,9 @@ use { cpi::{ AddLiquidityInstruction, ClosePositionInstruction, CollectFeesInstruction, InitializePoolInstruction, LiquidatePositionInstruction, OpenPositionInstruction, - RemoveLiquidityInstruction, + RemoveLiquidityInstruction, UpdatePriceAverageInstruction, }, + instructions::shared::error, state::{Pool, Position}, LpMintPda, VaultPda, }, @@ -42,10 +44,24 @@ const VICTIM_COLLATERAL: Pubkey = Pubkey::new_from_array([13; 32]); const VICTIM_LP: Pubkey = Pubkey::new_from_array([14; 32]); const OPERATOR_WALLET: Pubkey = Pubkey::new_from_array([15; 32]); const OPERATOR_COLLATERAL: Pubkey = Pubkey::new_from_array([16; 32]); +const KEEPER: Pubkey = Pubkey::new_from_array([17; 32]); +const SECOND_TRADER: Pubkey = Pubkey::new_from_array([18; 32]); +const SECOND_TRADER_COLLATERAL: Pubkey = Pubkey::new_from_array([19; 32]); + +// Matches `PRICE_AVERAGE_WINDOW_SECONDS`: one fold after this many seconds +// replaces the pool's average price with the oracle price. +const PRICE_AVERAGE_WINDOW_SECONDS: i64 = 600; // Ten years, in seconds. const TEN_YEARS: i64 = 315_360_000; +// The test pool's profit warm-up: a position can be closed at a profit from +// this many slots after it opened. +const PROFIT_WARMUP_SLOTS: u64 = 10; + +// Matches `HAIRCUT_PRECISION`: a haircut ratio of one. +const HAIRCUT_PRECISION: u64 = 1_000_000_000; + fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) } @@ -77,13 +93,36 @@ fn set_clock_at(test: &mut Test, slot: u64, unix_timestamp: i64) { data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&unix_timestamp.to_le_bytes()); - let clock_id: Pubkey = "SysvarC1ock11111111111111111111111111111111" - .parse() - .unwrap(); let sysvar_owner: Pubkey = "Sysvar1111111111111111111111111111111111111" .parse() .unwrap(); - test.set_account(Account::new(clock_id, sysvar_owner, 1_169_280, data)); + test.set_account(Account::new(clock_id(), sysvar_owner, 1_169_280, data)); +} + +fn clock_id() -> Pubkey { + "SysvarC1ock11111111111111111111111111111111" + .parse() + .unwrap() +} + +/// The Clock's `(slot, unix_timestamp)`, as `set_clock_at` last pinned them; +/// the world's default of slot 0 and timestamp 0 before that. +fn clock(test: &Test) -> (u64, i64) { + match test.account(clock_id()) { + Some(account) if account.data.len() >= 40 => ( + u64::from_le_bytes(account.data[0..8].try_into().unwrap()), + i64::from_le_bytes(account.data[32..40].try_into().unwrap()), + ), + _ => (0, 0), + } +} + +/// Move the slot forward by the profit warm-up, leaving the timestamp where it +/// is, so a position opened in the current slot can be closed at a profit. +/// The feed stays fresh: it is far fewer slots than the staleness bound. +fn pass_warmup(test: &mut Test) { + let (slot, unix_timestamp) = clock(test); + set_clock_at(test, slot + PROFIT_WARMUP_SLOTS, unix_timestamp); } /// Pin the LastRestartSlot sysvar account, simulating a cluster restart at @@ -115,18 +154,43 @@ fn init_pool_with_funding( funding_rate_per_second: u64, ) -> Outcome { test.send(InitializePoolInstruction { + maintenance_margin_bps, + close_fee_bps, + funding_rate_per_second, + ..default_initialize_pool() + }) +} + +/// The pool every test uses unless it overrides a parameter: 0.1% open and +/// close fees, half of each fee paid into the insurance fund, a 10% initial +/// margin (10x leverage), a 5% maintenance margin, a 1% liquidation fee, a 1% +/// maximum confidence band, a 20% price band around the pool's average price, +/// a 10-slot profit warm-up, and no funding. +fn default_initialize_pool() -> InitializePoolInstruction { + InitializePoolInstruction { authority: ADMIN, collateral_mint: COLLATERAL_MINT, oracle_feed: FEED, oracle_scale: ORACLE_SCALE, - funding_rate_per_second, + funding_rate_per_second: 0, open_fee_bps: 10, - close_fee_bps, - max_leverage: 10, - maintenance_margin_bps, + close_fee_bps: 10, + initial_margin_bps: 1_000, + maintenance_margin_bps: 500, liquidation_fee_bps: 100, max_confidence_bps: 100, - }) + max_price_deviation_bps: 2_000, + insurance_fee_bps: 5_000, + profit_warmup_slots: PROFIT_WARMUP_SLOTS, + } +} + +/// The world `initialize_pool` needs: the admin, the collateral mint, and a +/// feed at $100. +fn add_pool_prerequisites(test: &mut Test) { + test.add(Wallet::new().at(ADMIN)); + test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); + set_feed(test, dollars(100), 0); } /// The pool and its derived PDAs. @@ -137,8 +201,7 @@ struct Env { } /// Build a world with a collateral mint, an oracle feed at $100, and an -/// initialized pool (0.1% open/close fees, 10x max leverage, 5% maintenance -/// margin, 1% liquidation fee, 1% max confidence). +/// initialized pool with the parameters in `default_initialize_pool`. fn setup(test: &mut Test) -> Env { setup_with_funding(test, 0) } @@ -146,10 +209,19 @@ fn setup(test: &mut Test) -> Env { /// Like `setup`, but with a non-zero per-second funding rate so funding accrues /// as time passes. fn setup_with_funding(test: &mut Test, funding_rate_per_second: u64) -> Env { - test.add(Wallet::new().at(ADMIN)); - test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); - set_feed(test, dollars(100), 0); - init_pool_with_funding(test, 500, 10, funding_rate_per_second).succeeds(); + setup_with( + test, + InitializePoolInstruction { + funding_rate_per_second, + ..default_initialize_pool() + }, + ) +} + +/// Like `setup`, but initializing the pool with `instruction`. +fn setup_with(test: &mut Test, instruction: InitializePoolInstruction) -> Env { + add_pool_prerequisites(test); + test.send(instruction).succeeds(); let pool = test.derive_pda(Pool::seeds(&COLLATERAL_MINT, &FEED)); Env { @@ -196,12 +268,24 @@ fn remove_liquidity(test: &mut Test, env: &Env, shares: u64) -> Outcome { } fn open_position(test: &mut Test, env: &Env, side: u8, collateral: u64, size: u64) -> Outcome { + open_position_for(test, env, TRADER, TRADER_COLLATERAL, side, collateral, size) +} + +fn open_position_for( + test: &mut Test, + env: &Env, + owner: Pubkey, + owner_collateral: Pubkey, + side: u8, + collateral: u64, + size: u64, +) -> Outcome { test.send(OpenPositionInstruction { - owner: TRADER, + owner, oracle_feed: FEED, collateral_mint: COLLATERAL_MINT, custody_vault: env.custody_vault, - trader_collateral: TRADER_COLLATERAL, + trader_collateral: owner_collateral, side, collateral_amount: collateral, size, @@ -209,13 +293,81 @@ fn open_position(test: &mut Test, env: &Env, side: u8, collateral: u64, size: u6 }) } -fn close_position(test: &mut Test, env: &Env) -> Outcome { - test.send(ClosePositionInstruction { +/// `LIQUIDATOR` liquidates `TRADER`'s position. +fn liquidate(test: &mut Test, env: &Env) -> Outcome { + if test.account(LIQUIDATOR).is_none() { + test.add(Wallet::new().at(LIQUIDATOR)); + } + test.send(LiquidatePositionInstruction { + liquidator: LIQUIDATOR, owner: TRADER, oracle_feed: FEED, collateral_mint: COLLATERAL_MINT, custody_vault: env.custody_vault, trader_collateral: TRADER_COLLATERAL, + liquidator_collateral: LIQUIDATOR_COLLATERAL, + }) +} + +/// Assert the custody vault holds exactly what the pool's ledger says it +/// does: liquidity, open positions' collateral, program fees and the +/// insurance fund. +fn assert_vault_matches_ledger(test: &Test, env: &Env) { + let pool = test.read::(env.pool); + assert_eq!( + test.tokens(env.custody_vault), + u64::from(pool.liquidity) + + u64::from(pool.total_collateral) + + u64::from(pool.program_fees) + + u64::from(pool.insurance_fund) + ); +} + +/// The pool's `(average_price, last_oracle_price, average_price_timestamp)`. +fn pool_state(test: &Test, env: &Env) -> (u64, u64, i64) { + let pool = test.read::(env.pool); + ( + u64::from(pool.average_price), + u64::from(pool.last_oracle_price), + i64::from(pool.average_price_timestamp), + ) +} + +/// Move the clock `seconds` past the pool's last average fold, publish `price` +/// at the new slot, and call `update_price_average`, which credits those +/// seconds to the price seen at the previous read and records `price`. +fn update_average_after(test: &mut Test, env: &Env, seconds: i64, price: i128) -> Outcome { + let (_, _, last_fold) = pool_state(test, env); + let timestamp = last_fold + seconds; + let slot = timestamp as u64 * SLOTS_PER_SECOND; + set_clock_at(test, slot, timestamp); + set_feed_at_slot(test, price, slot, 0); + if test.account(KEEPER).is_none() { + test.add(Wallet::new().at(KEEPER)); + } + test.send(UpdatePriceAverageInstruction { + caller: KEEPER, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + }) +} + +fn close_position(test: &mut Test, env: &Env) -> Outcome { + close_position_for(test, env, TRADER, TRADER_COLLATERAL) +} + +fn close_position_for( + test: &mut Test, + env: &Env, + owner: Pubkey, + owner_collateral: Pubkey, +) -> Outcome { + test.send(ClosePositionInstruction { + owner, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + custody_vault: env.custody_vault, + trader_collateral: owner_collateral, minimum_payout: 0, }) } @@ -282,8 +434,7 @@ fn remove_liquidity_round_trip_returns_the_deposit_less_the_minimum(test: &mut T /// in is spread across shares nobody can redeem. #[quasar_test] fn inflating_liquidity_through_own_trades_does_not_pay(test: &mut Test) { - // The steepest rate a pool may have, held for ten years. The position is - // tiny because a pool holding 1_001 can back only 1_001 of notional. + // The steepest rate a pool may have, held for ten years. let env = setup_with_funding(test, MAX_FUNDING_RATE_PER_SECOND); fund(test, PROVIDER, PROVIDER_COLLATERAL, 1_001); add_liquidity(test, &env, 1_001) @@ -395,7 +546,9 @@ fn close_long_in_profit_pays_collateral_plus_pnl_minus_fees(test: &mut Test) { let size = 5_000 * ONE_USDC; open_position(test, &env, 0, 1_000 * ONE_USDC, size).succeeds(); - // Price rises 20%: a $5,000 long earns $1,000. + // Price rises 20%: a $5,000 long earns $1,000, paid once the warm-up has + // passed. + pass_warmup(test); set_feed(test, dollars(120), 0); let open_fee = size / 1_000; @@ -409,16 +562,29 @@ fn close_long_in_profit_pays_collateral_plus_pnl_minus_fees(test: &mut Test) { } #[quasar_test] -fn open_rejects_excess_leverage(test: &mut Test) { +fn open_rejects_position_below_initial_margin(test: &mut Test) { let env = setup(test); fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 2_000 * ONE_USDC); - fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); - // 11x exceeds the 10x maximum. - assert!( - open_position(test, &env, 0, 1_000 * ONE_USDC, 11_000 * ONE_USDC).is_err(), - "11x leverage must be rejected" + // The initial margin is 10% of notional. 1,000 USDC of collateral less + // the 11 USDC open fee leaves 989 USDC, short of the 1,100 USDC an 11,000 + // USDC position needs. + open_position(test, &env, SIDE_LONG, 1_000 * ONE_USDC, 11_000 * ONE_USDC) + .fails_with(error::INITIAL_MARGIN_NOT_MET); + + // A 10,000 USDC position needs 1,000 USDC net of its 10 USDC open fee. + // One minor unit short of 1,010 USDC is refused, and exactly 1,010 USDC + // opens at 10x. + let size = 10_000 * ONE_USDC; + let exact_collateral = 1_010 * ONE_USDC; + open_position(test, &env, SIDE_LONG, exact_collateral - 1, size) + .fails_with(error::INITIAL_MARGIN_NOT_MET); + open_position(test, &env, SIDE_LONG, exact_collateral, size).succeeds(); + assert_eq!( + u64::from(test.read::(env.pool).total_collateral), + size / 10 ); } @@ -472,18 +638,14 @@ fn collect_fees_sweeps_the_open_fee_to_the_admin(test: &mut Test) { authority_collateral: ADMIN_COLLATERAL, }) .succeeds() - // The open fee (0.1% of notional) was swept to the admin. - .has_tokens(ADMIN_COLLATERAL, size / 1_000); + // The program's half of the open fee (0.1% of notional) was swept to the + // admin; the other half is in the insurance fund. + .has_tokens(ADMIN_COLLATERAL, size / 1_000 / 2); + let pool = test.read::(env.pool); + assert_eq!(u64::from(pool.program_fees), 0); + assert_eq!(u64::from(pool.insurance_fund), size / 1_000 / 2); } -/// Retuning the rate settles the seconds already elapsed at the old rate -/// rather than repricing them at the new one. -/// -/// Both halves below hold the same position for the same seconds at the same -/// price, so the size and price scaling cancels and only the rates differ: the -/// spanning position pays one window at the old rate plus one at the new (3 -/// window-rates), and the position opened afterwards pays one window wholly at -/// the new rate (2 window-rates). /// Funding is quoted per second of wall-clock time, so slots passing without /// the clock moving charge nothing. A million extra slots halfway through the /// window, as a much shorter slot would produce, leave the funding unchanged. @@ -533,13 +695,9 @@ fn funding_follows_seconds_not_slots(test: &mut Test) { #[quasar_test] fn initialize_pool_rejects_funding_rate_above_the_maximum(test: &mut Test) { // The rate is fixed at creation, so this is the only place it is checked. - test.add(Wallet::new().at(ADMIN)); - test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); - set_feed(test, dollars(100), 0); - assert!( - init_pool_with_funding(test, 500, 10, MAX_FUNDING_RATE_PER_SECOND + 1).is_err(), - "a funding rate above the maximum must be rejected" - ); + add_pool_prerequisites(test); + init_pool_with_funding(test, 500, 10, MAX_FUNDING_RATE_PER_SECOND + 1) + .fails_with(error::INVALID_PARAMETER); init_pool_with_funding(test, 500, 10, MAX_FUNDING_RATE_PER_SECOND).succeeds(); } @@ -616,21 +774,26 @@ fn wide_oracle_confidence_is_rejected(test: &mut Test) { ); } +/// Nothing is set aside to back a position's profit, so a position can open +/// against a pool that could not pay its full winnings: here a $10,000 long +/// against $6,000 of liquidity. #[quasar_test] -fn open_rejects_when_pool_cannot_back_it(test: &mut Test) { +fn open_allowed_without_full_backing(test: &mut Test) { let env = setup(test); - fund(test, PROVIDER, PROVIDER_COLLATERAL, 3_000 * ONE_USDC); - add_liquidity(test, &env, 3_000 * ONE_USDC).succeeds(); - fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); - // A 5,000 position must reserve 5,000, but the pool only holds 3,000. - assert!( - open_position(test, &env, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC).is_err(), - "a position larger than the pool's free liquidity must be rejected" - ); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 6_000 * ONE_USDC); + add_liquidity(test, &env, 6_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 1_100 * ONE_USDC); + let size = 10_000 * ONE_USDC; + open_position(test, &env, SIDE_LONG, 1_100 * ONE_USDC, size).succeeds(); + + let pool = test.read::(env.pool); + assert_eq!(u128::from(pool.long_size), size as u128); + assert_eq!(u64::from(pool.liquidity), 6_000 * ONE_USDC); + assert_vault_matches_ledger(test, &env); } #[quasar_test] -fn profit_is_capped_at_the_reserved_notional(test: &mut Test) { +fn profit_runs_uncapped_when_backed(test: &mut Test) { let env = setup(test); fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); @@ -638,37 +801,446 @@ fn profit_is_capped_at_the_reserved_notional(test: &mut Test) { let collateral = 2_000 * ONE_USDC; let size = 5_000 * ONE_USDC; fund(test, TRADER, TRADER_COLLATERAL, collateral); - open_position(test, &env, 0, collateral, size).succeeds(); - - // Price triples: uncapped profit would be 2x the notional, but recoverable - // profit is capped at the reserved notional (`size`). + open_position(test, &env, SIDE_LONG, collateral, size).succeeds(); + + // Price triples, so the long's profit is twice its size. A move this + // large is far outside the price band, so the average has to catch up + // before the position can close: one update records $300, and a second a + // full window later credits that window to $300, replacing the average. + // That also passes the warm-up. The $100,000 pool backs the whole $10,000 + // profit, so it is paid in full. set_feed(test, dollars(300), 0); + update_average_after(test, &env, 0, dollars(300)).succeeds(); + update_average_after(test, &env, PRICE_AVERAGE_WINDOW_SECONDS, dollars(300)).succeeds(); 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; close_position(test, &env) .succeeds() - .has_tokens(TRADER_COLLATERAL, expected); + .has_tokens(TRADER_COLLATERAL, net_collateral + profit - close_fee); + assert_eq!( + u64::from(test.read::(env.pool).liquidity), + 100_000 * ONE_USDC - profit + ); + assert_vault_matches_ledger(test, &env); +} + +/// Two longs are owed $1,800 of profit between them, and the pool holds only +/// $900 to pay it with, so each is paid half of their profit: the first to +/// close is paid half of theirs, and the second, closing against what is left, +/// is paid half of theirs too. +#[quasar_test] +fn haircut_scales_profit_when_pool_stressed(test: &mut Test) { + // No fee goes to the insurance fund here, so the only backing is the $900 + // of liquidity and the first close adds nothing to it. + let env = setup_with( + test, + InitializePoolInstruction { + insurance_fee_bps: 0, + ..default_initialize_pool() + }, + ); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 900 * ONE_USDC); + add_liquidity(test, &env, 900 * ONE_USDC).succeeds(); + + let first_collateral = 1_000 * ONE_USDC; + let first_size = 6_000 * ONE_USDC; + fund(test, TRADER, TRADER_COLLATERAL, first_collateral); + open_position(test, &env, SIDE_LONG, first_collateral, first_size).succeeds(); + let second_collateral = 800 * ONE_USDC; + let second_size = 4_000 * ONE_USDC; + fund( + test, + SECOND_TRADER, + SECOND_TRADER_COLLATERAL, + second_collateral, + ); + open_position_for( + test, + &env, + SECOND_TRADER, + SECOND_TRADER_COLLATERAL, + SIDE_LONG, + second_collateral, + second_size, + ) + .succeeds(); + + // At $118 the first long is up $1,080 and the second $720: $1,800 owed + // against $900 of backing, so h = 900 / 1,800 = 0.5. + pass_warmup(test); + set_feed(test, dollars(118), 0); + let half = HAIRCUT_PRECISION / 2; + let first_paid = (first_size * 18 / 100) * half / HAIRCUT_PRECISION; + assert_eq!(first_paid, 540 * ONE_USDC); + close_position(test, &env).succeeds().has_tokens( + TRADER_COLLATERAL, + first_collateral - first_size / 1_000 + first_paid - first_size / 1_000, + ); + // The $540 withheld from the first long stays with the providers. + assert_eq!( + u64::from(test.read::(env.pool).liquidity), + 360 * ONE_USDC + ); + + // The second long is now owed $720 against $360: h is still 0.5. + let second_paid = (second_size * 18 / 100) * half / HAIRCUT_PRECISION; + assert_eq!(second_paid, 360 * ONE_USDC); + close_position_for(test, &env, SECOND_TRADER, SECOND_TRADER_COLLATERAL) + .succeeds() + .has_tokens( + SECOND_TRADER_COLLATERAL, + second_collateral - second_size / 1_000 + second_paid - second_size / 1_000, + ); + assert_eq!(u64::from(test.read::(env.pool).liquidity), 0); + assert_vault_matches_ledger(test, &env); +} + +/// Alice's long is up $1,000 while Bob's short, still open and healthy, is +/// down $900, so traders are owed only $100 in aggregate, and the pool's +/// backing is $300. Sized against the $100 alone the haircut would be one and +/// Alice's $1,000 would exceed the backing; it is sized against her $1,000 +/// instead, so she is paid exactly the $300 and the close goes through. Bob's +/// later close settles his loss into the pool in full. +#[quasar_test] +fn winner_offset_by_open_loser_is_paid_not_refused(test: &mut Test) { + let env = setup(test); + // $290.50 of liquidity plus the $9.50 the two open fees put in the + // insurance fund is $300 of backing. + fund(test, PROVIDER, PROVIDER_COLLATERAL, 290_500_000); + add_liquidity(test, &env, 290_500_000).succeeds(); + + let alice_collateral = 1_100 * ONE_USDC; + let alice_size = 10_000 * ONE_USDC; + fund(test, TRADER, TRADER_COLLATERAL, alice_collateral); + open_position(test, &env, SIDE_LONG, alice_collateral, alice_size).succeeds(); + let bob_collateral = 2_000 * ONE_USDC; + let bob_size = 9_000 * ONE_USDC; + fund( + test, + SECOND_TRADER, + SECOND_TRADER_COLLATERAL, + bob_collateral, + ); + open_position_for( + test, + &env, + SECOND_TRADER, + SECOND_TRADER_COLLATERAL, + SIDE_SHORT, + bob_collateral, + bob_size, + ) + .succeeds(); + let pool = test.read::(env.pool); + assert_eq!( + u64::from(pool.liquidity) + u64::from(pool.insurance_fund), + 300 * ONE_USDC + ); + + // At $110 Alice is up $1,000 and Bob down $900: h = 300 / 1,000 = 0.3. + pass_warmup(test); + set_feed(test, dollars(110), 0); + let alice_paid = 1_000 * ONE_USDC * (3 * HAIRCUT_PRECISION / 10) / HAIRCUT_PRECISION; + assert_eq!(alice_paid, 300 * ONE_USDC); + let alice_fee = alice_size / 1_000; + close_position(test, &env).succeeds().has_tokens( + TRADER_COLLATERAL, + alice_collateral - alice_fee + alice_paid - alice_fee, + ); + // The whole backing was paid out; the fund then took half of Alice's + // close fee. + let pool = test.read::(env.pool); + assert_eq!(u64::from(pool.liquidity), 0); + assert_eq!(u64::from(pool.insurance_fund), alice_fee / 2); + assert_vault_matches_ledger(test, &env); + + // Bob closes at the same price, losing $900 into the pool. + let bob_fee = bob_size / 1_000; + let bob_loss = 900 * ONE_USDC; + close_position_for(test, &env, SECOND_TRADER, SECOND_TRADER_COLLATERAL) + .succeeds() + .has_tokens( + SECOND_TRADER_COLLATERAL, + bob_collateral - bob_fee - bob_loss - bob_fee, + ); + let pool = test.read::(env.pool); + assert_eq!(u64::from(pool.liquidity), bob_loss); + assert_eq!(u64::from(pool.insurance_fund), (alice_fee + bob_fee) / 2); + assert_eq!(u64::from(pool.total_collateral), 0); + assert_vault_matches_ledger(test, &env); } +/// The haircut counts the insurance fund as backing, so a profit larger than +/// `liquidity` but within `liquidity + insurance_fund` is paid in full: the +/// pool's liquidity first, the insurance fund for the rest. #[quasar_test] -fn remove_liquidity_is_blocked_by_reserved_notional(test: &mut Test) { +fn insurance_pays_profit_beyond_liquidity(test: &mut Test) { + // A 5% open fee, half of which goes to the insurance fund. + let env = setup_with( + test, + InitializePoolInstruction { + open_fee_bps: 500, + ..default_initialize_pool() + }, + ); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 1_700 * ONE_USDC); + add_liquidity(test, &env, 1_700 * ONE_USDC).succeeds(); + + // $500 open fee: $250 to the insurance fund, $1,100 of net collateral. + let size = 10_000 * ONE_USDC; + fund(test, TRADER, TRADER_COLLATERAL, 1_600 * ONE_USDC); + open_position(test, &env, SIDE_LONG, 1_600 * ONE_USDC, size).succeeds(); + assert_eq!( + u64::from(test.read::(env.pool).insurance_fund), + 250 * ONE_USDC + ); + + // At $118 the long is up $1,800: more than the $1,700 of liquidity, within + // the $1,950 of liquidity plus insurance, so h = 1. + pass_warmup(test); + set_feed(test, dollars(118), 0); + let profit = 1_800 * ONE_USDC; + let close_fee = size / 1_000; + close_position(test, &env) + .succeeds() + .has_tokens(TRADER_COLLATERAL, 1_100 * ONE_USDC + profit - close_fee); + + let pool = test.read::(env.pool); + assert_eq!(u64::from(pool.liquidity), 0); + // $100 of the profit came from the insurance fund, which then took half + // of the $10 close fee. + assert_eq!( + u64::from(pool.insurance_fund), + 150 * ONE_USDC + close_fee / 2 + ); + assert_vault_matches_ledger(test, &env); +} + +/// Shares are priced against assets-under-management, which counts a +/// trader's unrealized loss as the providers' gain, but that loss is still in +/// the trader's collateral. A withdrawal is capped at `liquidity`. +#[quasar_test] +fn remove_liquidity_capped_at_liquidity(test: &mut Test) { let env = setup(test); fund(test, PROVIDER, PROVIDER_COLLATERAL, 10_000 * ONE_USDC); add_liquidity(test, &env, 10_000 * ONE_USDC).succeeds(); fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); - open_position(test, &env, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC).succeeds(); + open_position(test, &env, SIDE_LONG, 1_000 * ONE_USDC, 5_000 * ONE_USDC).succeeds(); + + // At $80 the long is down $1,000, so assets-under-management is $11,000 + // against $10,000 of liquidity, and each share redeems 1.1 minor units + // (the provider's shares plus the withheld minimum are 10,000 USDC of + // shares). 9,090,909,092 shares would redeem 10,000,000,001, one minor + // unit more than `liquidity`, and are refused. + set_feed(test, dollars(80), 0); + remove_liquidity(test, &env, 9_090_909_092).fails_with(error::INSUFFICIENT_LIQUIDITY); + + // One share fewer redeems exactly the pool's liquidity. + remove_liquidity(test, &env, 9_090_909_091) + .succeeds() + .has_tokens(PROVIDER_COLLATERAL, 10_000 * ONE_USDC); + assert_eq!(u64::from(test.read::(env.pool).liquidity), 0); + assert_vault_matches_ledger(test, &env); +} - // 5,000 of the 10,000 liquidity is reserved: pulling everything fails, but - // withdrawing within the free half succeeds. - let shares = test.tokens(PROVIDER_LP); - assert!( - remove_liquidity(test, &env, shares).is_err(), - "withdrawing reserved liquidity must fail" +/// Open a $5,000 long with $1,000 of collateral against a $100,000 pool and +/// return the slot it opened in. +fn open_long_against_deep_pool(test: &mut Test, env: &Env) -> u64 { + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, env, 100_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); + open_position(test, env, SIDE_LONG, 1_000 * ONE_USDC, 5_000 * ONE_USDC).succeeds(); + let position = test.read::(test.derive_pda(Position::seeds(&env.pool, &TRADER))); + u64::from(position.entry_slot) +} + +/// One slot short of the warm-up, a profitable close is refused and the +/// position stays open. +#[quasar_test] +fn profit_blocked_before_maturation(test: &mut Test) { + let env = setup(test); + let entry_slot = open_long_against_deep_pool(test, &env); + + let (_, unix_timestamp) = clock(test); + set_clock_at(test, entry_slot + PROFIT_WARMUP_SLOTS - 1, unix_timestamp); + set_feed(test, dollars(110), 0); + close_position(test, &env).fails_with(error::PROFIT_NOT_MATURED); + assert_eq!( + u128::from(test.read::(env.pool).long_size), + (5_000 * ONE_USDC) as u128 ); - remove_liquidity(test, &env, shares / 2).succeeds(); + assert_eq!(test.tokens(TRADER_COLLATERAL), 0); +} + +/// From exactly `entry_slot + profit_warmup_slots`, the profit is paid. +#[quasar_test] +fn profit_realized_after_maturation(test: &mut Test) { + let env = setup(test); + let entry_slot = open_long_against_deep_pool(test, &env); + + let (_, unix_timestamp) = clock(test); + set_clock_at(test, entry_slot + PROFIT_WARMUP_SLOTS, unix_timestamp); + set_feed(test, dollars(110), 0); + let size = 5_000 * ONE_USDC; + let fee = size / 1_000; + close_position(test, &env) + .succeeds() + .has_tokens(TRADER_COLLATERAL, 1_000 * ONE_USDC - fee + size / 10 - fee); +} + +/// The warm-up holds back profit only: a losing position closes in the slot +/// it opened. +#[quasar_test] +fn loss_not_gated_by_maturation(test: &mut Test) { + let env = setup(test); + let entry_slot = open_long_against_deep_pool(test, &env); + + // Price falls 10% within the same slot: a $500 loss. + set_feed(test, dollars(90), 0); + assert_eq!(clock(test).0, entry_slot); + let size = 5_000 * ONE_USDC; + let fee = size / 1_000; + let loss = size / 10; + close_position(test, &env) + .succeeds() + .has_tokens(TRADER_COLLATERAL, 1_000 * ONE_USDC - fee - loss - fee); + assert_eq!( + u64::from(test.read::(env.pool).liquidity), + 100_000 * ONE_USDC + loss + ); +} + +/// `insurance_fee_bps` of each open and close fee goes to the insurance fund, +/// rounded down, and the program keeps the rest, so no minor unit is lost. +#[quasar_test] +fn insurance_fund_funded_by_fees(test: &mut Test) { + let env = setup_with( + test, + InitializePoolInstruction { + insurance_fee_bps: 3_333, + ..default_initialize_pool() + }, + ); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + + // A size whose 0.1% fee is 1,234,567 minor units: 3,333 basis points of + // that is 411,481.18, so the insurance fund gets 411,481 and the program + // the other 823,086. + let size = 1_234_567_890; + let fee = 1_234_567; + let insurance_cut = 411_481; + assert_eq!(size / 1_000, fee); + fund(test, TRADER, TRADER_COLLATERAL, 200 * ONE_USDC); + open_position(test, &env, SIDE_LONG, 200 * ONE_USDC, size).succeeds(); + let pool = test.read::(env.pool); + assert_eq!(u64::from(pool.insurance_fund), insurance_cut); + assert_eq!(u64::from(pool.program_fees), fee - insurance_cut); + + // Closing at the open price charges the same fee again. + close_position(test, &env).succeeds(); + let pool = test.read::(env.pool); + assert_eq!(u64::from(pool.insurance_fund), 2 * insurance_cut); + assert_eq!(u64::from(pool.program_fees), 2 * (fee - insurance_cut)); + assert_vault_matches_ledger(test, &env); +} + +/// A $1,000 long with $110 of net collateral, liquidated after a 15% fall: +/// its $150 loss leaves equity at -$40. +fn open_long_and_gap_through_zero(test: &mut Test, env: &Env) { + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, env, 100_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 160 * ONE_USDC); + open_position(test, env, SIDE_LONG, 160 * ONE_USDC, 1_000 * ONE_USDC).succeeds(); + set_feed(test, dollars(85), 0); +} + +/// A bankrupt position's deficit, its loss beyond its collateral, is paid by +/// the insurance fund when the fund holds enough. +#[quasar_test] +fn insurance_absorbs_bankruptcy_deficit(test: &mut Test) { + // A 5% open fee, 90% of which goes to the insurance fund: $45 of the $50. + let env = setup_with( + test, + InitializePoolInstruction { + open_fee_bps: 500, + insurance_fee_bps: 9_000, + ..default_initialize_pool() + }, + ); + open_long_and_gap_through_zero(test, &env); + assert_eq!( + u64::from(test.read::(env.pool).insurance_fund), + 45 * ONE_USDC + ); + let liquidity_before = u64::from(test.read::(env.pool).liquidity); + + liquidate(test, &env) + .succeeds() + .has_tokens(LIQUIDATOR_COLLATERAL, 0); + + // The fund pays the $40 deficit, so the providers keep the $110 of + // collateral and are credited the full $150 loss. + let pool = test.read::(env.pool); + assert_eq!(u64::from(pool.insurance_fund), 5 * ONE_USDC); + assert_eq!(u64::from(pool.liquidity), liquidity_before + 150 * ONE_USDC); + assert_vault_matches_ledger(test, &env); +} + +/// A position already below zero equity can still be liquidated by anyone. +/// Its equity cannot pay the liquidation fee, so the fee is forgiven and the +/// liquidator receives nothing. The insurance fund pays as much of the deficit +/// as it holds, and the liquidity providers bear only the rest. +#[quasar_test] +fn liquidation_of_bankrupt_position_charges_insurance_before_liquidity(test: &mut Test) { + // A 5% open fee, half of which goes to the insurance fund: $25 of the $50. + let env = setup_with( + test, + InitializePoolInstruction { + open_fee_bps: 500, + ..default_initialize_pool() + }, + ); + open_long_and_gap_through_zero(test, &env); + assert_eq!( + u64::from(test.read::(env.pool).insurance_fund), + 25 * ONE_USDC + ); + let liquidity_before = u64::from(test.read::(env.pool).liquidity); + + let position = test.derive_pda(Position::seeds(&env.pool, &TRADER)); + liquidate(test, &env) + .succeeds() + .is_closed(position) + .has_tokens(LIQUIDATOR_COLLATERAL, 0) + .has_tokens(TRADER_COLLATERAL, 0); + + // The $40 deficit: $25 from the insurance fund, $15 borne by the + // providers, who keep the $110 of collateral plus the fund's $25. + let pool = test.read::(env.pool); + assert_eq!(u64::from(pool.insurance_fund), 0); + assert_eq!(u64::from(pool.liquidity), liquidity_before + 135 * ONE_USDC); + assert_eq!(u128::from(pool.long_size), 0); + assert_eq!(u64::from(pool.total_collateral), 0); + assert_vault_matches_ledger(test, &env); +} + +#[quasar_test] +fn initialize_pool_rejects_insurance_fee_at_or_above_full_fee(test: &mut Test) { + add_pool_prerequisites(test); + test.send(InitializePoolInstruction { + insurance_fee_bps: 10_000, + ..default_initialize_pool() + }) + .fails_with(error::INVALID_PARAMETER); + test.send(InitializePoolInstruction { + insurance_fee_bps: 9_999, + ..default_initialize_pool() + }) + .succeeds(); } #[quasar_test] @@ -676,11 +1248,263 @@ fn initialize_pool_rejects_close_fee_at_or_above_maintenance_margin(test: &mut T // A pool whose close fee reached the maintenance margin could strand a // position that is too healthy to liquidate but too poor to pay the fee to // close, so initialize_pool refuses the configuration. - test.add(Wallet::new().at(ADMIN)); - test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); - set_feed(test, dollars(100), 0); - assert!( - init_pool(test, 500, 600).is_err(), - "close_fee_bps >= maintenance_margin_bps must be rejected" + add_pool_prerequisites(test); + init_pool(test, 500, 600).fails_with(error::INVALID_PARAMETER); +} + +#[quasar_test] +fn initialize_pool_records_the_margins_band_and_average(test: &mut Test) { + let env = setup(test); + let pool = test.read::(env.pool); + assert_eq!(u16::from(pool.initial_margin_bps), 1_000); + assert_eq!(u16::from(pool.max_price_deviation_bps), 2_000); + // The average starts at the oracle price the pool was created against. + assert_eq!(u64::from(pool.average_price), dollars(100) as u64); +} + +#[quasar_test] +fn initialize_pool_rejects_initial_margin_at_or_below_maintenance(test: &mut Test) { + // An initial margin at or below the 5% maintenance margin would let a + // position open already liquidatable. + add_pool_prerequisites(test); + for initial_margin_bps in [500, 350] { + test.send(InitializePoolInstruction { + initial_margin_bps, + ..default_initialize_pool() + }) + .fails_with(error::INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE); + } + // Above 100% of notional is refused too. + test.send(InitializePoolInstruction { + initial_margin_bps: 10_001, + ..default_initialize_pool() + }) + .fails_with(error::INVALID_PARAMETER); + // One basis point above the maintenance margin is accepted. + test.send(InitializePoolInstruction { + initial_margin_bps: 501, + ..default_initialize_pool() + }) + .succeeds(); +} + +#[quasar_test] +fn initialize_pool_rejects_price_deviation_outside_range(test: &mut Test) { + add_pool_prerequisites(test); + for max_price_deviation_bps in [0, 10_000] { + test.send(InitializePoolInstruction { + max_price_deviation_bps, + ..default_initialize_pool() + }) + .fails_with(error::INVALID_PRICE_DEVIATION); + } + test.send(InitializePoolInstruction { + max_price_deviation_bps: 9_999, + ..default_initialize_pool() + }) + .succeeds(); +} + +/// A single oracle print far from the pool's average cannot be traded at: the +/// open is refused before the price is folded into the average. +#[quasar_test] +fn open_rejected_when_oracle_jumps_outside_band(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); + let size = 5_000 * ONE_USDC; + + // The band is 20% around the $100 average: $125 and $79 are outside it. + for outside_price in [dollars(125), dollars(79)] { + set_feed(test, outside_price, 0); + open_position(test, &env, SIDE_LONG, 1_000 * ONE_USDC, size) + .fails_with(error::PRICE_OUTSIDE_BAND); + // The refused open folded nothing into the average. + assert_eq!(pool_state(test, &env).0, dollars(100) as u64); + } + + // $118 is inside the band, and opens at that price. + set_feed(test, dollars(118), 0); + open_position(test, &env, SIDE_LONG, 1_000 * ONE_USDC, size).succeeds(); + let position = test.read::(test.derive_pda(Position::seeds(&env.pool, &TRADER))); + assert_eq!(u64::from(position.entry_price), dollars(118) as u64); +} + +#[quasar_test] +fn close_rejected_when_oracle_jumps_outside_band(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + fund(test, TRADER, TRADER_COLLATERAL, collateral); + open_position(test, &env, SIDE_LONG, collateral, size).succeeds(); + + // A jump to $125 would pay the long $1,250, but $125 is 25% from the + // $100 average, outside the 20% band. + set_feed(test, dollars(125), 0); + close_position(test, &env).fails_with(error::PRICE_OUTSIDE_BAND); + + // At $115, inside the band and after the warm-up, the close goes through + // and pays the 15% gain. + pass_warmup(test); + set_feed(test, dollars(115), 0); + let fee = size / 1_000; + let profit = size * 15 / 100; + close_position(test, &env) + .succeeds() + .has_tokens(TRADER_COLLATERAL, collateral - fee + profit - fee); +} + +/// Liquidation has no band check: a genuine crash is when positions go +/// underwater, so the pool has to be able to liquidate through one. +#[quasar_test] +fn liquidation_runs_outside_band(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 1_100 * ONE_USDC); + open_position(test, &env, SIDE_LONG, 1_100 * ONE_USDC, 10_000 * ONE_USDC).succeeds(); + + // $75 is 25% below the $100 average, so the owner cannot close there. + set_feed(test, dollars(75), 0); + close_position(test, &env).fails_with(error::PRICE_OUTSIDE_BAND); + + test.add(Wallet::new().at(LIQUIDATOR)); + let position = test.derive_pda(Position::seeds(&env.pool, &TRADER)); + test.send(LiquidatePositionInstruction { + liquidator: LIQUIDATOR, + owner: TRADER, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + custody_vault: env.custody_vault, + trader_collateral: TRADER_COLLATERAL, + liquidator_collateral: LIQUIDATOR_COLLATERAL, + }) + .succeeds() + .is_closed(position); + assert_eq!(u128::from(test.read::(env.pool).long_size), 0); +} + +#[quasar_test] +fn liquidity_changes_rejected_when_oracle_jumps_outside_band(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 15_000 * ONE_USDC); + add_liquidity(test, &env, 10_000 * ONE_USDC).succeeds(); + let shares = test.tokens(PROVIDER_LP); + + // $76 is 24% below the $100 average. + set_feed(test, dollars(76), 0); + add_liquidity(test, &env, 5_000 * ONE_USDC).fails_with(error::PRICE_OUTSIDE_BAND); + remove_liquidity(test, &env, shares).fails_with(error::PRICE_OUTSIDE_BAND); +} + +/// After a genuine move outside the band, anyone can walk the average toward +/// the new price with `update_price_average`, and trading resumes once the +/// price is back inside the band. +#[quasar_test] +fn price_average_catches_up_after_genuine_move(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + fund(test, TRADER, TRADER_COLLATERAL, collateral); + + // NVDAx reprices from $100 to $130, 30% away from the average. + let new_price = dollars(130); + set_feed(test, new_price, 0); + open_position(test, &env, SIDE_LONG, collateral, size).fails_with(error::PRICE_OUTSIDE_BAND); + + // Every two minutes the keeper calls `update_price_average`. Each call + // credits the two minutes since the previous read to the price that read + // saw, a fifth of the window. The first call credits $100, the price + // before the move, and records $130; each later call moves the average a + // fifth of the remaining gap to $130: $100, then $106, then $110.80. $130 + // is within 20% of any average from $108.34 up, so the third update + // reopens trading. + let mut updates = 0; + loop { + update_average_after(test, &env, 120, new_price).succeeds(); + updates += 1; + let opened = open_position(test, &env, SIDE_LONG, collateral, size); + if opened.is_ok() { + break; + } + opened.fails_with(error::PRICE_OUTSIDE_BAND); + assert!(updates < 10, "the average never caught up"); + } + assert_eq!(updates, 3); + let (average_price, last_oracle_price, _) = pool_state(test, &env); + assert_eq!(average_price, 11_080_000_000); + assert_eq!(last_oracle_price, new_price as u64); +} + +#[quasar_test] +fn single_update_moves_average_by_elapsed_fraction(test: &mut Test) { + let env = setup(test); + let (_, _, created_at) = pool_state(test, &env); + + // The first update after the oracle moves to $115 credits the four + // minutes since creation to $100, the price seen at creation, so the + // average stays at $100 and $115 is recorded for the next read. + update_average_after(test, &env, 240, dollars(115)).succeeds(); + assert_eq!( + pool_state(test, &env), + (dollars(100) as u64, dollars(115) as u64, created_at + 240) + ); + + // Four more minutes at $115 are 240 of the 600-second window, so the next + // update moves the average 240/600 of the way from $100 to $115: to $106. + update_average_after(test, &env, 240, dollars(115)).succeeds(); + assert_eq!( + pool_state(test, &env), + (dollars(106) as u64, dollars(115) as u64, created_at + 480) + ); + + // Fifteen minutes is more than a full window, so the next update replaces + // the average with $115, the price at the previous read, and records the + // fall to $97. One more update credits $97 for a full window. + update_average_after(test, &env, 900, dollars(97)).succeeds(); + assert_eq!( + pool_state(test, &env), + (dollars(115) as u64, dollars(97) as u64, created_at + 1_380) ); + update_average_after(test, &env, 900, dollars(97)).succeeds(); + assert_eq!(pool_state(test, &env).0, dollars(97) as u64); +} + +/// A pool left idle for more than a window cannot have its average set by one +/// read of a manipulated price. The read only records the price; the interval +/// before it is credited to the price seen at the read before. Once a read of +/// the real price replaces it, the manipulated price has moved the average +/// only by the seconds between the two reads. +#[quasar_test] +fn one_manipulated_read_after_idle_does_not_move_average(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + let collateral = 1_000 * ONE_USDC; + fund(test, TRADER, TRADER_COLLATERAL, collateral); + + // Fifteen idle minutes, then the oracle is pushed to $160 and + // `update_price_average` is called. The average stays at $100. + update_average_after(test, &env, 900, dollars(160)).succeeds(); + let (average_price, last_oracle_price, _) = pool_state(test, &env); + assert_eq!(average_price, dollars(100) as u64); + assert_eq!(last_oracle_price, dollars(160) as u64); + + // Six seconds later the oracle is back at $100 and is read again. The six + // seconds are credited to $160: the average moves 6/600 of the $60 gap, + // to $100.60, and $100 replaces $160 as the latest observation. + update_average_after(test, &env, 6, dollars(100)).succeeds(); + let (average_price, last_oracle_price, last_fold) = pool_state(test, &env); + assert_eq!(average_price, 10_060_000_000); + assert_eq!(last_oracle_price, dollars(100) as u64); + + // An open at $160 is still refused. + set_feed_at_slot(test, dollars(160), last_fold as u64 * SLOTS_PER_SECOND, 0); + open_position(test, &env, SIDE_LONG, collateral, 5_000 * ONE_USDC) + .fails_with(error::PRICE_OUTSIDE_BAND); }