diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index 8b95d1d5..dab8f168 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 50e07f7f..4fe766a7 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 3affc32e..5da22e87 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 c6648c95..b510a3c1 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 99210fe8..dcf51deb 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 5d819026..15d6be35 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 a0e494ac..ac443909 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 8c386e2e..40ed93e3 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 24357443..ae517dc9 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 3e88cb5f..bb5d18e2 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 4b7b6a93..e91cc5f1 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 0e90b8c7..e2f59b6c 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 5f7c820f..48622c4e 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 4abeec54..298fd51e 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 c6a70f0c..6fc8bb24 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 e861bc76..4d80fd03 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/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index ecd49fe1..61622503 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 617df0df..63a28c48 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/constants.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs index 3bf65d18..1d71245f 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/anchor/programs/perpetual-futures/src/instructions/mod.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/mod.rs index 88337e1d..9a7210c2 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 00000000..03a98fb0 --- /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 31e19e4c..2923d6a1 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 3055ffea..0610d1df 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 5a9b02bc..32dcf26a 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/constants.rs b/finance/perpetual-futures/quasar/src/constants.rs index 1ff6012a..c0a0c6a3 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/perpetual-futures/quasar/src/instructions/mod.rs b/finance/perpetual-futures/quasar/src/instructions/mod.rs index 00453e62..6ae5145f 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 00000000..dde1edb8 --- /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 245aff21..0a101561 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 961d35b0..2eae4b6f 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); 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 186587f2..459fb845 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 3ec12bbf..d9c8fd6d 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