From dd31c45f6b7b3ac34afa4820de4c97880d756fa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 02:43:27 +0000 Subject: [PATCH 1/2] lending: make slots-per-year configuration, not a constant Turning a reserve's APR into the per-slot rate interest actually accrues at needs a slots-per-year divisor, and that divisor is the cluster's slot time in disguise. Both ports carried it as SLOTS_PER_YEAR = 78_840_000, fixed at a 400ms slot. Solana is lowering the slot time, and a lower one raises the wall-clock rate every borrower pays with no line of either program changing and nothing in the config to show for it. Anchor: ReserveConfig gains slots_per_year, validate() rejects zero, and the owner retunes through the existing update_reserve_config. Quasar: Reserve gains slots_per_year, initialize_reserve takes it, and a new owner-only update_slots_per_year (discriminator 12) retunes it. That handler accrues at the old figure before storing the new one, so slots already elapsed are charged at the rate that was in force for them rather than repriced. Also drop the "at 400ms/slot" gloss from the price-staleness comments in lending, prop-amm and perpetual-futures. The window is counted in slots on purpose; what it comes to in seconds follows the cluster and tightens on its own. --- finance/lending/anchor/CHANGELOG.md | 12 +++ finance/lending/anchor/README.md | 10 +++ .../anchor/programs/lending/src/constants.rs | 12 ++- .../programs/lending/src/state/reserve.rs | 16 +++- .../programs/lending/tests/common/mod.rs | 8 ++ .../programs/lending/tests/test_interest.rs | 7 +- .../programs/lending/tests/test_reserve.rs | 76 ++++++++++++++++++- finance/lending/quasar/CHANGELOG.md | 21 +++++ finance/lending/quasar/README.md | 12 ++- finance/lending/quasar/src/constants.rs | 9 ++- .../lending/quasar/src/instructions/admin.rs | 36 +++++++++ finance/lending/quasar/src/lib.rs | 10 +++ finance/lending/quasar/src/logic.rs | 2 + finance/lending/quasar/src/math.rs | 10 ++- finance/lending/quasar/src/state.rs | 7 ++ finance/lending/quasar/src/tests.rs | 57 ++++++++++++++ .../perpetual-futures/src/constants.rs | 5 +- .../perpetual-futures/quasar/src/constants.rs | 4 +- .../anchor/programs/prop-amm/src/constants.rs | 8 +- finance/prop-amm/quasar/src/constants.rs | 8 +- 20 files changed, 300 insertions(+), 30 deletions(-) diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index 8b95d1d5c..dab8f1686 100644 --- a/finance/lending/anchor/CHANGELOG.md +++ b/finance/lending/anchor/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026-08-14 + +Move slots-per-year out of the code and into the reserve config. Turning an APR +into the per-slot rate interest accrues at needs a slots-per-year divisor, and +that divisor is the cluster's slot time in disguise. It was a `SLOTS_PER_YEAR` +constant fixed at a 400ms slot, so a protocol change to the slot time would have +raised the wall-clock rate every borrower pays with no code change and nothing +to show for it. `ReserveConfig` now carries `slots_per_year`, `validate()` +rejects zero, and the market owner retunes it with `update_reserve_config`. +Tested by `slots_per_year_scales_the_per_slot_rate` and +`rejects_zero_slots_per_year`. + ## 2026-08-04 Reject oracle prices from before a cluster restart. A halt stops the slot diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index 50e07f7f6..4fe766a76 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -64,6 +64,16 @@ utilization. Each borrow stores its principal as **scaled debt** (principal รท index at borrow time), so every obligation's debt grows automatically as the index advances: no per-obligation accrual loop. +Those curve parameters are annual, and the conversion to a per-slot rate divides +by `config.slots_per_year`. That divisor is the cluster's slot time expressed as +a count, which is why it is configuration and not a constant: Solana lowers the +slot time over time, and a reserve left on an old figure charges borrowers more +per day than the APR it advertises, with nothing in the program changed to say +so. Read the current slot time off the cluster you deploy against (two +[`getBlockTime`](https://solana.com/docs/rpc/http/getblocktime) results a known +number of slots apart) and keep the reserve in step with +`update_reserve_config`. + ### Protocol fees (how the market earns) Borrowers owe the full interest, but suppliers don't receive all of it. On each diff --git a/finance/lending/anchor/programs/lending/src/constants.rs b/finance/lending/anchor/programs/lending/src/constants.rs index 3affc32e6..5da22e876 100644 --- a/finance/lending/anchor/programs/lending/src/constants.rs +++ b/finance/lending/anchor/programs/lending/src/constants.rs @@ -21,18 +21,16 @@ pub const FIXED_POINT_SCALE_DECIMALS: i32 = 18; /// Denominator for every basis-point config value. 100% == 10_000 bps. pub const BPS_DENOMINATOR: u128 = 10_000; -/// Slots per year, for turning an APR (in bps) into a per-slot rate. -/// Solana targets ~2.5 slots/second: 2.5 * 60 * 60 * 24 * 365 = 78_840_000. -pub const SLOTS_PER_YEAR: u128 = 78_840_000; - /// Maximum distinct reserves an obligation may use as collateral, and /// separately as borrows. Bounds the account size and the compute cost of /// refresh_obligation (which iterates every entry). pub const MAX_OBLIGATION_RESERVES: usize = 4; -/// A price feed older than this many slots is rejected as stale (~10s at 2.5 -/// slots/second). Freshness is measured in slots, not unix time, because the -/// runtime guarantees slot progression while the timestamp is validator-influenced. +/// A price feed older than this many slots is rejected as stale. Freshness is +/// measured in slots, not unix time, because the runtime guarantees slot +/// progression while the timestamp is validator-influenced. How long the window +/// is in seconds follows the cluster's slot time, which the protocol lowers over +/// time, so the window tightens on its own and never loosens. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 25; // PDA seeds. diff --git a/finance/lending/anchor/programs/lending/src/state/reserve.rs b/finance/lending/anchor/programs/lending/src/state/reserve.rs index c6648c956..b510a3c1e 100644 --- a/finance/lending/anchor/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor/programs/lending/src/state/reserve.rs @@ -1,6 +1,6 @@ use anchor_lang::prelude::*; -use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, RESERVE_SEED, SLOTS_PER_YEAR}; +use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, RESERVE_SEED}; use crate::errors::LendingError; use crate::math::{mul_div_ceil, mul_div_floor}; @@ -98,6 +98,14 @@ pub struct ReserveConfig { pub optimal_borrow_rate_bps: u16, /// Borrow APR at 100% utilization. pub max_borrow_rate_bps: u16, + /// Slots in a year: the divisor that turns the APR fields above into the + /// per-slot rate interest actually accrues at. This is the cluster's slot + /// time expressed as a count, so it belongs in configuration rather than in + /// a constant. The protocol lowers the slot time over time, and a value left + /// behind here charges borrowers at the wrong wall-clock rate while every + /// other number in this struct still reads correctly. The owner updates it + /// with `update_reserve_config` when the slot time changes. + pub slots_per_year: u64, } impl ReserveConfig { @@ -130,6 +138,8 @@ impl ReserveConfig { && self.optimal_borrow_rate_bps <= self.max_borrow_rate_bps, LendingError::InvalidConfig ); + // Zero would divide by zero when converting the APR to a per-slot rate. + require!(self.slots_per_year > 0, LendingError::InvalidConfig); Ok(()) } } @@ -202,9 +212,9 @@ impl Reserve { .ok_or(LendingError::MathOverflow)? }; - // apr_bps / (BPS_DENOMINATOR * SLOTS_PER_YEAR), carried at FIXED_POINT_SCALE. + // apr_bps / (BPS_DENOMINATOR * slots_per_year), carried at FIXED_POINT_SCALE. let per_year_denominator = BPS_DENOMINATOR - .checked_mul(SLOTS_PER_YEAR) + .checked_mul(self.config.slots_per_year as u128) .ok_or(LendingError::MathOverflow)?; mul_div_floor(apr_bps, FIXED_POINT_SCALE, per_year_denominator) } diff --git a/finance/lending/anchor/programs/lending/tests/common/mod.rs b/finance/lending/anchor/programs/lending/tests/common/mod.rs index 99210fe89..dcf51deb7 100644 --- a/finance/lending/anchor/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor/programs/lending/tests/common/mod.rs @@ -732,6 +732,13 @@ impl Env { /// A reasonable default reserve config: 75% LTV, 80% liquidation threshold, /// 5% bonus, 50% close factor, 10% reserve factor (protocol's cut of interest), /// kink at 80% utilization, 2%/20%/150% APR curve. +/// Slots in a year, which is how a reserve turns an APR into a per-slot rate. +/// 78_840_000 is a 400ms slot: 2.5 slots/second * 60 * 60 * 24 * 365. It is a +/// test fixture, not a law: a deployment reads the slot time off the cluster it +/// points at (two `getBlockTime` results a known number of slots apart) and +/// updates the reserve when the protocol changes it. +pub const SLOTS_PER_YEAR: u64 = 78_840_000; + pub fn default_config() -> ReserveConfig { ReserveConfig { loan_to_value_bps: 7_500, @@ -743,5 +750,6 @@ pub fn default_config() -> ReserveConfig { min_borrow_rate_bps: 200, optimal_borrow_rate_bps: 2_000, max_borrow_rate_bps: 15_000, + slots_per_year: SLOTS_PER_YEAR, } } diff --git a/finance/lending/anchor/programs/lending/tests/test_interest.rs b/finance/lending/anchor/programs/lending/tests/test_interest.rs index 5d8190262..15d6be350 100644 --- a/finance/lending/anchor/programs/lending/tests/test_interest.rs +++ b/finance/lending/anchor/programs/lending/tests/test_interest.rs @@ -1,6 +1,6 @@ mod common; -use common::{default_config, dollars, ata, Env}; +use common::{default_config, dollars, ata, Env, SLOTS_PER_YEAR}; use lending::constants::FIXED_POINT_SCALE; use solana_signer::Signer; @@ -31,8 +31,9 @@ fn interest_accrues_on_borrows_over_time() { assert_eq!(env.reserve(&borrow).borrow_accumulation_factor, FIXED_POINT_SCALE); - // Let ~0.1 year pass (2.5 slots/s => ~7.884M slots), re-publish prices, refresh. - env.warp_slots(7_884_000); + // Let a tenth of a year pass, counted at the reserve's own slots-per-year + // figure, then re-publish prices and refresh. + env.warp_slots(SLOTS_PER_YEAR / 10); env.set_price(collateral.mint, dollars(1)); env.set_price(borrow.mint, dollars(1)); env.refresh_reserve_only(&borrower, &borrow); diff --git a/finance/lending/anchor/programs/lending/tests/test_reserve.rs b/finance/lending/anchor/programs/lending/tests/test_reserve.rs index a0e494acc..ac4439091 100644 --- a/finance/lending/anchor/programs/lending/tests/test_reserve.rs +++ b/finance/lending/anchor/programs/lending/tests/test_reserve.rs @@ -1,6 +1,6 @@ mod common; -use common::{default_config, Env}; +use common::{default_config, Env, SLOTS_PER_YEAR}; use lending::constants::FIXED_POINT_SCALE; #[test] @@ -57,3 +57,77 @@ fn accepts_valid_config_update() { env.try_update_config(&usdc, updated).unwrap(); assert_eq!(env.reserve(&usdc).config.loan_to_value_bps, 6_000); } + +#[test] +fn rejects_zero_slots_per_year() { + let mut env = Env::new(); + let usdc = env.add_reserve(6, common::dollars(1), default_config()); + + let mut bad = default_config(); + bad.slots_per_year = 0; + let result = env.try_update_config(&usdc, bad); + assert!( + result.unwrap_err().contains("InvalidConfig"), + "a zero slots-per-year divisor must be rejected, not divided by" + ); +} + +/// The rate fields are annual; what a borrower is charged per slot is the APR +/// divided by `slots_per_year`. Two reserves differing only in that divisor +/// accrue in proportion to it over the same elapsed slots. This is why the +/// cluster's slot time has to be configured rather than compiled in: leave a +/// stale figure in place after the protocol shortens the slot and every +/// borrower pays more per day than the advertised APR, with nothing in the +/// program changed to say so. +#[test] +fn slots_per_year_scales_the_per_slot_rate() { + let mut env = Env::new(); + let collateral = env.add_reserve(6, common::dollars(1), default_config()); + + let baseline = env.add_reserve(6, common::dollars(1), default_config()); + let mut halved_config = default_config(); + halved_config.slots_per_year = SLOTS_PER_YEAR / 2; + let halved = env.add_reserve(6, common::dollars(1), halved_config); + + // Identical supply and borrow in both, so both sit at 50% utilization and + // therefore resolve to the same APR from the same kinked curve. + for reserve in [&baseline, &halved] { + let supplier = env.create_user(); + env.fund(&supplier, reserve.mint, 1_000_000_000); + env.supply(&supplier, reserve, 1_000_000_000); + + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, reserve.mint, 0); + env.supply(&borrower, &collateral, 1_000_000_000); + let obligation = env.initialize_obligation(&borrower); + env.post_collateral(&borrower, obligation, &collateral, 1_000_000_000); + env.try_borrow(&borrower, obligation, &[&collateral], &[], reserve, 500_000_000) + .unwrap(); + } + + let elapsed = SLOTS_PER_YEAR / 100; + env.warp_slots(elapsed); + env.set_price(collateral.mint, common::dollars(1)); + env.set_price(baseline.mint, common::dollars(1)); + env.set_price(halved.mint, common::dollars(1)); + + let refresher = env.create_user(); + env.refresh_reserve_only(&refresher, &baseline); + env.refresh_reserve_only(&refresher, &halved); + + let baseline_growth = env.reserve(&baseline).borrow_accumulation_factor - FIXED_POINT_SCALE; + let halved_growth = env.reserve(&halved).borrow_accumulation_factor - FIXED_POINT_SCALE; + assert!( + baseline_growth > 0, + "the baseline reserve must have accrued something to compare against" + ); + + // The per-slot rate is floored, so the two rates can differ by one unit in + // the last place; over `elapsed` slots that is an `elapsed`-sized gap. + let doubled = baseline_growth * 2; + assert!( + halved_growth.abs_diff(doubled) <= elapsed as u128, + "halving slots_per_year should double the accrual: got {halved_growth}, expected about {doubled}" + ); +} diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index 8c386e2ee..40ed93e3f 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [2026-08-14] + +### Added + +- `update_slots_per_year` (discriminator 12, owner-only): retunes a reserve to + the cluster's current slot time. It accrues at the old figure before storing + the new one, so slots already elapsed are charged at the rate that was in + force for them. Every other config value is a policy choice the owner makes; + this one tracks a protocol parameter that changes without asking, which is why + it gets its own handler. + +### Changed + +- `Reserve` carries `slots_per_year`, and `initialize_reserve` takes it as a + parameter. Converting an APR into a per-slot rate needs a slots-per-year + divisor, and that divisor is the cluster's slot time in disguise; it was a + `SLOTS_PER_YEAR` constant fixed at a 400ms slot, so a protocol change to the + slot time would have raised the wall-clock rate every borrower pays with no + code change. `validate_config` rejects zero. Tested by + `retuning_slots_per_year_rescales_accrual`. + ## [2026-08-04] ### Changed diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index 243574434..ae517dc98 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -69,6 +69,16 @@ Everything else mirrors the Anchor version. the owner earns. - **Integer-only math**: `u128`, scaled by `FIXED_POINT_SCALE` (10^18), every conversion rounding in the protocol's favour. +- **`slots_per_year`**: the divisor that turns a reserve's annual rate curve into + the per-slot rate interest accrues at. It is the cluster's slot time expressed + as a count, which is why it is stored rather than compiled in: Solana lowers + the slot time over time, and a reserve left on an old figure charges borrowers + more per day than the APR it advertises. Read the current slot time off the + cluster you deploy against (two + [`getBlockTime`](https://solana.com/docs/rpc/http/getblocktime) results a known + number of slots apart) and keep the reserve in step with + `update_slots_per_year`, which accrues at the old figure before storing the new + one. ### Instruction handlers (numeric discriminators) @@ -77,7 +87,7 @@ Everything else mirrors the Anchor version. `initialize_obligation` (5), `deposit_obligation_collateral` (6), `withdraw_obligation_collateral` (7), `borrow_obligation_liquidity` (8), `repay_obligation_liquidity` (9), `liquidate_obligation` (10), -`collect_protocol_fees` (11). +`collect_protocol_fees` (11), `update_slots_per_year` (12). ## Setup diff --git a/finance/lending/quasar/src/constants.rs b/finance/lending/quasar/src/constants.rs index 3e88cb5f7..bb5d18e25 100644 --- a/finance/lending/quasar/src/constants.rs +++ b/finance/lending/quasar/src/constants.rs @@ -14,10 +14,11 @@ pub const FIXED_POINT_SCALE_DECIMALS: i32 = 18; /// 100% expressed in basis points. pub const BPS_DENOMINATOR: u128 = 10_000; -/// Slots per year (~2.5 slots/s), for turning an APR in bps into a per-slot rate. -pub const SLOTS_PER_YEAR: u128 = 78_840_000; - -/// Reject a price feed older than this many slots (~10s at 2.5 slots/s). +/// Reject a price feed older than this many slots. Freshness is counted in +/// slots, not unix time, because the runtime guarantees slot progression while +/// the timestamp is validator-influenced. How long the window is in seconds +/// follows the cluster's slot time, which the protocol lowers over time, so the +/// window tightens on its own and never loosens. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 25; /// SPL token account size, for the rent-exempt vault created in `initialize_reserve`. diff --git a/finance/lending/quasar/src/instructions/admin.rs b/finance/lending/quasar/src/instructions/admin.rs index 4b7b6a93c..e91cc5f14 100644 --- a/finance/lending/quasar/src/instructions/admin.rs +++ b/finance/lending/quasar/src/instructions/admin.rs @@ -83,6 +83,7 @@ impl InitializeReserve { min_borrow_rate_bps: u16, optimal_borrow_rate_bps: u16, max_borrow_rate_bps: u16, + slots_per_year: u64, bumps: &InitializeReserveBumps, ) -> Result<(), ProgramError> { validate_config( @@ -95,6 +96,7 @@ impl InitializeReserve { min_borrow_rate_bps, optimal_borrow_rate_bps, max_borrow_rate_bps, + slots_per_year, )?; let reserve_address = *self.reserve.address(); @@ -154,6 +156,7 @@ impl InitializeReserve { borrowed_principal: 0, borrow_accumulation_factor: crate::constants::FIXED_POINT_SCALE, last_update_slot: now()?, + slots_per_year, liquidity_decimals: decimals, loan_to_value_bps, liquidation_threshold_bps, @@ -170,6 +173,39 @@ impl InitializeReserve { } } +// --------------------------------------------------------------------------- +// update_slots_per_year +// --------------------------------------------------------------------------- + +#[derive(Accounts)] +pub struct UpdateSlotsPerYear { + pub owner: Signer, + #[account(has_one(owner))] + pub lending_market: Account, + #[account(mut, has_one(lending_market))] + pub reserve: Account, +} + +impl UpdateSlotsPerYear { + /// Retune the reserve to the cluster's current slot time. Every other config + /// value is a policy choice the owner makes; this one tracks a protocol + /// parameter that changes without asking, so it gets its own handler. + /// + /// Interest is accrued at the old rate first, so the slots already elapsed + /// are charged at the figure that was in force for them rather than being + /// silently repriced by the new one. + #[inline(always)] + pub fn run(&mut self, slots_per_year: u64) -> Result<(), ProgramError> { + require!(slots_per_year > 0, LendingError::InvalidConfig); + + let mut reserve = snapshot_reserve(&self.reserve); + accrue(&mut reserve, now()?)?; + reserve.slots_per_year = slots_per_year; + self.reserve.set_inner(reserve); + Ok(()) + } +} + // --------------------------------------------------------------------------- // set_price (Switchboard stand-in for tests) // --------------------------------------------------------------------------- diff --git a/finance/lending/quasar/src/lib.rs b/finance/lending/quasar/src/lib.rs index 0e90b8c76..e2f59b6c8 100644 --- a/finance/lending/quasar/src/lib.rs +++ b/finance/lending/quasar/src/lib.rs @@ -55,6 +55,7 @@ mod quasar_lending { min_borrow_rate_bps: u16, optimal_borrow_rate_bps: u16, max_borrow_rate_bps: u16, + slots_per_year: u64, ) -> Result<(), ProgramError> { ctx.accounts.run( loan_to_value_bps, @@ -66,6 +67,7 @@ mod quasar_lending { min_borrow_rate_bps, optimal_borrow_rate_bps, max_borrow_rate_bps, + slots_per_year, &ctx.bumps, ) } @@ -144,4 +146,12 @@ mod quasar_lending { pub fn collect_protocol_fees(ctx: Ctx) -> Result<(), ProgramError> { ctx.accounts.run() } + + #[instruction(discriminator = 12)] + pub fn update_slots_per_year( + ctx: Ctx, + slots_per_year: u64, + ) -> Result<(), ProgramError> { + ctx.accounts.run(slots_per_year) + } } diff --git a/finance/lending/quasar/src/logic.rs b/finance/lending/quasar/src/logic.rs index 5f7c820fb..48622c4eb 100644 --- a/finance/lending/quasar/src/logic.rs +++ b/finance/lending/quasar/src/logic.rs @@ -34,6 +34,7 @@ pub fn snapshot_reserve(reserve: &Account) -> ReserveInner { borrowed_principal: u128::from(reserve.borrowed_principal), borrow_accumulation_factor: u128::from(reserve.borrow_accumulation_factor), last_update_slot: u64::from(reserve.last_update_slot), + slots_per_year: u64::from(reserve.slots_per_year), liquidity_decimals: reserve.liquidity_decimals, loan_to_value_bps: u16::from(reserve.loan_to_value_bps), liquidation_threshold_bps: u16::from(reserve.liquidation_threshold_bps), @@ -78,6 +79,7 @@ pub fn accrue(reserve: &mut ReserveInner, slot: u64) -> Result<(), ProgramError> reserve.min_borrow_rate_bps, reserve.optimal_borrow_rate_bps, reserve.max_borrow_rate_bps, + reserve.slots_per_year, )?; // The protocol keeps `reserve_factor_bps` of the newly accrued interest; the // rest lifts the supplier exchange rate. Flooring rounds the owner's cut down. diff --git a/finance/lending/quasar/src/math.rs b/finance/lending/quasar/src/math.rs index 4abeec540..298fd51e3 100644 --- a/finance/lending/quasar/src/math.rs +++ b/finance/lending/quasar/src/math.rs @@ -5,7 +5,7 @@ use quasar_lang::prelude::*; use crate::{ - constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, FIXED_POINT_SCALE_DECIMALS, SLOTS_PER_YEAR}, + constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, FIXED_POINT_SCALE_DECIMALS}, error::LendingError, }; @@ -137,6 +137,7 @@ pub fn borrow_rate_per_slot( min_rate_bps: u16, optimal_rate_bps: u16, max_rate_bps: u16, + slots_per_year: u64, ) -> Result { let optimal_utilization = optimal_utilization_bps as u128; let apr_bps = if utilization <= optimal_utilization { @@ -161,7 +162,7 @@ pub fn borrow_rate_per_slot( .ok_or(LendingError::MathOverflow)? }; let denominator = BPS_DENOMINATOR - .checked_mul(SLOTS_PER_YEAR) + .checked_mul(slots_per_year as u128) .ok_or(LendingError::MathOverflow)?; mul_div_floor(apr_bps, FIXED_POINT_SCALE, denominator) } @@ -179,6 +180,7 @@ pub fn accrue_factor( min_rate_bps: u16, optimal_rate_bps: u16, max_rate_bps: u16, + slots_per_year: u64, ) -> Result { let elapsed = now .checked_sub(last_update_slot) @@ -193,6 +195,7 @@ pub fn accrue_factor( min_rate_bps, optimal_rate_bps, max_rate_bps, + slots_per_year, )?; let growth = FIXED_POINT_SCALE .checked_add(rate.checked_mul(elapsed as u128).ok_or(LendingError::MathOverflow)?) @@ -211,6 +214,7 @@ pub fn validate_config( min_borrow_rate_bps: u16, optimal_borrow_rate_bps: u16, max_borrow_rate_bps: u16, + slots_per_year: u64, ) -> Result<(), ProgramError> { let within = |value: u16| (value as u128) <= BPS_DENOMINATOR; require!( @@ -236,5 +240,7 @@ pub fn validate_config( && optimal_borrow_rate_bps <= max_borrow_rate_bps, LendingError::InvalidConfig ); + // Zero would divide by zero when converting the APR to a per-slot rate. + require!(slots_per_year > 0, LendingError::InvalidConfig); Ok(()) } diff --git a/finance/lending/quasar/src/state.rs b/finance/lending/quasar/src/state.rs index c6a70f0c1..6fc8bb249 100644 --- a/finance/lending/quasar/src/state.rs +++ b/finance/lending/quasar/src/state.rs @@ -37,6 +37,13 @@ pub struct Reserve { pub borrowed_principal: u128, pub borrow_accumulation_factor: u128, pub last_update_slot: u64, + /// Slots in a year: the divisor that turns the APR fields below into the + /// per-slot rate interest accrues at. This is the cluster's slot time + /// expressed as a count, so it is configuration rather than a constant. The + /// protocol lowers the slot time over time, and a value left behind here + /// charges borrowers at the wrong wall-clock rate while every other number + /// still reads correctly. The owner corrects it with `update_slots_per_year`. + pub slots_per_year: u64, pub liquidity_decimals: u8, pub loan_to_value_bps: u16, pub liquidation_threshold_bps: u16, diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index e861bc76e..4d80fd03b 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -29,6 +29,12 @@ fn cents(amount: u64) -> i128 { const DECIMALS: u8 = 6; const UNIT: u64 = 1_000_000; // 1 token at 6 decimals +/// Slots in a year, which is how a reserve turns an APR into a per-slot rate. +/// 78_840_000 is a 400ms slot: 2.5 slots/second * 60 * 60 * 24 * 365. It is a +/// fixture, not a law: a deployment reads the slot time off the cluster it +/// points at and calls `update_slots_per_year` when the protocol changes it. +const SLOTS_PER_YEAR: u64 = 78_840_000; + // Deterministic addresses. const OWNER: Pubkey = Pubkey::new_from_array([1; 32]); const SUPPLIER: Pubkey = Pubkey::new_from_array([2; 32]); @@ -149,6 +155,7 @@ fn initialize_reserve(test: &mut Test, w: &Pdas, the_mint: Pubkey) { min_borrow_rate_bps: 200, optimal_borrow_rate_bps: 2_000, max_borrow_rate_bps: 15_000, + slots_per_year: SLOTS_PER_YEAR, }) .succeeds(); } @@ -557,6 +564,7 @@ mod slot_warp { for value in config { data.extend_from_slice(&value.to_le_bytes()); } + data.extend_from_slice(&crate::tests::SLOTS_PER_YEAR.to_le_bytes()); let metas = vec![ meta(OWNER, true, true), meta(self.market, false, false), @@ -718,6 +726,17 @@ mod slot_warp { /// Market owner collects accrued protocol fees from the borrow reserve /// into `OWNER_BORROW`. The handler accrues interest itself, so no /// separate refresh. + fn update_slots_per_year(&mut self, slots_per_year: u64) -> quasar_svm::ExecutionResult { + let mut data = vec![12u8]; + data.extend_from_slice(&slots_per_year.to_le_bytes()); + let metas = vec![ + meta(OWNER, false, true), + meta(self.market, false, false), + meta(self.borrow_reserve, true, false), + ]; + self.run(data, metas) + } + fn collect_borrow_fees(&mut self) -> quasar_svm::ExecutionResult { let metas = vec![ meta(OWNER, true, true), @@ -779,6 +798,44 @@ mod slot_warp { world.borrow(100 * UNIT).assert_success(); } + /// `slots_per_year` is how the reserve converts its annual rate curve into + /// the per-slot rate it actually charges, so it carries the cluster's slot + /// time. Halving it doubles what accrues over the same number of slots, + /// which is what makes it configuration: when the protocol shortens the + /// slot, an owner who leaves the old figure in place charges borrowers more + /// per day than the APR they were quoted. + #[test] + fn retuning_slots_per_year_rescales_accrual() { + let mut world = World::new(); + world.bootstrap_position(); + world.borrow(500 * UNIT).assert_success(); + + // First window, at the figure the reserve was created with. + let window = 7_884_000; + let start = world.svm.sysvars.clock.slot; + world.svm.sysvars.warp_to_slot(start + window); + let first = balance(&world.collect_borrow_fees(), OWNER_BORROW); + assert!(first > 0, "the first window must accrue collectable fees"); + + // Halve the slot time, so a year now takes half as many slots. + world + .update_slots_per_year(super::SLOTS_PER_YEAR / 2) + .assert_success(); + + // Second window of exactly the same length. + world.svm.sysvars.warp_to_slot(start + 2 * window); + let second = balance(&world.collect_borrow_fees(), OWNER_BORROW) - first; + + // Not exactly 2x: the factor compounds across the two windows and the + // first collection took liquidity out of the pool, both of which nudge + // the second window up. The band is wide enough for that and far too + // narrow to pass if the new figure were ignored. + assert!( + second * 10 >= first * 18 && second * 10 <= first * 22, + "halving slots_per_year should roughly double accrual: {first} then {second}" + ); + } + #[test] fn protocol_fees_accrue_and_owner_can_collect() { let mut world = World::new(); 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 3bf65d18b..1d71245f7 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs @@ -25,8 +25,9 @@ pub const SIZE_PRECISION: u128 = 1_000_000_000; pub const MINIMUM_LIQUIDITY: u64 = 1_000; /// Reject an oracle price older than this many slots. Slot count is what the -/// runtime guarantees; unix timestamps are validator-influenced. ~150 slots is -/// roughly one minute at 400ms/slot. +/// runtime guarantees; unix timestamps are validator-influenced. How long the +/// window is in seconds follows the cluster's slot time, which the protocol +/// 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 diff --git a/finance/perpetual-futures/quasar/src/constants.rs b/finance/perpetual-futures/quasar/src/constants.rs index 1ff6012aa..c0a0c6a39 100644 --- a/finance/perpetual-futures/quasar/src/constants.rs +++ b/finance/perpetual-futures/quasar/src/constants.rs @@ -14,7 +14,9 @@ pub const SIZE_PRECISION: u128 = 1_000_000_000; /// supply never starts at a dust amount. pub const MINIMUM_LIQUIDITY: u64 = 1_000; -/// Reject an oracle price older than this many slots (~1 minute at 400ms). +/// Reject an oracle price older than this many slots. Counted in slots because +/// the runtime guarantees slot progression; the seconds that comes to follow +/// 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`. diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/constants.rs b/finance/prop-amm/anchor/programs/prop-amm/src/constants.rs index 186587f2d..459fb845c 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/src/constants.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/src/constants.rs @@ -6,9 +6,11 @@ use anchor_lang::prelude::*; pub const BASIS_POINTS_DENOMINATOR: u64 = 10_000; /// Reject an oracle price older than this many slots. Slot count is what the -/// runtime guarantees; unix timestamps are validator-influenced. ~150 slots is -/// roughly one minute at 400ms/slot. For a market maker this bound is not a -/// nicety: a stale quote is a free option for whoever notices first. +/// runtime guarantees; unix timestamps are validator-influenced. How long the +/// window is in seconds follows the cluster's slot time, which the protocol +/// lowers over time, so the window tightens on its own and never loosens. For a +/// market maker this bound is not a nicety: a stale quote is a free option for +/// whoever notices first. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; #[constant] diff --git a/finance/prop-amm/quasar/src/constants.rs b/finance/prop-amm/quasar/src/constants.rs index 3ec12bbfa..d9c8fd6db 100644 --- a/finance/prop-amm/quasar/src/constants.rs +++ b/finance/prop-amm/quasar/src/constants.rs @@ -1,9 +1,11 @@ /// Basis-point denominator: 100% = 10_000 bps. pub const BASIS_POINTS_DENOMINATOR: u64 = 10_000; -/// Reject an oracle price older than this many slots (~1 minute at 400ms). -/// For a market maker the bound is the business itself: a stale quote is a -/// free option for whoever notices first. +/// Reject an oracle price older than this many slots. Counted in slots because +/// the runtime guarantees slot progression; the seconds that comes to follow the +/// cluster's slot time, which the protocol lowers over time. For a market maker +/// the bound is the business itself: a stale quote is a free option for whoever +/// notices first. pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; /// `direction` argument values for `swap`. Quasar instruction arguments are From 4c9c62715e025aa21daebd34490127a65b4e890d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 17:07:38 +0000 Subject: [PATCH 2/2] perps: let the pool authority retune the funding rate `funding_rate_per_slot` was set at initialize_pool and never again. The rate is quoted per slot, so what a position costs per hour depends on the cluster's slot time as well as on the rate. Solana lowers the slot time over time, and a pool that outlives a reduction charges the heavier side more per hour than it was set up to, with no way to correct it short of a new pool. Add set_funding_rate to both ports (Quasar discriminator 7), owner-only. It advances the funding index at the old rate before storing the new one, so slots already elapsed are charged at the rate that was in force for them rather than repriced by the new one. The tests pin that settlement rather than just the write: a position held across a retune to double the rate pays one window at the old rate and one at the new, which is exactly 1.5x what a position opened afterwards pays over a single window. Comparing two windows in the same pool cancels the size and price scaling, so the assertion is on the rates alone. --- finance/perpetual-futures/anchor/CHANGELOG.md | 16 ++++ finance/perpetual-futures/anchor/README.md | 4 +- .../perpetual-futures/src/instructions/mod.rs | 2 + .../src/instructions/set_funding_rate.rs | 37 ++++++++ .../programs/perpetual-futures/src/lib.rs | 9 ++ .../tests/test_perpetual_futures.rs | 87 +++++++++++++++++ finance/perpetual-futures/quasar/CHANGELOG.md | 16 ++++ .../quasar/src/instructions/mod.rs | 2 + .../src/instructions/set_funding_rate.rs | 49 ++++++++++ finance/perpetual-futures/quasar/src/lib.rs | 8 ++ finance/perpetual-futures/quasar/src/tests.rs | 94 ++++++++++++++++++- 11 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/set_funding_rate.rs create mode 100644 finance/perpetual-futures/quasar/src/instructions/set_funding_rate.rs diff --git a/finance/perpetual-futures/anchor/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index ecd49fe12..61622503a 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 2026-08-14 + +Add `set_funding_rate`, so the pool authority can retune `funding_rate_per_slot` +after the pool is created. The rate is quoted per slot, so what a position costs +per hour depends on the cluster's slot time as well as on the rate; Solana lowers +the slot time over time, and a pool created before a reduction charges the +heavier side more per hour than it was set up to. The handler advances the +funding index at the old rate before storing the new one, so slots already +elapsed are charged at the rate that was in force for them. Tested by +`test_set_funding_rate_settles_at_the_old_rate_first` and +`test_only_authority_can_set_funding_rate`. + +Also drop the "at 400ms/slot" gloss from the price-staleness constant: the +window is counted in slots on purpose, and what it comes to in seconds follows +the cluster. + ## 2026-08-04 Reject oracle prices from before a cluster restart. A halt stops the slot diff --git a/finance/perpetual-futures/anchor/README.md b/finance/perpetual-futures/anchor/README.md index 617df0dff..63a28c486 100644 --- a/finance/perpetual-futures/anchor/README.md +++ b/finance/perpetual-futures/anchor/README.md @@ -42,6 +42,8 @@ So a winning trader can always be paid, the pool **reserves** liquidity to back [Funding](https://www.investopedia.com/terms/f/futurescontract.asp) anchors the pool's risk: the heavier side of [open interest](https://www.investopedia.com/terms/o/openinterest.asp) pays the pool over time. A cumulative funding index rises while longs are the larger side and falls while shorts are, advancing by `funding_rate_per_slot` each [slot](https://solana.com/docs/terminology#slot); a position records the index at open and settles the change when it closes. In a pool-based perp this is the equivalent of the borrow fee Jupiter Perpetuals charges. +Because the rate is quoted per slot, what a position costs per hour depends on the cluster's slot time as well as on the rate. Solana lowers the slot time over time, so a pool that outlives a reduction charges the heavier side more per hour than it was set up to. `set_funding_rate(funding_rate_per_slot)` lets the pool authority bring it back in line; it advances the index at the old rate first, so slots already elapsed are charged at the rate that was in force for them. + ### Maintenance margin and liquidation A position's *equity* is its net collateral plus profit/loss minus funding. Once equity falls to or below the [maintenance margin](https://www.investopedia.com/terms/m/maintenancemargin.asp) (`maintenance_margin_bps` of notional), the position can be [liquidated](https://www.investopedia.com/terms/l/liquidation.asp). Liquidation is permissionless: anyone can crank it and earn the liquidation fee. @@ -205,7 +207,7 @@ 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, liquidation (and the refusal to liquidate a healthy position), reserved-liquidity behaviour (profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity), and fee collection. +The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover liquidity round-trips, opening and closing longs and shorts in profit and loss, leverage and slippage rejection, stale-price, pre-restart-price, and wide-confidence rejection, funding accrual, funding-rate retuning (including that it settles elapsed slots at the old rate, and that only the authority may call it), 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. ```bash anchor build 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 88337e1df..9a7210c28 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 @@ -5,6 +5,7 @@ pub mod initialize_pool; pub mod liquidate_position; pub mod open_position; pub mod remove_liquidity; +pub mod set_funding_rate; pub mod shared; pub use add_liquidity::*; @@ -14,3 +15,4 @@ pub use initialize_pool::*; pub use liquidate_position::*; pub use open_position::*; pub use remove_liquidity::*; +pub use set_funding_rate::*; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/set_funding_rate.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/set_funding_rate.rs new file mode 100644 index 000000000..03a98fb05 --- /dev/null +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/set_funding_rate.rs @@ -0,0 +1,37 @@ +use anchor_lang::prelude::*; + +use crate::constants::POOL_SEED; +use crate::instructions::shared::accrue_funding; +use crate::state::Pool; + +/// Retune the pool's funding rate. The rate is quoted per slot, so the wall-clock +/// cost of holding a position depends on the cluster's slot time as well as on +/// this number: shorten the slot and the same rate charges the heavier side more +/// per hour. Solana lowers the slot time over time, so a pool that outlives a +/// reduction needs its rate brought back in line. +/// +/// Funding is accrued at the old rate first, so the slots already elapsed are +/// charged at the rate that was in force for them rather than repriced by the +/// new one. +pub fn handle_set_funding_rate( + context: Context, + funding_rate_per_slot: u64, +) -> Result<()> { + let pool = &mut context.accounts.pool; + accrue_funding(pool, Clock::get()?.slot)?; + pool.funding_rate_per_slot = funding_rate_per_slot; + Ok(()) +} + +#[derive(Accounts)] +pub struct SetFundingRateAccountConstraints<'info> { + pub authority: Signer<'info>, + + #[account( + mut, + seeds = [POOL_SEED, pool.collateral_mint.as_ref(), pool.oracle_feed.as_ref()], + bump = pool.bump, + has_one = authority, + )] + pub pool: Box>, +} 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 31e19e4c7..2923d6a16 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs @@ -79,4 +79,13 @@ pub mod perpetual_futures { pub fn collect_fees(context: Context) -> Result<()> { instructions::handle_collect_fees(context) } + + /// Pool authority retunes the per-slot funding rate, accruing at the old + /// rate first. + pub fn set_funding_rate( + context: Context, + funding_rate_per_slot: u64, + ) -> Result<()> { + instructions::handle_set_funding_rate(context, funding_rate_per_slot) + } } 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 3055ffead..0610d1df0 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 @@ -493,6 +493,29 @@ impl Market { .map_err(|_| ()) } + fn set_funding_rate(&mut self, authority: &Keypair, rate: u64) -> Result<(), ()> { + let instruction = Instruction::new_with_bytes( + perpetual_futures::id(), + &perpetual_futures::instruction::SetFundingRate { + funding_rate_per_slot: rate, + } + .data(), + perpetual_futures::accounts::SetFundingRateAccountConstraints { + authority: authority.pubkey(), + pool: self.pool, + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[authority], + &authority.pubkey(), + ) + .map(|_| ()) + .map_err(|_| ()) + } + /// Deposit a large amount of liquidity so the pool can pay trader profits, /// returning the provider and its collateral account. fn seed_liquidity(&mut self, amount: u64) -> (Keypair, Pubkey) { @@ -890,6 +913,70 @@ fn test_funding_charged_to_long() { ); } +/// The funding rate is quoted per slot, so what a position costs per hour also +/// depends on the cluster's slot time. When the protocol shortens the slot, the +/// pool authority retunes the rate, and the retune must settle the slots already +/// elapsed at the old rate rather than repricing them at the new one. +#[test] +fn test_set_funding_rate_settles_at_the_old_rate_first() { + let rate = 5_000; + let window = 2_000; + + // Same position and the same total elapsed slots in both runs. The only + // difference is that the second doubles the rate halfway through, so it + // should pay 1x for the first window and 2x for the second: 1.5x overall. + let funding_for = |retune: bool| -> u64 { + let mut market = Market::new(dollars(100), rate); + market.seed_liquidity(100_000 * ONE_USDC); + + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + + let opened_at = market.current_slot(); + market.warp(opened_at + window); + if retune { + let admin = market.admin.insecure_clone(); + market.set_funding_rate(&admin, rate * 2).unwrap(); + } + market.warp(opened_at + 2 * window); + market.set_price(dollars(100)); + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + + let fee = size / 1_000; + let payout = get_token_account_balance(&market.svm, &trader_collateral).unwrap(); + (collateral - fee - fee) - payout + }; + + let flat = funding_for(false); + let retuned = funding_for(true); + assert!(flat > 0, "the flat run must pay some funding to compare against"); + + // Half the elapsed slots at 1x and half at 2x is 1.5x the flat run. Had the + // handler skipped its accrual, the new rate would have applied to every + // slot and this would be 2x. + assert_eq!( + retuned * 2, + flat * 3, + "retuning halfway should cost 1.5x the flat run: flat {flat}, retuned {retuned}" + ); +} + +#[test] +fn test_only_authority_can_set_funding_rate() { + let mut market = Market::new(dollars(100), 5_000); + let (impostor, _) = market.funded_trader(ONE_USDC); + assert!( + market.set_funding_rate(&impostor, 1).is_err(), + "a non-authority must not be able to retune the funding rate" + ); +} + #[test] fn test_liquidation_of_underwater_long() { let mut market = Market::default_market(); diff --git a/finance/perpetual-futures/quasar/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index 5a9b02bc4..32dcf26ae 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 2026-08-14 + +Add `set_funding_rate` (discriminator 7), so the pool authority can retune +`funding_rate_per_slot` after the pool is created. The rate is quoted per slot, +so what a position costs per hour depends on the cluster's slot time as well as +on the rate; Solana lowers the slot time over time, and a pool created before a +reduction charges the heavier side more per hour than it was set up to. The +handler advances the funding index at the old rate before storing the new one, +so slots already elapsed are charged at the rate that was in force for them. +Tested by `set_funding_rate_settles_at_the_old_rate_first` and +`only_the_authority_can_set_the_funding_rate`. + +Also drop the "at 400ms" gloss from the price-staleness constant: the window is +counted in slots on purpose, and what it comes to in seconds follows the +cluster. + ## 2026-08-04 Reject oracle prices from before a cluster restart: `read_oracle_price` diff --git a/finance/perpetual-futures/quasar/src/instructions/mod.rs b/finance/perpetual-futures/quasar/src/instructions/mod.rs index 00453e621..6ae5145f8 100644 --- a/finance/perpetual-futures/quasar/src/instructions/mod.rs +++ b/finance/perpetual-futures/quasar/src/instructions/mod.rs @@ -5,6 +5,7 @@ mod initialize_pool; mod liquidate_position; mod open_position; mod remove_liquidity; +mod set_funding_rate; pub mod shared; pub use add_liquidity::*; @@ -14,3 +15,4 @@ pub use initialize_pool::*; pub use liquidate_position::*; pub use open_position::*; pub use remove_liquidity::*; +pub use set_funding_rate::*; diff --git a/finance/perpetual-futures/quasar/src/instructions/set_funding_rate.rs b/finance/perpetual-futures/quasar/src/instructions/set_funding_rate.rs new file mode 100644 index 000000000..dde1edb86 --- /dev/null +++ b/finance/perpetual-futures/quasar/src/instructions/set_funding_rate.rs @@ -0,0 +1,49 @@ +use { + crate::{instructions::shared::advance_funding, state::Pool}, + quasar_lang::{prelude::*, sysvars::Sysvar}, +}; + +#[derive(Accounts)] +pub struct SetFundingRate { + pub authority: Signer, + #[account( + mut, + has_one(authority), + address = Pool::seeds(collateral_mint.address(), oracle_feed.address()), + )] + pub pool: Account, + /// CHECK: bound to the pool via its seeds. + pub collateral_mint: UncheckedAccount, + /// CHECK: bound to the pool via its seeds. + pub oracle_feed: UncheckedAccount, +} + +/// Retune the pool's funding rate. The rate is quoted per slot, so what holding +/// a position costs per hour depends on the cluster's slot time as well as on +/// this number: shorten the slot and the same rate charges the heavier side +/// more. Solana lowers the slot time over time, so a pool that outlives a +/// reduction needs its rate brought back in line. +/// +/// Funding advances at the old rate first, so slots already elapsed are charged +/// at the rate that was in force for them rather than repriced by the new one. +#[inline(always)] +pub fn handle_set_funding_rate( + accounts: &mut SetFundingRate, + funding_rate_per_slot: u64, +) -> Result<(), ProgramError> { + let pool = &mut accounts.pool; + let slot = u64::from(Clock::get()?.slot); + + let new_funding = advance_funding( + pool.cumulative_funding.get(), + pool.last_funding_slot.get(), + slot, + pool.funding_rate_per_slot.get(), + pool.long_size.get(), + pool.short_size.get(), + )?; + pool.cumulative_funding.set(new_funding); + pool.last_funding_slot.set(slot); + pool.funding_rate_per_slot.set(funding_rate_per_slot); + Ok(()) +} diff --git a/finance/perpetual-futures/quasar/src/lib.rs b/finance/perpetual-futures/quasar/src/lib.rs index 245aff216..0a1015614 100644 --- a/finance/perpetual-futures/quasar/src/lib.rs +++ b/finance/perpetual-futures/quasar/src/lib.rs @@ -127,4 +127,12 @@ mod quasar_perpetual_futures { pub fn collect_fees(ctx: Ctx) -> Result<(), ProgramError> { instructions::handle_collect_fees(&mut ctx.accounts, &ctx.bumps) } + + #[instruction(discriminator = 7)] + pub fn set_funding_rate( + ctx: Ctx, + funding_rate_per_slot: u64, + ) -> Result<(), ProgramError> { + instructions::handle_set_funding_rate(&mut ctx.accounts, funding_rate_per_slot) + } } diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index 961d35b0d..2eae4b6f0 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -7,7 +7,7 @@ use { cpi::{ AddLiquidityInstruction, ClosePositionInstruction, CollectFeesInstruction, InitializePoolInstruction, LiquidatePositionInstruction, OpenPositionInstruction, - RemoveLiquidityInstruction, + RemoveLiquidityInstruction, SetFundingRateInstruction, }, state::{Pool, Position}, LpMintPda, VaultPda, @@ -92,12 +92,21 @@ fn set_last_restart_slot(test: &mut Test, slot: u64) { } fn init_pool(test: &mut Test, maintenance_margin_bps: u16, close_fee_bps: u16) -> Outcome { + init_pool_with_funding(test, maintenance_margin_bps, close_fee_bps, 0) +} + +fn init_pool_with_funding( + test: &mut Test, + maintenance_margin_bps: u16, + close_fee_bps: u16, + funding_rate_per_slot: u64, +) -> Outcome { test.send(InitializePoolInstruction { authority: ADMIN, collateral_mint: COLLATERAL_MINT, oracle_feed: FEED, oracle_scale: ORACLE_SCALE, - funding_rate_per_slot: 0, + funding_rate_per_slot, open_fee_bps: 10, close_fee_bps, max_leverage: 10, @@ -118,10 +127,16 @@ struct Env { /// initialized pool (0.1% open/close fees, 10x max leverage, 5% maintenance /// margin, 1% liquidation fee, 1% max confidence). fn setup(test: &mut Test) -> Env { + setup_with_funding(test, 0) +} + +/// Like `setup`, but with a non-zero per-slot funding rate so funding accrues +/// as slots pass. +fn setup_with_funding(test: &mut Test, funding_rate_per_slot: u64) -> Env { test.add(Wallet::new().at(ADMIN)); test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); set_feed(test, dollars(100), 0); - init_pool(test, 500, 10).succeeds(); + init_pool_with_funding(test, 500, 10, funding_rate_per_slot).succeeds(); let pool = test.derive_pda(Pool::seeds(&COLLATERAL_MINT, &FEED)); Env { @@ -358,6 +373,79 @@ fn collect_fees_sweeps_the_open_fee_to_the_admin(test: &mut Test) { .has_tokens(ADMIN_COLLATERAL, size / 1_000); } +/// The funding rate is quoted per slot, so what a position costs per hour also +/// depends on the cluster's slot time. When the protocol shortens the slot, the +/// pool authority retunes the rate, and the retune settles the slots already +/// elapsed at the old rate rather than repricing them at the new one. +/// +/// Both halves below hold the same position for the same slots 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). +#[quasar_test] +fn set_funding_rate_settles_at_the_old_rate_first(test: &mut Test) { + let rate = 5_000; + let window = 2_000; + let size = 5_000 * ONE_USDC; + let collateral = 1_000 * ONE_USDC; + let fees = 2 * (size / 1_000); // open and close, 0.1% of notional each + + let env = setup_with_funding(test, rate); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 10_000 * ONE_USDC); + + // A position held across the retune: one window at `rate`, one at `rate * 2`. + let before_spanning = test.tokens(TRADER_COLLATERAL); + open_position(test, &env, 0, collateral, size).succeeds(); + set_clock_at(test, window); + test.send(SetFundingRateInstruction { + authority: ADMIN, + collateral_mint: COLLATERAL_MINT, + oracle_feed: FEED, + funding_rate_per_slot: rate * 2, + }) + .succeeds(); + set_clock_at(test, 2 * window); + set_feed_at_slot(test, dollars(100), 2 * window, 0); + close_position(test, &env).succeeds(); + let spanning = (before_spanning - test.tokens(TRADER_COLLATERAL)) - fees; + + // A fresh position over one window, now wholly at the doubled rate. + let before_doubled = test.tokens(TRADER_COLLATERAL); + open_position(test, &env, 0, collateral, size).succeeds(); + set_clock_at(test, 3 * window); + set_feed_at_slot(test, dollars(100), 3 * window, 0); + close_position(test, &env).succeeds(); + let doubled = (before_doubled - test.tokens(TRADER_COLLATERAL)) - fees; + + assert!(doubled > 0, "the doubled-rate window must charge some funding"); + assert_eq!( + spanning * 2, + doubled * 3, + "a position spanning the retune should pay 1.5x one doubled window: \ + spanning {spanning}, doubled {doubled}" + ); +} + +#[quasar_test] +fn only_the_authority_can_set_the_funding_rate(test: &mut Test) { + let env = setup_with_funding(test, 5_000); + let _ = env; + fund(test, TRADER, TRADER_COLLATERAL, ONE_USDC); + assert!( + test.send(SetFundingRateInstruction { + authority: TRADER, + collateral_mint: COLLATERAL_MINT, + oracle_feed: FEED, + funding_rate_per_slot: 1, + }) + .is_err(), + "a non-authority must not be able to retune the funding rate" + ); +} + #[quasar_test] fn wide_oracle_confidence_is_rejected(test: &mut Test) { let env = setup(test);