Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to this repository are documented here.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [2026-06-29] - Perpetual-futures Percolator risk model

### Changed

- `finance/perpetual-futures` (both the Anchor and Quasar implementations) replaced its reserve-and-cap risk model with the haircut model adapted from [Percolator](https://github.com/aeyakovenko/percolator). Trader profit is now a junior claim: positions open with no up-front reserve and no open-interest cap, profit runs uncapped, and solvency is held at exit by a global haircut ratio `h` that scales every winner's profit to the backing the pool can cover. The `Pool.reserved_liquidity` field and the per-position profit cap were removed; provider withdrawals are now gated by the profit traders are currently owed rather than by a reserve.
- Liquidating a position that gapped through zero equity now draws the deficit from the insurance fund before socializing any of it to liquidity providers.

### Added

- Profit maturation: positions carry an `entry_slot` and cannot be closed in profit until `profit_warmup_slots` have elapsed, an oracle-manipulation defense. Loss is never gated.
- An insurance fund (`Pool.insurance_fund`), funded by an `insurance_fee_bps` cut of every open/close fee, which absorbs bankruptcy deficits and counts as backing in the haircut.
- Tests in both implementations for the haircut, maturation, withdrawal guard, and insurance fund; READMEs and `TERMINOLOGY.md` document the model and why Percolator's peer-to-peer `A`/`K` overhang indices do not map onto a single-counterparty pool.

## [2026-06-12] - Rust + LiteSVM tests everywhere

### Changed
Expand Down
68 changes: 46 additions & 22 deletions finance/perpetual-futures/anchor/README.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions finance/perpetual-futures/anchor/TERMINOLOGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ Terms used in this example, in the sense they carry here.
a position must keep to avoid liquidation.
- **Liquidation** — closing an under-margined position. Permissionless here: any
caller can trigger it and earns the liquidation fee.
- **Senior / junior claim** — a trader's deposited collateral is *senior*: always
reclaimable in full. Their profit is *junior*: only as real as the backing
behind it, and scaled down by the haircut when the pool is stressed.
- **Haircut ratio (`h`)** — a single global number, between zero and one, that
every closing winner's profit is multiplied by. One when the pool can back all
profit; below one when it cannot, the same fraction for everyone — no queue, no
singled-out trader.
- **Profit maturation (warm-up)** — profit cannot be realized until a position has
been open `profit_warmup_slots` slots. An oracle spike's paper gain cannot be
cashed out before the manipulation passes. Loss is never gated this way.
- **Insurance fund** — a senior buffer, funded by a cut of fees, that absorbs a
bankrupt position's deficit before liquidity providers do, and counts as
backing for trader profit in the haircut.
- **Funding** — a periodic payment that anchors the pool's risk. The heavier
side of open interest pays funding to the pool over time.
- **Open interest** — the total notional size currently open on a side.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ pub const FUNDING_PRECISION: i128 = 1_000_000_000;
/// from two running sums instead of iterating every open position.
pub const SIZE_PRECISION: u128 = 1_000_000_000;

/// Fixed-point precision for the haircut ratio `h`. The ratio is carried scaled
/// by this factor: `HAIRCUT_PRECISION` means `h = 1` (profit fully backed), and a
/// smaller value means profit is honoured only in proportion. Profit (a junior
/// claim) is multiplied by `h` and divided by this on the way out, rounding down
/// so the haircut payouts can never sum to more than the pool actually holds.
pub const HAIRCUT_PRECISION: u128 = 1_000_000_000;

/// Liquidity-provider shares withheld from the first deposit. The first
/// depositor receives `deposit - MINIMUM_LIQUIDITY` shares rather than the full
/// amount, the same convention Uniswap V2 uses, so the share supply can never be
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ pub enum PerpError {
#[msg("Position equity is below maintenance margin; it must be liquidated, not closed")]
PositionNotHealthy,

#[msg("Profit has not matured yet; wait out the warm-up period before closing in profit")]
ProfitNotMatured,

#[msg("No protocol fees are available to collect")]
NothingToClaim,
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ use anchor_spl::{

use crate::constants::{AUTHORITY_SEED, POOL_SEED, POSITION_SEED, VAULT_SEED};
use crate::errors::PerpError;
use crate::instructions::shared::{basis_points_of, refresh_price_and_funding, settle_position};
use crate::instructions::shared::{
apply_haircut, basis_points_of, haircut_ratio, refresh_price_and_funding, settle_position,
split_fee,
};
use crate::state::{Pool, Position};

pub fn handle_close_position(
Expand All @@ -16,15 +19,30 @@ pub fn handle_close_position(
let pool = &mut context.accounts.pool;
let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?;

// Compute the haircut against the whole pool *before* this position leaves
// the accumulators, so the closer is one of the winners being scaled rather
// than scaling only those left behind.
let haircut = haircut_ratio(pool, price)?;

let position = &context.accounts.position;
let position_size = position.size;
let entry_slot = position.entry_slot;
let settlement = settle_position(pool, position, price)?;
let close_fee = basis_points_of(position_size, pool.close_fee_bps)?;

// Recoverable profit is capped at the reserved amount (the position's
// notional `size`), so the pool can always cover a winner. Losses are not
// capped.
let realized_pnl = settlement.profit_and_loss.min(position_size as i128);
// Profit is a junior claim, gated twice before it is paid; a loss settles in
// full and skips both gates. First it must have *matured* — the warm-up
// since open must have elapsed, so a freshly minted oracle gain cannot be
// cashed out in the block it appears. Then it is *haircut* to the fraction
// `h` the pool can currently back, the same fraction for every winner.
let realized_pnl = if settlement.profit_and_loss > 0 {
let matured =
Clock::get()?.slot >= entry_slot.saturating_add(pool.profit_warmup_slots);
require!(matured, PerpError::ProfitNotMatured);
apply_haircut(settlement.profit_and_loss, haircut)?
} else {
settlement.profit_and_loss
};
let equity = settlement
.equity
.checked_sub(settlement.profit_and_loss)
Expand All @@ -42,14 +60,12 @@ pub fn handle_close_position(
let payout: u64 = payout.try_into().map_err(|_| PerpError::MathOverflow)?;
require!(payout >= minimum_payout, PerpError::SlippageExceeded);

// Release the position's reserved liquidity now that it is closing.
pool.reserved_liquidity = pool
.reserved_liquidity
.checked_sub(position_size)
.ok_or(PerpError::MathOverflow)?;
let (insurance_cut, protocol_cut) = split_fee(close_fee, pool.insurance_fee_bps)?;

// Liquidity providers are the counterparty: they pay the trader's (capped)
// Liquidity providers are the counterparty: they pay the trader's (haircut)
// profit and receive their loss, and collect the funding the trader owed.
// The part of a winner's profit withheld by the haircut stays in liquidity —
// the pool-model way bankruptcy overhang is socialized to providers.
let liquidity_delta = settlement
.funding
.checked_sub(realized_pnl)
Expand All @@ -63,7 +79,11 @@ pub fn handle_close_position(
.map_err(|_| PerpError::MathOverflow)?;
pool.protocol_fees = pool
.protocol_fees
.checked_add(close_fee)
.checked_add(protocol_cut)
.ok_or(PerpError::MathOverflow)?;
pool.insurance_fund = pool
.insurance_fund
.checked_add(insurance_cut)
.ok_or(PerpError::MathOverflow)?;

let pool_key = pool.key();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ pub struct PoolParameters {

/// Maximum oracle confidence band tolerated, in basis points of the price.
pub max_confidence_bps: u16,

/// Fraction of every open/close fee, in basis points, routed to the
/// insurance fund rather than to protocol fees.
pub insurance_fee_bps: u16,

/// Slots a position must stay open before its profit can be realized.
pub profit_warmup_slots: u64,
}

pub fn handle_initialize_pool(
Expand Down Expand Up @@ -74,6 +81,13 @@ pub fn handle_initialize_pool(
parameters.max_confidence_bps > 0 && parameters.max_confidence_bps < denominator,
PerpError::InvalidParameter
);
// The insurance cut is a fraction of the fee, so it cannot exceed the whole
// fee. `denominator` (100%) would route every fee to insurance, leaving the
// protocol nothing — allowed, but anything above it is meaningless.
require!(
parameters.insurance_fee_bps <= denominator,
PerpError::InvalidParameter
);

let pool = &mut context.accounts.pool;
pool.authority = context.accounts.authority.key();
Expand All @@ -83,7 +97,7 @@ pub fn handle_initialize_pool(
pool.custody_vault = context.accounts.custody_vault.key();
pool.lp_mint = context.accounts.lp_mint.key();
pool.liquidity = 0;
pool.reserved_liquidity = 0;
pool.insurance_fund = 0;
pool.total_collateral = 0;
pool.protocol_fees = 0;
pool.long_size = 0;
Expand All @@ -99,6 +113,8 @@ pub fn handle_initialize_pool(
pool.maintenance_margin_bps = parameters.maintenance_margin_bps;
pool.liquidation_fee_bps = parameters.liquidation_fee_bps;
pool.max_confidence_bps = parameters.max_confidence_bps;
pool.insurance_fee_bps = parameters.insurance_fee_bps;
pool.profit_warmup_slots = parameters.profit_warmup_slots;
pool.bump = context.bumps.pool;
pool.authority_bump = context.bumps.pool_authority;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,9 @@ pub fn handle_liquidate_position(

let position = &context.accounts.position;
let position_size = position.size;
let position_collateral = position.collateral;
let settlement = settle_position(pool, position, price)?;

// Release the position's reserved liquidity now that it is closing.
pool.reserved_liquidity = pool
.reserved_liquidity
.checked_sub(position_size)
.ok_or(PerpError::MathOverflow)?;

// Liquidatable only once equity has fallen to or below the maintenance
// margin. A healthy position can only be closed by its owner.
let maintenance = basis_points_of(position_size, pool.maintenance_margin_bps)?;
Expand All @@ -40,17 +35,35 @@ pub fn handle_liquidate_position(
.max(0)
.try_into()
.map_err(|_| PerpError::MathOverflow)?;
let liquidation_fee = basis_points_of(position.size, pool.liquidation_fee_bps)?;
let liquidation_fee = basis_points_of(position_size, pool.liquidation_fee_bps)?;
let liquidator_payout = liquidation_fee.min(remaining_equity);
let trader_refund = remaining_equity
.checked_sub(liquidator_payout)
.ok_or(PerpError::MathOverflow)?;

// A position that gapped through zero equity owes more than its collateral.
// That deficit is drawn from the insurance fund first; only what the fund
// cannot cover is socialized to liquidity providers.
let deficit: u64 = settlement
.equity
.min(0)
.unsigned_abs()
.try_into()
.map_err(|_| PerpError::MathOverflow)?;
let insurance_drawn = deficit.min(pool.insurance_fund);
pool.insurance_fund = pool
.insurance_fund
.checked_sub(insurance_drawn)
.ok_or(PerpError::MathOverflow)?;

// Everything the trader does not get back stays with the liquidity
// providers. Derived from vault conservation: the pool keeps the position's
// collateral minus whatever is paid out as equity.
let liquidity_delta = (position.collateral as i128)
// providers, topped up by the insurance draw. Derived from vault
// conservation: the pool keeps the position's collateral minus whatever is
// paid out as equity, plus the insurance that absorbed the deficit.
let liquidity_delta = (position_collateral as i128)
.checked_sub(remaining_equity as i128)
.ok_or(PerpError::MathOverflow)?
.checked_add(insurance_drawn as i128)
.ok_or(PerpError::MathOverflow)?;
let new_liquidity = (pool.liquidity as i128)
.checked_add(liquidity_delta)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use anchor_spl::{

use crate::constants::{POOL_SEED, POSITION_SEED, VAULT_SEED};
use crate::errors::PerpError;
use crate::instructions::shared::{basis_points_of, refresh_price_and_funding, scale_size};
use crate::instructions::shared::{basis_points_of, refresh_price_and_funding, scale_size, split_fee};
use crate::state::{Pool, Position, Side};

pub fn handle_open_position(
Expand Down Expand Up @@ -48,20 +48,13 @@ pub fn handle_open_position(
let maintenance = basis_points_of(size, pool.maintenance_margin_bps)?;
require!(net_collateral > maintenance, PerpError::PositionNotHealthy);

// Reserve liquidity to cover this position's maximum recoverable profit
// (its notional `size`). The reserve must be backed by liquidity-provider
// capital, which also caps total open interest at the pool's liquidity.
let new_reserved = pool
.reserved_liquidity
.checked_add(size)
.ok_or(PerpError::MathOverflow)?;
require!(
new_reserved <= pool.liquidity,
PerpError::InsufficientLiquidity
);
pool.reserved_liquidity = new_reserved;

// No open-interest cap: a position can open even when the pool could not
// cover its full winnings. Profit is a junior claim — if traders end up
// collectively owed more than the pool holds, the haircut `h` scales every
// winner's profit to the available backing rather than reserving capital up
// front. Solvency is preserved at exit, not gated at entry.
let size_scaled = scale_size(size, price)?;
let (insurance_cut, protocol_cut) = split_fee(open_fee, pool.insurance_fee_bps)?;

// Effects: record the position and the pool's new aggregates before moving
// any tokens.
Expand All @@ -74,6 +67,7 @@ pub fn handle_open_position(
position.entry_price = price;
position.size_scaled = size_scaled;
position.entry_funding = pool.cumulative_funding;
position.entry_slot = Clock::get()?.slot;
position.bump = context.bumps.position;

pool.total_collateral = pool
Expand All @@ -82,7 +76,11 @@ pub fn handle_open_position(
.ok_or(PerpError::MathOverflow)?;
pool.protocol_fees = pool
.protocol_fees
.checked_add(open_fee)
.checked_add(protocol_cut)
.ok_or(PerpError::MathOverflow)?;
pool.insurance_fund = pool
.insurance_fund
.checked_add(insurance_cut)
.ok_or(PerpError::MathOverflow)?;

match side {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ use anchor_spl::{

use crate::constants::{AUTHORITY_SEED, POOL_SEED, VAULT_SEED};
use crate::errors::PerpError;
use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding};
use crate::instructions::shared::{
liquidity_provider_aum, pool_profit_liability, refresh_price_and_funding,
};
use crate::state::Pool;

pub fn handle_remove_liquidity(
Expand All @@ -35,15 +37,14 @@ pub fn handle_remove_liquidity(
.map_err(|_| PerpError::MathOverflow)?;

require!(amount_out > 0, PerpError::AmountRoundsToZero);
// Only free liquidity can leave: the portion reserved to cover open
// positions' payouts stays put, so a winning trader can always be paid. A
// provider wanting more must wait for positions to close.
let free_liquidity = pool
.liquidity
.checked_sub(pool.reserved_liquidity)
.ok_or(PerpError::MathOverflow)?;
// Only free liquidity can leave: the backing for the profit traders are
// currently owed stays put, so providers cannot withdraw out from under a
// winning trader and force their haircut. Profit traders are owed beyond
// what liquidity can cover already has `h < 1`, leaving no free liquidity.
let liability = pool_profit_liability(pool, price)?;
let free_liquidity = (pool.liquidity as u128).saturating_sub(liability);
require!(
amount_out <= free_liquidity,
amount_out as u128 <= free_liquidity,
PerpError::InsufficientLiquidity
);
require!(
Expand Down
Loading
Loading