Skip to content
Merged
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
117 changes: 117 additions & 0 deletions finance/perpetual-futures/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,122 @@
# Changelog

## 2026-10-01

Replace the leverage cap with an initial margin. `max_leverage` on
`PoolParameters` and `Pool` is now `initial_margin_bps`, the net collateral a
position must post to open, in basis points of its size (1,000 is 10x).
`initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <=
10_000`, refusing an initial margin at or below the maintenance margin with the
new `InitialMarginNotAboveMaintenance` and one above 10,000 with
`InvalidParameter`; `MAX_LEVERAGE_CEILING` is removed. `open_position` checks
`net_collateral * 10_000 >= size * initial_margin_bps` and fails with
`InitialMarginNotMet`, which takes `LeverageTooHigh`'s place and its error code
(6004). Its separate check that a new position starts above the maintenance
margin is removed, because the initial margin implies it; `PositionNotHealthy`
remains for `close_position`.

Add a price band around a program-maintained average price. A fresh, confident
oracle print could still be wrong, and every handler traded at it. The pool now
keeps `average_price`, a time-weighted moving average of the oracle price,
`last_oracle_price`, the price at the most recent oracle read, and
`average_price_timestamp`. `initialize_pool` seeds the average and
`last_oracle_price` from the oracle. Every handler that reads the oracle credits
the seconds since the previous read to the price that read saw,
`average += (last_oracle_price - average) * min(elapsed,
PRICE_AVERAGE_WINDOW_SECONDS) / PRICE_AVERAGE_WINDOW_SECONDS`, with the new
constant at 600 seconds, and then records the price it read as
`last_oracle_price`. The price read now only counts from now, so a pool left
idle for a window or more cannot have its average set by one read of a
manipulated price: that price moves the average only if the oracle still shows
it at a later read, weighted by the seconds between the two reads.
`open_position`, `close_position`, `add_liquidity` and `remove_liquidity` refuse
a price outside `|price - average_price| * 10_000 <= average_price *
max_price_deviation_bps` with the new `PriceOutsideBand`, checked against the
stored average before anything is folded in. `liquidate_position` folds and
records without the check. The new permissionless `update_price_average`
handler folds and records too, also without the check, so keepers calling it
repeatedly as time passes can walk the average to a genuine move. `max_price_deviation_bps` is a new
`PoolParameters` field, which `initialize_pool` requires to be above zero and
below 10,000 with the new `InvalidPriceDeviation`. `shared.rs` has
`refresh_price_and_funding_within_band` for the four band-checked handlers
beside `refresh_price_and_funding` for the other two. The `errors` module is
public so the tests can match `PerpError` codes.

Tested by `test_open_rejects_position_below_initial_margin` (formerly
`test_open_rejects_excess_leverage`, now checking both sides of the boundary),
`test_initialize_pool_rejects_initial_margin_at_or_below_maintenance`,
`test_initialize_pool_rejects_price_deviation_outside_range`,
`test_open_rejected_when_oracle_jumps_outside_band`,
`test_close_rejected_when_oracle_jumps_outside_band`,
`test_liquidity_changes_rejected_when_oracle_jumps_outside_band`,
`test_liquidation_runs_outside_band`,
`test_price_average_catches_up_after_genuine_move`,
`test_single_update_moves_average_by_elapsed_fraction` and
`test_one_manipulated_read_after_idle_does_not_move_average`. The default test market
uses a 1,000 basis point initial margin and a 2,000 basis point band;
`test_profit_runs_uncapped_when_backed` triples the price, far outside the
band, so it now calls `update_price_average` to record the new price, lets a
full window pass, and calls it again before closing.

Replace reserved liquidity with the haircut risk model from
[Percolator](https://github.com/aeyakovenko/percolator): trader collateral is
senior, and trader profit is junior, paid only as far as the pool can back it.
`Pool.reserved_liquidity` is removed, and with it `open_position`'s
`reserved + size <= liquidity` check, which failed with `InsufficientLiquidity`,
and `close_position`'s cap on profit at the position's size. A position opens
whatever the pool's liquidity, and profit has no cap. `close_position` computes
the haircut ratio `h = min(1, (liquidity + insurance_fund) /
max(0, traders' aggregate unrealized profit, closing position's profit))` from
the per-side accumulators, before the closing position leaves them, and pays a
winning position `profit * h / HAIRCUT_PRECISION`, rounded down, with the new
constant at 10^9, so every winner closing at the same moment is paid the same
fraction; a loss settles in full. A winner who closes while open losers still
offset them is paid at most the pool's backing rather than refused, and every
other winner's fraction is unchanged. The profit is paid from `liquidity` first
and from the insurance fund for the rest; `PoolInsolvent` remains as a
defensive check.
`remove_liquidity` caps a withdrawal at `liquidity` rather than `liquidity -
reserved_liquidity`, still failing with `InsufficientLiquidity`, whose message
now says the withdrawal is larger than the pool's liquidity. `shared.rs` has
the new `haircut_ratio` and `apply_haircut`.

Add an insurance fund. `Pool.insurance_fund` is new, and so is
`PoolParameters.insurance_fee_bps`, which `initialize_pool` requires to be below
10,000 or fails with `InvalidParameter`. That fraction of every open and close
fee goes to the fund, rounded down, and the rest to `program_fees`, through the
new `split_fee` and `credit_fee` in `shared.rs`. `liquidate_position` takes a
position's deficit, its loss beyond its collateral, from the fund first and
credits what the fund pays to `liquidity`; the providers bear the rest. The
liquidation fee is still paid only out of the position's remaining equity: the
part the equity cannot cover is forgiven, as in Percolator, and neither the
insurance fund nor `liquidity` pays it. The vault holds `liquidity +
total_collateral + program_fees + insurance_fund`, plus any tokens sent to it
directly.

Add a profit warm-up. `PoolParameters.profit_warmup_slots` and
`Position.entry_slot`, which `open_position` sets to the current slot, are new.
`close_position` refuses to pay a profit before slot `entry_slot +
profit_warmup_slots` with the new `ProfitNotMatured` (6022). A losing position
closes at any time, and liquidation is not delayed.

Tested by `test_open_allowed_without_full_backing`,
`test_profit_runs_uncapped_when_backed`,
`test_haircut_scales_profit_when_pool_stressed`,
`test_insurance_pays_profit_beyond_liquidity`,
`test_winner_offset_by_open_loser_is_paid_not_refused`,
`test_remove_liquidity_capped_at_liquidity`,
`test_profit_blocked_before_maturation`,
`test_profit_realized_after_maturation`, `test_loss_not_gated_by_maturation`,
`test_insurance_fund_funded_by_fees`, `test_insurance_absorbs_bankruptcy_deficit`,
`test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity` and
`test_initialize_pool_rejects_insurance_fee_at_or_above_full_fee`. They replace
`test_open_rejects_when_pool_cannot_back_it`,
`test_profit_capped_at_reserved_notional` and
`test_remove_liquidity_blocked_by_reserved`. The default test market pays half
of each fee into the insurance fund and has a 10-slot warm-up, so the tests that
close at a profit first let the warm-up pass, and `test_open_long_updates_pool`
checks the fee split.

## 2026-09-30

Remove `set_funding_rate`. The pool's authority could change the funding rate at
Expand Down
Loading
Loading