diff --git a/finance/perpetual-futures/anchor-v1/CHANGELOG.md b/finance/perpetual-futures/anchor-v1/CHANGELOG.md index f2ee8472..6e52940f 100644 --- a/finance/perpetual-futures/anchor-v1/CHANGELOG.md +++ b/finance/perpetual-futures/anchor-v1/CHANGELOG.md @@ -1,5 +1,63 @@ # 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_capped_at_reserved_notional` 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. + ## 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..6eecb217 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 @@ -54,10 +54,25 @@ Funding runs on the wall clock rather than the slot count, so what a position co 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. +`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. @@ -74,7 +89,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, a 5% maintenance margin, a 1% liquidation fee, a 1% maximum oracle confidence band, and a 20% price band around its average price. --- @@ -82,9 +97,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, reserved liquidity, collateral total, per-side open-interest accumulators, funding index, average oracle price, 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 - `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 @@ -137,7 +154,7 @@ 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. +$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` (well under the $5,000 reserve cap), minus the $5 close fee. **Accounts modified:** @@ -145,6 +162,8 @@ Her profit is `5,000 × (116 − 100) / 100 = $800` (well under the $5,000 reser - `Pool.reserved_liquidity`: −$5,000 (reserve released) - `Pool.total_collateral`: −$995 - `Pool.program_fees`: +$5 +- `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 @@ -195,7 +214,7 @@ The genuinely hard part of a perpetual-futures venue is keeping it solvent and p - **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, auto-deleveraging (ADL) and an insurance fund for the bad-debt tail, 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. --- @@ -212,7 +231,18 @@ This is a teaching example, not an audited exchange. Notably: ## 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 +- reserved-liquidity behaviour: profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity +- `initialize_pool`'s parameter checks, including an initial margin at or below the maintenance margin and a price band outside its range +- 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..55493ce8 100644 --- a/finance/perpetual-futures/anchor-v1/TERMINOLOGY.md +++ b/finance/perpetual-futures/anchor-v1/TERMINOLOGY.md @@ -2,36 +2,47 @@ 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 +- **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 +- **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..706426f4 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 @@ -33,10 +33,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..5a18aa40 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, @@ -58,4 +58,13 @@ 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, } 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..0d0b9a8d 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,9 @@ 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::{ + basis_points_of, refresh_price_and_funding_within_band, settle_position, +}; use crate::state::{Pool, Position}; pub fn handle_close_position( @@ -14,7 +16,7 @@ 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)?; let position = &context.accounts.position; let position_size = position.size; 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..a6c8ffe5 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,22 @@ 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, } pub fn handle_initialize_pool( @@ -38,10 +48,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 +81,40 @@ 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 ); + // 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(); @@ -98,14 +132,18 @@ pub fn handle_initialize_pool( 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.bump = context.bumps.pool; Ok(()) @@ -128,8 +166,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/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..0e1554c0 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, 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,21 +34,28 @@ 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); + let required_scaled = (size as u128) + .checked_mul(pool.initial_margin_bps as u128) + .ok_or(PerpError::MathOverflow)?; + require!( + collateral_scaled >= required_scaled, + PerpError::InitialMarginNotMet + ); // Reserve liquidity to cover this position's maximum recoverable profit // (its notional `size`). The reserve must be backed by liquidity-provider 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..500c119d 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)?; 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..53e9665e 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,8 @@ use anchor_lang::prelude::*; -use crate::constants::{BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, SIZE_PRECISION}; +use crate::constants::{ + BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, PRICE_AVERAGE_WINDOW_SECONDS, SIZE_PRECISION, +}; use crate::errors::PerpError; use crate::state::{Pool, Position, Side}; @@ -216,16 +218,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..36535302 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 @@ -71,6 +71,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 +97,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 +113,10 @@ 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, + /// 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/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs index 62876964..ef1d5dfd 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,6 +21,9 @@ 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; // Collateral token has 6 decimals (like USDC), so one whole unit is 1_000_000 @@ -52,6 +59,37 @@ 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, a 10% initial margin (10x leverage), a 5% maintenance +/// margin, a 1% liquidation fee, a 1% maximum confidence band, and a 20% price +/// band around the pool's average price. +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, + } +} + +/// 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 +107,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 +196,7 @@ impl Market { &[&admin], &admin.pubkey(), ) - .map_err(|_| ())?; + .map_err(|error| format!("{error:?}"))?; Ok(Market { svm, @@ -275,7 +304,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 +334,7 @@ impl Market { &[provider], &provider.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn remove_liquidity( @@ -315,7 +343,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 +373,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 +396,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 +427,7 @@ impl Market { &[trader], &trader.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn close_position( @@ -410,7 +436,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 +461,7 @@ impl Market { &[trader], &trader.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn liquidate( @@ -445,7 +470,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 +498,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 +524,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 +579,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); @@ -849,17 +915,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 +1163,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 @@ -1300,8 +1402,11 @@ fn test_profit_capped_at_reserved_notional() { .unwrap(); // Price triples: uncapped profit would be 2x the notional, but recoverable - // profit is capped at the reserved notional (`size`). + // profit is capped at the reserved notional (`size`). A move this large is + // far outside the price band, so the average has to catch up before the + // position can close. market.set_price(dollars(300)); + market.settle_average_at(dollars(300)); market .close_position(&trader, trader_collateral, Side::Long, 0) .unwrap(); @@ -1350,14 +1455,304 @@ 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_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) }; - assert!(Market::try_new(dollars(100), parameters).is_err()); + 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, the close goes through and pays the 15% gain. + 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..80b6e1ee 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -1,5 +1,63 @@ # 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_capped_at_reserved_notional` 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. + ## 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..2551e37d 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 @@ -54,10 +54,25 @@ Funding runs on the wall clock rather than the slot count, so what a position co 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. +`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. @@ -74,7 +89,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, a 5% maintenance margin, a 1% liquidation fee, a 1% maximum oracle confidence band, and a 20% price band around its average price. --- @@ -82,9 +97,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, reserved liquidity, collateral total, per-side open-interest accumulators, funding index, average oracle price, 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 - `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 @@ -137,7 +154,7 @@ 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. +$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` (well under the $5,000 reserve cap), minus the $5 close fee. **Accounts modified:** @@ -145,6 +162,8 @@ Her profit is `5,000 × (116 − 100) / 100 = $800` (well under the $5,000 reser - `Pool.reserved_liquidity`: −$5,000 (reserve released) - `Pool.total_collateral`: −$995 - `Pool.program_fees`: +$5 +- `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 @@ -195,7 +214,7 @@ The genuinely hard part of a perpetual-futures venue is keeping it solvent and p - **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, auto-deleveraging (ADL) and an insurance fund for the bad-debt tail, 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. --- @@ -212,7 +231,18 @@ This is a teaching example, not an audited exchange. Notably: ## 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 +- reserved-liquidity behaviour: profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity +- `initialize_pool`'s parameter checks, including an initial margin at or below the maintenance margin and a price band outside its range +- fee collection ```bash anchor build diff --git a/finance/perpetual-futures/anchor/TERMINOLOGY.md b/finance/perpetual-futures/anchor/TERMINOLOGY.md index 26c7bd5e..55493ce8 100644 --- a/finance/perpetual-futures/anchor/TERMINOLOGY.md +++ b/finance/perpetual-futures/anchor/TERMINOLOGY.md @@ -2,36 +2,47 @@ 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 +- **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 +- **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..706426f4 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs @@ -33,10 +33,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..5a18aa40 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, @@ -58,4 +58,13 @@ 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, } 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..5440a8b7 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,9 @@ 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::{ + basis_points_of, refresh_price_and_funding_within_band, settle_position, +}; use crate::state::{Pool, Position}; pub fn handle_close_position( @@ -14,7 +16,7 @@ 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)?; let position = &context.accounts.position; let position_size = position.size; 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..2c096339 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,22 @@ 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, } pub fn handle_initialize_pool( @@ -40,10 +50,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 +83,40 @@ 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 ); + // 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(); @@ -100,14 +134,18 @@ pub fn handle_initialize_pool( 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.bump = context.bumps.pool; Ok(()) @@ -130,8 +168,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/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..8cf66d3a 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, 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,21 +34,28 @@ 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); + let required_scaled = (size as u128) + .checked_mul(pool.initial_margin_bps as u128) + .ok_or(PerpError::MathOverflow)?; + require!( + collateral_scaled >= required_scaled, + PerpError::InitialMarginNotMet + ); // Reserve liquidity to cover this position's maximum recoverable profit // (its notional `size`). The reserve must be backed by liquidity-provider 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..c3cf66c5 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)?; 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..38d2ce17 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs @@ -1,6 +1,8 @@ use anchor_lang::prelude::*; -use crate::constants::{BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, SIZE_PRECISION}; +use crate::constants::{ + BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, PRICE_AVERAGE_WINDOW_SECONDS, SIZE_PRECISION, +}; use crate::errors::PerpError; use crate::state::{Pool, Position, Side}; @@ -216,16 +218,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..ad7993d5 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 @@ -71,6 +71,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 +97,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 +113,10 @@ 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, + /// 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/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs index 9cfc0584..791fbb1b 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,6 +19,9 @@ 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; // Collateral token has 6 decimals (like USDC), so one whole unit is 1_000_000 @@ -50,6 +57,37 @@ 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, a 10% initial margin (10x leverage), a 5% maintenance +/// margin, a 1% liquidation fee, a 1% maximum confidence band, and a 20% price +/// band around the pool's average price. +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, + } +} + +/// 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 +105,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 +194,7 @@ impl Market { &[&admin], &admin.pubkey(), ) - .map_err(|_| ())?; + .map_err(|error| format!("{error:?}"))?; Ok(Market { svm, @@ -273,7 +302,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 +332,7 @@ impl Market { &[provider], &provider.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn remove_liquidity( @@ -313,7 +341,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 +371,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 +394,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 +425,7 @@ impl Market { &[trader], &trader.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn close_position( @@ -408,7 +434,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 +459,7 @@ impl Market { &[trader], &trader.pubkey(), ) - .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } fn liquidate( @@ -443,7 +468,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 +496,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 +522,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 +577,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); @@ -847,17 +913,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 +1158,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 @@ -1295,8 +1397,11 @@ fn test_profit_capped_at_reserved_notional() { .unwrap(); // Price triples: uncapped profit would be 2x the notional, but recoverable - // profit is capped at the reserved notional (`size`). + // profit is capped at the reserved notional (`size`). A move this large is + // far outside the price band, so the average has to catch up before the + // position can close. market.set_price(dollars(300)); + market.settle_average_at(dollars(300)); market .close_position(&trader, trader_collateral, Side::Long, 0) .unwrap(); @@ -1345,14 +1450,304 @@ 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_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) }; - assert!(Market::try_new(dollars(100), parameters).is_err()); + 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, the close goes through and pays the 15% gain. + 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..dbef0377 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -1,5 +1,65 @@ # 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_is_capped_at_the_reserved_notional` 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. + ## 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..6638b370 100644 --- a/finance/perpetual-futures/quasar/README.md +++ b/finance/perpetual-futures/quasar/README.md @@ -30,10 +30,32 @@ 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, reserved liquidity, and fee collection + +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) and +`PRICE_OUTSIDE_BAND` (21) among them. `update_price_average` is discriminator 7. ```bash cargo build-sbf diff --git a/finance/perpetual-futures/quasar/src/constants.rs b/finance/perpetual-futures/quasar/src/constants.rs index c0ff398d..3bb38298 100644 --- a/finance/perpetual-futures/quasar/src/constants.rs +++ b/finance/perpetual-futures/quasar/src/constants.rs @@ -21,8 +21,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..07024da6 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, + basis_points_of, err, error, 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, diff --git a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs index 6e0f5735..ca466763 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,11 @@ 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, bumps: &InitializePoolBumps, ) -> Result<(), ProgramError> { let denominator = BASIS_POINTS_DENOMINATOR as u16; @@ -67,9 +69,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 +86,34 @@ 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)); } + // 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(), @@ -109,13 +132,17 @@ pub fn handle_initialize_pool( 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, bump: bumps.pool, }); Ok(()) 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..eaf87781 100644 --- a/finance/perpetual-futures/quasar/src/instructions/open_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/open_position.rs @@ -1,8 +1,8 @@ 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, err, error, refresh_price_and_funding_within_band, scale_size, }, state::{Pool, Position, PositionInner}, }, @@ -58,7 +58,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,24 +76,29 @@ 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)); + let required_scaled = (size as u128) + .checked_mul(accounts.pool.initial_margin_bps.get() as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; + if collateral_scaled < required_scaled { + return Err(err(error::INITIAL_MARGIN_NOT_MET)); } // Reserve liquidity to cover this position's maximum recoverable profit diff --git a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs index 20ce56e4..5eeff7af 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, diff --git a/finance/perpetual-futures/quasar/src/instructions/shared.rs b/finance/perpetual-futures/quasar/src/instructions/shared.rs index f4a08c1a..4dca8c2b 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, 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,9 @@ 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; } #[inline(always)] @@ -267,30 +270,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..dfbfee80 100644 --- a/finance/perpetual-futures/quasar/src/lib.rs +++ b/finance/perpetual-futures/quasar/src/lib.rs @@ -40,10 +40,11 @@ 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, ) -> Result<(), ProgramError> { instructions::handle_initialize_pool( &mut ctx.accounts, @@ -51,10 +52,11 @@ 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, &ctx.bumps, ) } @@ -122,4 +124,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..dea3c04b 100644 --- a/finance/perpetual-futures/quasar/src/state.rs +++ b/finance/perpetual-futures/quasar/src/state.rs @@ -32,17 +32,37 @@ 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, pub bump: u8, } diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index e51ba944..0ca5c8ad 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, and the +//! oracle/margin/reserve checks. 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,6 +44,11 @@ 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]); + +// 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; @@ -115,18 +122,40 @@ 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, 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 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, + } +} + +/// 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 +166,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,9 +174,7 @@ 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); + add_pool_prerequisites(test); init_pool_with_funding(test, 500, 10, funding_rate_per_second).succeeds(); let pool = test.derive_pda(Pool::seeds(&COLLATERAL_MINT, &FEED)); @@ -209,6 +235,35 @@ fn open_position(test: &mut Test, env: &Env, side: u8, collateral: u64, size: u6 }) } +/// 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 { test.send(ClosePositionInstruction { owner: TRADER, @@ -409,16 +464,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 ); } @@ -476,14 +544,6 @@ fn collect_fees_sweeps_the_open_fee_to_the_admin(test: &mut Test) { .has_tokens(ADMIN_COLLATERAL, size / 1_000); } -/// 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 +593,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(); } @@ -641,8 +697,13 @@ fn profit_is_capped_at_the_reserved_notional(test: &mut Test) { 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`). + // profit is capped at the reserved notional (`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. 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; @@ -676,11 +737,261 @@ 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, the close goes through and pays the 15% gain. + 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); }