From 6e09975652d32034c0577d4e88cfe4ef49b028c7 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Tue, 22 Sep 2026 23:31:07 +0000 Subject: [PATCH 1/3] Accrue lending interest and perpetual futures funding by the wall clock (Anchor v2, Anchor v1) Both programs accrued over elapsed slots, so a rate quoted per year or per hour depended on a guess at the slot length. The lending reserve's slots_per_year default assumed 400 ms slots and charged twice the advertised APR once the network moved to 200 ms. Lending: ReserveConfig loses slots_per_year. Reserve gains last_accrual_timestamp, and interest accrues for the seconds since it at the APR over SECONDS_PER_YEAR (current_borrow_rate_per_slot is now current_borrow_rate_per_second). last_update_slot stays as the same-slot refresh check. A timestamp at or before the stored one accrues nothing. update_reserve_config accrues at the old curve before storing the new one. New tests: interest_accrues_by_seconds_not_slots, a_timestamp_behind_the_last_accrual_charges_nothing, a_config_update_accrues_at_the_old_rates_first; the two slots_per_year tests are removed. Perpetual futures: last_funding_slot is now last_funding_timestamp and funding_rate_per_slot is now funding_rate_per_second (Pool, PoolParameters, set_funding_rate). New test test_funding_follows_seconds_not_slots; the retune and funding-charged tests count seconds. Price freshness stays in slots. The Quasar variants follow separately. Claude-Session: https://claude.ai/code/session_01CXqcBq93zfcZ2F8jeoSaez --- finance/lending/anchor-v1/CHANGELOG.md | 17 ++ finance/lending/anchor-v1/README.md | 26 ++- .../programs/lending/src/constants.rs | 8 +- .../instructions/admin/initialize_reserve.rs | 4 +- .../admin/update_reserve_config.rs | 8 +- .../src/instructions/refresh_reserve.rs | 8 +- .../programs/lending/src/state/reserve.rs | 62 ++++--- .../programs/lending/tests/common/mod.rs | 36 +++- .../programs/lending/tests/test_interest.rs | 11 +- .../programs/lending/tests/test_reserve.rs | 161 ++++++++++------ .../programs/lending/tests/test_rounding.rs | 2 +- finance/lending/anchor/CHANGELOG.md | 17 ++ finance/lending/anchor/README.md | 26 ++- .../anchor/programs/lending/src/constants.rs | 8 +- .../instructions/admin/initialize_reserve.rs | 4 +- .../admin/update_reserve_config.rs | 8 +- .../src/instructions/refresh_reserve.rs | 8 +- .../programs/lending/src/state/reserve.rs | 62 ++++--- .../programs/lending/tests/common/mod.rs | 36 +++- .../programs/lending/tests/test_interest.rs | 11 +- .../programs/lending/tests/test_reserve.rs | 174 +++++++++++------- .../programs/lending/tests/test_rounding.rs | 2 +- finance/lending/kani-proofs/src/lib.rs | 2 +- .../perpetual-futures/anchor-v1/CHANGELOG.md | 13 ++ finance/perpetual-futures/anchor-v1/README.md | 6 +- .../perpetual-futures/src/constants.rs | 4 +- .../src/instructions/initialize_pool.rs | 8 +- .../src/instructions/set_funding_rate.rs | 14 +- .../src/instructions/shared.rs | 23 ++- .../programs/perpetual-futures/src/lib.rs | 6 +- .../perpetual-futures/src/state/pool.rs | 9 +- .../tests/test_perpetual_futures.rs | 122 +++++++----- finance/perpetual-futures/anchor/CHANGELOG.md | 13 ++ finance/perpetual-futures/anchor/README.md | 6 +- .../perpetual-futures/src/constants.rs | 4 +- .../src/instructions/initialize_pool.rs | 8 +- .../src/instructions/set_funding_rate.rs | 14 +- .../src/instructions/shared.rs | 23 ++- .../programs/perpetual-futures/src/lib.rs | 6 +- .../perpetual-futures/src/state/pool.rs | 9 +- .../tests/test_perpetual_futures.rs | 118 +++++++----- 41 files changed, 714 insertions(+), 393 deletions(-) diff --git a/finance/lending/anchor-v1/CHANGELOG.md b/finance/lending/anchor-v1/CHANGELOG.md index dab8f1686..481fe877f 100644 --- a/finance/lending/anchor-v1/CHANGELOG.md +++ b/finance/lending/anchor-v1/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 2026-09-22 + +Accrue interest by the wall clock instead of by slots. The reserve's +`slots_per_year` config field was a guess at the cluster's slot length, and the +test default of 78,840,000 (a 400 ms slot) charged twice the advertised APR once +the network moved to 200 ms slots. Interest now accrues for the seconds between +the Clock's `unix_timestamp` and the new `Reserve::last_accrual_timestamp`, at +the APR divided by `SECONDS_PER_YEAR`; `slots_per_year` is gone from +`ReserveConfig`, and `current_borrow_rate_per_slot` is now +`current_borrow_rate_per_second`. A timestamp at or before the stored one +accrues nothing. `last_update_slot` stays, as the same-slot refresh check. +`update_reserve_config` now accrues at the old curve before storing the new one. +Tested by `interest_accrues_by_seconds_not_slots`, +`a_timestamp_behind_the_last_accrual_charges_nothing` and +`a_config_update_accrues_at_the_old_rates_first`, which replace +`slots_per_year_scales_the_per_slot_rate` and `rejects_zero_slots_per_year`. + ## 2026-08-14 Move slots-per-year out of the code and into the reserve config. Turning an APR diff --git a/finance/lending/anchor-v1/README.md b/finance/lending/anchor-v1/README.md index b780a17da..cefc669a6 100644 --- a/finance/lending/anchor-v1/README.md +++ b/finance/lending/anchor-v1/README.md @@ -63,22 +63,28 @@ closing the classic empty-pool inflation attack. The first deposit mints 1:1. ### Interest: a kinked curve and a cumulative index Each `refresh_reserve` advances `borrow_accumulation_factor` by -`(1 + rate_per_slot * elapsed_slots)`. `rate_per_slot` comes from a kinked +`(1 + rate_per_second * elapsed_seconds)`. `rate_per_second` comes from a kinked utilization curve: linear from `min_borrow_rate_bps` to `optimal_borrow_rate_bps` up to `optimal_utilization_bps`, then steeper to `max_borrow_rate_bps` at full 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`. +Those curve parameters are annual, and the conversion to a per-second rate +divides by `SECONDS_PER_YEAR`. Elapsed time is the Clock's `unix_timestamp` +minus the reserve's `last_accrual_timestamp`, so a borrower pays the advertised +APR over a real year whatever the cluster's slot time is. Counting slots instead +would need a slots-per-year divisor, which is a guess at the slot length: the +network changes that length by feature gate, and does not deliver it exactly +between changes. The timestamp is written by each block's leader, but the +runtime bounds how far one block can move it, so an elapsed time is out by a +second or two at most, which is nothing against an annual rate. A timestamp at +or before the stored one accrues nothing. `update_reserve_config` accrues at the +old curve before storing a new one, so the seconds already elapsed are charged +at the rates that applied to them. + +The reserve still records `last_update_slot`, for a different job: handlers that +read the reserve's value require the refresh to have run in the current slot. ### Protocol fees (how the market earns) diff --git a/finance/lending/anchor-v1/programs/lending/src/constants.rs b/finance/lending/anchor-v1/programs/lending/src/constants.rs index 5da22e876..a56c893d5 100644 --- a/finance/lending/anchor-v1/programs/lending/src/constants.rs +++ b/finance/lending/anchor-v1/programs/lending/src/constants.rs @@ -8,7 +8,7 @@ /// values. A ratio `r` is stored as the integer `r * FIXED_POINT_SCALE`. /// /// All money math is integer-only (no floats, no fixed-point crates). 10^18 -/// keeps a single slot's interest — which can be a tiny fraction of the index — +/// keeps a single second's interest, which can be a tiny fraction of the index, /// from truncating to zero, while u128's ~3.4e38 ceiling leaves headroom for the /// index to grow and for intermediate products before the final narrowing cast. pub const FIXED_POINT_SCALE: u128 = 1_000_000_000_000_000_000; @@ -26,6 +26,12 @@ pub const BPS_DENOMINATOR: u128 = 10_000; /// refresh_obligation (which iterates every entry). pub const MAX_OBLIGATION_RESERVES: usize = 4; +/// Seconds in a 365-day year: the divisor that turns an annual rate into the +/// per-second rate interest accrues at. Interest runs on the wall clock, not +/// the slot count, because a rate quoted per year is a promise about wall-clock +/// time and a slots-per-year divisor is only a guess at the slot length. +pub const SECONDS_PER_YEAR: u128 = 31_536_000; + /// 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 diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/admin/initialize_reserve.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/admin/initialize_reserve.rs index 96478950b..16bd5c8b0 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/admin/initialize_reserve.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/admin/initialize_reserve.rs @@ -20,7 +20,9 @@ pub fn handle_initialize_reserve(context: Context, config: Re reserve.share_mint_supply = 0; reserve.borrowed_principal = 0; reserve.borrow_accumulation_factor = FIXED_POINT_SCALE; - reserve.last_update_slot = Clock::get()?.slot; + let clock = Clock::get()?; + reserve.last_update_slot = clock.slot; + reserve.last_accrual_timestamp = clock.unix_timestamp; reserve.accumulated_protocol_fees = 0; reserve.config = config; reserve.bump = context.bumps.reserve; diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/admin/update_reserve_config.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/admin/update_reserve_config.rs index 06df1b1b0..182221f11 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/admin/update_reserve_config.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/admin/update_reserve_config.rs @@ -7,7 +7,13 @@ pub fn handle_update_reserve_config( config: ReserveConfig, ) -> Result<()> { config.validate()?; - context.accounts.reserve.config = config; + // Accrue at the old curve first, so the seconds since the last refresh are + // charged at the rates that applied to them rather than repriced by the new + // ones. + let clock = Clock::get()?; + let reserve = &mut context.accounts.reserve; + reserve.accrue_interest(clock.slot, clock.unix_timestamp)?; + reserve.config = config; Ok(()) } diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/refresh_reserve.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/refresh_reserve.rs index 151f095ad..d8d236d41 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/refresh_reserve.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/refresh_reserve.rs @@ -2,11 +2,15 @@ use anchor_lang::prelude::*; use crate::state::Reserve; -/// Accrue interest up to the current slot. Must run (as its own instruction in +/// Accrue interest up to the current time. Must run (as its own instruction in /// the same transaction) before any handler that reads the reserve's value, and /// before `refresh_obligation` for any reserve the obligation touches. pub fn handle_refresh_reserve(context: Context) -> Result<()> { - context.accounts.reserve.accrue_interest(Clock::get()?.slot) + let clock = Clock::get()?; + context + .accounts + .reserve + .accrue_interest(clock.slot, clock.unix_timestamp) } #[derive(Accounts)] diff --git a/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs b/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs index b510a3c1e..ccc0067da 100644 --- a/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs @@ -1,6 +1,6 @@ use anchor_lang::prelude::*; -use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, RESERVE_SEED}; +use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, RESERVE_SEED, SECONDS_PER_YEAR}; use crate::errors::LendingError; use crate::math::{mul_div_ceil, mul_div_floor}; @@ -63,8 +63,16 @@ pub struct Reserve { /// Starts at FIXED_POINT_SCALE (1.0) and only ever multiplies by factors >= 1. pub borrow_accumulation_factor: u128, + /// The slot of the last refresh. Handlers that read the reserve's value + /// require it to equal the current slot, so the refresh ran in this + /// transaction. This is a freshness check, not the accrual clock. pub last_update_slot: u64, + /// The Clock's `unix_timestamp` at the last accrual. Interest accrues for + /// the seconds since, so it follows wall-clock time whatever the slot + /// length is. + pub last_accrual_timestamp: i64, + /// Liquidity owed to the market owner: the protocol's cut of accrued /// interest (`config.reserve_factor_bps`). It is carved out of /// `total_liquidity` so it never inflates the share exchange rate, and the @@ -98,14 +106,6 @@ 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 { @@ -138,8 +138,6 @@ 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(()) } } @@ -181,10 +179,10 @@ impl Reserve { mul_div_floor(self.current_borrowed_amount()? as u128, BPS_DENOMINATOR, gross) } - /// Per-slot borrow rate (FIXED_POINT_SCALE-scaled) from the kinked curve: + /// Per-second borrow rate (FIXED_POINT_SCALE-scaled) from the kinked curve: /// linear from `min` to `optimal` up to the kink, then steeper from `optimal` /// to `max` between the kink and full utilization. - pub fn current_borrow_rate_per_slot(&self) -> Result { + pub fn current_borrow_rate_per_second(&self) -> Result { let utilization = self.utilization_bps()?; let optimal_utilization = self.config.optimal_utilization_bps as u128; @@ -212,26 +210,37 @@ impl Reserve { .ok_or(LendingError::MathOverflow)? }; - // apr_bps / (BPS_DENOMINATOR * slots_per_year), carried at FIXED_POINT_SCALE. + // apr_bps / (BPS_DENOMINATOR * SECONDS_PER_YEAR), carried at FIXED_POINT_SCALE. let per_year_denominator = BPS_DENOMINATOR - .checked_mul(self.config.slots_per_year as u128) + .checked_mul(SECONDS_PER_YEAR) .ok_or(LendingError::MathOverflow)?; mul_div_floor(apr_bps, FIXED_POINT_SCALE, per_year_denominator) } - /// Advance the accumulation factor for the slots elapsed since the last refresh. - /// `new_factor = old_factor * (1 + rate_per_slot * elapsed_slots)`, a single - /// multiply per refresh that compounds across refreshes (Solend's approach). - pub fn accrue_interest(&mut self, current_slot: u64) -> Result<()> { - let elapsed = current_slot - .checked_sub(self.last_update_slot) - .ok_or(LendingError::MathOverflow)?; + /// Advance the accumulation factor for the seconds elapsed since the last + /// accrual, and record `current_slot` as the slot of this refresh. + /// `new_factor = old_factor * (1 + rate_per_second * elapsed_seconds)`, a + /// single multiply per refresh that compounds across refreshes (Solend's + /// approach, on the wall clock rather than the slot count). + /// + /// The timestamp is written by each block's leader. The runtime rejects a + /// block whose time goes backwards, but a timestamp at or before the stored + /// one is still treated as no time elapsed, and the stored stamp is left + /// where it is, so no second is ever charged twice or skipped. + pub fn accrue_interest(&mut self, current_slot: u64, current_timestamp: i64) -> Result<()> { + let elapsed = if current_timestamp > self.last_accrual_timestamp { + current_timestamp + .checked_sub(self.last_accrual_timestamp) + .ok_or(LendingError::MathOverflow)? as u128 + } else { + 0 + }; if elapsed > 0 && self.borrowed_principal > 0 { let borrowed_before = self.current_borrowed_amount()?; - let rate_per_slot = self.current_borrow_rate_per_slot()?; - let accrued = rate_per_slot - .checked_mul(elapsed as u128) + let rate_per_second = self.current_borrow_rate_per_second()?; + let accrued = rate_per_second + .checked_mul(elapsed) .ok_or(LendingError::MathOverflow)?; let growth_factor = FIXED_POINT_SCALE .checked_add(accrued) @@ -260,6 +269,9 @@ impl Reserve { .ok_or(LendingError::MathOverflow)?; } + if elapsed > 0 { + self.last_accrual_timestamp = current_timestamp; + } self.last_update_slot = current_slot; Ok(()) } diff --git a/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs b/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs index dcf51deb7..39756bd95 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs @@ -193,13 +193,38 @@ impl Env { } } - /// Advance time so interest accrues and blockhashes differ. + pub fn current_timestamp(&self) -> i64 { + self.svm.get_sysvar::().unix_timestamp + } + + /// Advance the slot only, leaving the Clock's timestamp where it is. Price + /// freshness is counted in slots, so this ages prices; interest is counted + /// in seconds, so on its own this accrues none. pub fn warp_slots(&mut self, slots: u64) { let target = self.current_slot() + slots; self.svm.warp_to_slot(target); self.svm.expire_blockhash(); } + /// Move the Clock's timestamp by `seconds` (backwards when negative), + /// leaving the slot where it is. Interest accrues on this clock. + pub fn shift_timestamp(&mut self, seconds: i64) { + let mut clock = self.svm.get_sysvar::(); + clock.unix_timestamp += seconds; + self.svm.set_sysvar(&clock); + self.svm.expire_blockhash(); + } + + /// Let `seconds` of wall-clock time pass: the timestamp moves by `seconds` + /// and the slot by five a second, the network's 200 ms target, so prices + /// age as they would. Interest accrues for exactly `seconds`, however many + /// slots that turns out to be. + pub fn warp_seconds(&mut self, seconds: i64) { + let target = self.current_slot() + seconds as u64 * 5; + self.svm.warp_to_slot(target); + self.shift_timestamp(seconds); + } + /// Simulate a cluster restart at `slot`: prices stamped at or before it /// must be rejected until the publisher posts again. pub fn set_last_restart_slot(&mut self, slot: u64) { @@ -732,12 +757,8 @@ 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; +/// A tenth of a 365-day year, in seconds: long enough for interest to show. +pub const TENTH_OF_A_YEAR: i64 = lending::constants::SECONDS_PER_YEAR as i64 / 10; pub fn default_config() -> ReserveConfig { ReserveConfig { @@ -750,6 +771,5 @@ 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-v1/programs/lending/tests/test_interest.rs b/finance/lending/anchor-v1/programs/lending/tests/test_interest.rs index 15d6be350..f802ed053 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_interest.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_interest.rs @@ -1,10 +1,10 @@ mod common; -use common::{default_config, dollars, ata, Env, SLOTS_PER_YEAR}; +use common::{default_config, dollars, ata, Env, TENTH_OF_A_YEAR}; use lending::constants::FIXED_POINT_SCALE; use solana_signer::Signer; -/// Borrowing at non-zero utilization, then letting slots pass, must grow the +/// Borrowing at non-zero utilization, then letting time pass, must grow the /// reserve's accumulation factor, the borrower's debt, and the share exchange rate. #[test] fn interest_accrues_on_borrows_over_time() { @@ -31,9 +31,8 @@ fn interest_accrues_on_borrows_over_time() { assert_eq!(env.reserve(&borrow).borrow_accumulation_factor, FIXED_POINT_SCALE); - // 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); + // Let a tenth of a year pass, then re-publish prices and refresh. + env.warp_seconds(TENTH_OF_A_YEAR); env.set_price(collateral.mint, dollars(1)); env.set_price(borrow.mint, dollars(1)); env.refresh_reserve_only(&borrower, &borrow); @@ -87,7 +86,7 @@ fn protocol_fees_accrue_and_owner_can_collect() { // No interest has accrued yet, so no fees. assert_eq!(env.reserve(&borrow).accumulated_protocol_fees, 0); - env.warp_slots(7_884_000); + env.warp_seconds(TENTH_OF_A_YEAR); env.refresh_reserve_only(&borrower, &borrow); // Fees accrued, and they are ~10% (the reserve factor) of total interest. diff --git a/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs b/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs index ac4439091..fd75ebbc5 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs @@ -1,6 +1,7 @@ mod common; -use common::{default_config, Env, SLOTS_PER_YEAR}; +use common::{default_config, Env}; +use lending::state::Reserve; use lending::constants::FIXED_POINT_SCALE; #[test] @@ -58,76 +59,114 @@ fn accepts_valid_config_update() { assert_eq!(env.reserve(&usdc).config.loan_to_value_bps, 6_000); } +/// A reserve at 50% utilization with a borrower drawing half the pool, so the +/// kinked curve resolves to a non-zero rate. Returns the collateral and borrow +/// reserves. +fn half_borrowed_reserve(env: &mut Env) -> (common::ReserveHandle, common::ReserveHandle) { + let collateral = env.add_reserve(6, common::dollars(1), default_config()); + let borrow = env.add_reserve(6, common::dollars(1), default_config()); + + let supplier = env.create_user(); + env.fund(&supplier, borrow.mint, 1_000_000_000); + env.supply(&supplier, &borrow, 1_000_000_000); + + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, borrow.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], &[], &borrow, 500_000_000) + .unwrap(); + (collateral, borrow) +} + +/// The factor after one refresh `seconds` after the last: one multiply by +/// `1 + rate_per_second * seconds`, floored, exactly as the program does it. +fn factor_after(reserve: &Reserve, seconds: u128) -> u128 { + let rate = reserve.current_borrow_rate_per_second().unwrap(); + reserve.borrow_accumulation_factor * (FIXED_POINT_SCALE + rate * seconds) / FIXED_POINT_SCALE +} + +/// The rate fields are annual, and a year is a length of wall-clock time, so +/// interest accrues for the seconds on the Clock's timestamp and ignores the +/// slot count. Slots passing on their own accrue nothing; seconds passing on +/// their own accrue exactly the per-second rate times the seconds. A shorter +/// or longer slot therefore cannot change what a borrower pays. #[test] -fn rejects_zero_slots_per_year() { +fn interest_accrues_by_seconds_not_slots() { let mut env = Env::new(); - let usdc = env.add_reserve(6, common::dollars(1), default_config()); + let (_collateral, borrow) = half_borrowed_reserve(&mut env); + let refresher = env.create_user(); + let before = env.reserve(&borrow); + + env.warp_slots(1_000_000); + env.refresh_reserve_only(&refresher, &borrow); + assert_eq!( + env.reserve(&borrow).borrow_accumulation_factor, + before.borrow_accumulation_factor, + "a million slots with the clock standing still must accrue nothing" + ); - 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" + let seconds = common::TENTH_OF_A_YEAR; + env.shift_timestamp(seconds); + env.refresh_reserve_only(&refresher, &borrow); + let after = env.reserve(&borrow); + assert!(after.borrow_accumulation_factor > before.borrow_accumulation_factor); + assert_eq!( + after.borrow_accumulation_factor, + factor_after(&before, seconds as u128) ); + assert_eq!(after.last_accrual_timestamp, env.current_timestamp()); } -/// 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. +/// The leader writes the timestamp, and a timestamp at or before the stored +/// one accrues nothing and leaves the stored stamp alone. When the clock +/// moves forward again, only the seconds past the stored stamp are charged, +/// so no second is charged twice. #[test] -fn slots_per_year_scales_the_per_slot_rate() { +fn a_timestamp_behind_the_last_accrual_charges_nothing() { 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 (_collateral, borrow) = half_borrowed_reserve(&mut env); 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" + let before = env.reserve(&borrow); + + env.shift_timestamp(-600); + env.refresh_reserve_only(&refresher, &borrow); + let behind = env.reserve(&borrow); + assert_eq!(behind.borrow_accumulation_factor, before.borrow_accumulation_factor); + assert_eq!(behind.last_accrual_timestamp, before.last_accrual_timestamp); + + // 1,600 seconds forward from the shifted clock is 1,000 past the stamp. + env.shift_timestamp(1_600); + env.refresh_reserve_only(&refresher, &borrow); + assert_eq!( + env.reserve(&borrow).borrow_accumulation_factor, + factor_after(&before, 1_000) ); +} - // 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}" +/// Changing the rate curve accrues at the old curve first, so the seconds +/// since the last refresh are charged at the rates that applied to them +/// rather than repriced by the new ones. +#[test] +fn a_config_update_accrues_at_the_old_rates_first() { + let mut env = Env::new(); + let (_collateral, borrow) = half_borrowed_reserve(&mut env); + let before = env.reserve(&borrow); + + let seconds = common::TENTH_OF_A_YEAR; + env.warp_seconds(seconds); + let mut steeper = default_config(); + steeper.min_borrow_rate_bps = 1_000; + steeper.optimal_borrow_rate_bps = 5_000; + steeper.max_borrow_rate_bps = 20_000; + env.try_update_config(&borrow, steeper).unwrap(); + + let after = env.reserve(&borrow); + assert_eq!(after.config.optimal_borrow_rate_bps, 5_000); + assert_eq!( + after.borrow_accumulation_factor, + factor_after(&before, seconds as u128) ); } diff --git a/finance/lending/anchor-v1/programs/lending/tests/test_rounding.rs b/finance/lending/anchor-v1/programs/lending/tests/test_rounding.rs index 7cebb803c..983be7d63 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_rounding.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_rounding.rs @@ -26,7 +26,7 @@ fn deposit_that_would_mint_zero_shares_is_rejected() { .unwrap(); // Accrue enough interest that total liquidity exceeds the share supply. - env.warp_slots(7_884_000); + env.warp_seconds(common::TENTH_OF_A_YEAR); env.refresh_reserve_only(&borrower, &borrow); assert!(env.reserve(&borrow).borrow_accumulation_factor > lending::constants::FIXED_POINT_SCALE); diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index dab8f1686..481fe877f 100644 --- a/finance/lending/anchor/CHANGELOG.md +++ b/finance/lending/anchor/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 2026-09-22 + +Accrue interest by the wall clock instead of by slots. The reserve's +`slots_per_year` config field was a guess at the cluster's slot length, and the +test default of 78,840,000 (a 400 ms slot) charged twice the advertised APR once +the network moved to 200 ms slots. Interest now accrues for the seconds between +the Clock's `unix_timestamp` and the new `Reserve::last_accrual_timestamp`, at +the APR divided by `SECONDS_PER_YEAR`; `slots_per_year` is gone from +`ReserveConfig`, and `current_borrow_rate_per_slot` is now +`current_borrow_rate_per_second`. A timestamp at or before the stored one +accrues nothing. `last_update_slot` stays, as the same-slot refresh check. +`update_reserve_config` now accrues at the old curve before storing the new one. +Tested by `interest_accrues_by_seconds_not_slots`, +`a_timestamp_behind_the_last_accrual_charges_nothing` and +`a_config_update_accrues_at_the_old_rates_first`, which replace +`slots_per_year_scales_the_per_slot_rate` and `rejects_zero_slots_per_year`. + ## 2026-08-14 Move slots-per-year out of the code and into the reserve config. Turning an APR diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index f3376a85f..40713099f 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -63,22 +63,28 @@ closing the classic empty-pool inflation attack. The first deposit mints 1:1. ### Interest: a kinked curve and a cumulative index Each `refresh_reserve` advances `borrow_accumulation_factor` by -`(1 + rate_per_slot * elapsed_slots)`. `rate_per_slot` comes from a kinked +`(1 + rate_per_second * elapsed_seconds)`. `rate_per_second` comes from a kinked utilization curve: linear from `min_borrow_rate_bps` to `optimal_borrow_rate_bps` up to `optimal_utilization_bps`, then steeper to `max_borrow_rate_bps` at full 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`. +Those curve parameters are annual, and the conversion to a per-second rate +divides by `SECONDS_PER_YEAR`. Elapsed time is the Clock's `unix_timestamp` +minus the reserve's `last_accrual_timestamp`, so a borrower pays the advertised +APR over a real year whatever the cluster's slot time is. Counting slots instead +would need a slots-per-year divisor, which is a guess at the slot length: the +network changes that length by feature gate, and does not deliver it exactly +between changes. The timestamp is written by each block's leader, but the +runtime bounds how far one block can move it, so an elapsed time is out by a +second or two at most, which is nothing against an annual rate. A timestamp at +or before the stored one accrues nothing. `update_reserve_config` accrues at the +old curve before storing a new one, so the seconds already elapsed are charged +at the rates that applied to them. + +The reserve still records `last_update_slot`, for a different job: handlers that +read the reserve's value require the refresh to have run in the current slot. ### Protocol fees (how the market earns) diff --git a/finance/lending/anchor/programs/lending/src/constants.rs b/finance/lending/anchor/programs/lending/src/constants.rs index 5da22e876..a56c893d5 100644 --- a/finance/lending/anchor/programs/lending/src/constants.rs +++ b/finance/lending/anchor/programs/lending/src/constants.rs @@ -8,7 +8,7 @@ /// values. A ratio `r` is stored as the integer `r * FIXED_POINT_SCALE`. /// /// All money math is integer-only (no floats, no fixed-point crates). 10^18 -/// keeps a single slot's interest — which can be a tiny fraction of the index — +/// keeps a single second's interest, which can be a tiny fraction of the index, /// from truncating to zero, while u128's ~3.4e38 ceiling leaves headroom for the /// index to grow and for intermediate products before the final narrowing cast. pub const FIXED_POINT_SCALE: u128 = 1_000_000_000_000_000_000; @@ -26,6 +26,12 @@ pub const BPS_DENOMINATOR: u128 = 10_000; /// refresh_obligation (which iterates every entry). pub const MAX_OBLIGATION_RESERVES: usize = 4; +/// Seconds in a 365-day year: the divisor that turns an annual rate into the +/// per-second rate interest accrues at. Interest runs on the wall clock, not +/// the slot count, because a rate quoted per year is a promise about wall-clock +/// time and a slots-per-year divisor is only a guess at the slot length. +pub const SECONDS_PER_YEAR: u128 = 31_536_000; + /// 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 diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/initialize_reserve.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/initialize_reserve.rs index 74976f2ae..a559ffb28 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/admin/initialize_reserve.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/initialize_reserve.rs @@ -25,7 +25,9 @@ pub fn handle_initialize_reserve( reserve.share_mint_supply = 0; reserve.borrowed_principal = 0; reserve.borrow_accumulation_factor = FIXED_POINT_SCALE; - reserve.last_update_slot = Clock::get()?.slot; + let clock = Clock::get()?; + reserve.last_update_slot = clock.slot; + reserve.last_accrual_timestamp = clock.unix_timestamp; reserve.accumulated_protocol_fees = 0; reserve.config = config; reserve.bump = context.bumps.reserve; diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs index ddcf53dd0..89f13a2a3 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs @@ -7,7 +7,13 @@ pub fn handle_update_reserve_config( config: ReserveConfig, ) -> Result<()> { config.validate()?; - context.accounts.reserve.config = config; + // Accrue at the old curve first, so the seconds since the last refresh are + // charged at the rates that applied to them rather than repriced by the new + // ones. + let clock = Clock::get()?; + let reserve = &mut context.accounts.reserve; + reserve.accrue_interest(clock.slot, clock.unix_timestamp)?; + reserve.config = config; Ok(()) } diff --git a/finance/lending/anchor/programs/lending/src/instructions/refresh_reserve.rs b/finance/lending/anchor/programs/lending/src/instructions/refresh_reserve.rs index e2770d14a..b2b407f33 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/refresh_reserve.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/refresh_reserve.rs @@ -2,11 +2,15 @@ use anchor_lang::prelude::*; use crate::state::Reserve; -/// Accrue interest up to the current slot. Must run (as its own instruction in +/// Accrue interest up to the current time. Must run (as its own instruction in /// the same transaction) before any handler that reads the reserve's value, and /// before `refresh_obligation` for any reserve the obligation touches. pub fn handle_refresh_reserve(context: &mut Context) -> Result<()> { - context.accounts.reserve.accrue_interest(Clock::get()?.slot) + let clock = Clock::get()?; + context + .accounts + .reserve + .accrue_interest(clock.slot, clock.unix_timestamp) } #[derive(Accounts)] diff --git a/finance/lending/anchor/programs/lending/src/state/reserve.rs b/finance/lending/anchor/programs/lending/src/state/reserve.rs index 9ffc392e3..53ebfdf25 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}; +use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, RESERVE_SEED, SECONDS_PER_YEAR}; use crate::errors::LendingError; use crate::math::{mul_div_ceil, mul_div_floor}; @@ -63,8 +63,16 @@ pub struct Reserve { /// Starts at FIXED_POINT_SCALE (1.0) and only ever multiplies by factors >= 1. pub borrow_accumulation_factor: u128, + /// The slot of the last refresh. Handlers that read the reserve's value + /// require it to equal the current slot, so the refresh ran in this + /// transaction. This is a freshness check, not the accrual clock. pub last_update_slot: u64, + /// The Clock's `unix_timestamp` at the last accrual. Interest accrues for + /// the seconds since, so it follows wall-clock time whatever the slot + /// length is. + pub last_accrual_timestamp: i64, + /// Liquidity owed to the market owner: the protocol's cut of accrued /// interest (`config.reserve_factor_bps`). It is carved out of /// `total_liquidity` so it never inflates the share exchange rate, and the @@ -100,14 +108,6 @@ 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 { @@ -140,8 +140,6 @@ 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(()) } } @@ -187,10 +185,10 @@ impl Reserve { ) } - /// Per-slot borrow rate (FIXED_POINT_SCALE-scaled) from the kinked curve: + /// Per-second borrow rate (FIXED_POINT_SCALE-scaled) from the kinked curve: /// linear from `min` to `optimal` up to the kink, then steeper from `optimal` /// to `max` between the kink and full utilization. - pub fn current_borrow_rate_per_slot(&self) -> Result { + pub fn current_borrow_rate_per_second(&self) -> Result { let utilization = self.utilization_bps()?; let optimal_utilization = self.config.optimal_utilization_bps as u128; @@ -218,26 +216,37 @@ impl Reserve { .ok_or(LendingError::MathOverflow)? }; - // apr_bps / (BPS_DENOMINATOR * slots_per_year), carried at FIXED_POINT_SCALE. + // apr_bps / (BPS_DENOMINATOR * SECONDS_PER_YEAR), carried at FIXED_POINT_SCALE. let per_year_denominator = BPS_DENOMINATOR - .checked_mul(self.config.slots_per_year as u128) + .checked_mul(SECONDS_PER_YEAR) .ok_or(LendingError::MathOverflow)?; mul_div_floor(apr_bps, FIXED_POINT_SCALE, per_year_denominator) } - /// Advance the accumulation factor for the slots elapsed since the last refresh. - /// `new_factor = old_factor * (1 + rate_per_slot * elapsed_slots)`, a single - /// multiply per refresh that compounds across refreshes (Solend's approach). - pub fn accrue_interest(&mut self, current_slot: u64) -> Result<()> { - let elapsed = current_slot - .checked_sub(self.last_update_slot) - .ok_or(LendingError::MathOverflow)?; + /// Advance the accumulation factor for the seconds elapsed since the last + /// accrual, and record `current_slot` as the slot of this refresh. + /// `new_factor = old_factor * (1 + rate_per_second * elapsed_seconds)`, a + /// single multiply per refresh that compounds across refreshes (Solend's + /// approach, on the wall clock rather than the slot count). + /// + /// The timestamp is written by each block's leader. The runtime rejects a + /// block whose time goes backwards, but a timestamp at or before the stored + /// one is still treated as no time elapsed, and the stored stamp is left + /// where it is, so no second is ever charged twice or skipped. + pub fn accrue_interest(&mut self, current_slot: u64, current_timestamp: i64) -> Result<()> { + let elapsed = if current_timestamp > self.last_accrual_timestamp { + current_timestamp + .checked_sub(self.last_accrual_timestamp) + .ok_or(LendingError::MathOverflow)? as u128 + } else { + 0 + }; if elapsed > 0 && self.borrowed_principal > 0 { let borrowed_before = self.current_borrowed_amount()?; - let rate_per_slot = self.current_borrow_rate_per_slot()?; - let accrued = rate_per_slot - .checked_mul(elapsed as u128) + let rate_per_second = self.current_borrow_rate_per_second()?; + let accrued = rate_per_second + .checked_mul(elapsed) .ok_or(LendingError::MathOverflow)?; let growth_factor = FIXED_POINT_SCALE .checked_add(accrued) @@ -266,6 +275,9 @@ impl Reserve { .ok_or(LendingError::MathOverflow)?; } + if elapsed > 0 { + self.last_accrual_timestamp = current_timestamp; + } self.last_update_slot = current_slot; Ok(()) } diff --git a/finance/lending/anchor/programs/lending/tests/common/mod.rs b/finance/lending/anchor/programs/lending/tests/common/mod.rs index ce490c299..d369b8df1 100644 --- a/finance/lending/anchor/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor/programs/lending/tests/common/mod.rs @@ -219,13 +219,38 @@ impl Env { } } - /// Advance time so interest accrues and blockhashes differ. + pub fn current_timestamp(&self) -> i64 { + self.svm.get_sysvar::().unix_timestamp + } + + /// Advance the slot only, leaving the Clock's timestamp where it is. Price + /// freshness is counted in slots, so this ages prices; interest is counted + /// in seconds, so on its own this accrues none. pub fn warp_slots(&mut self, slots: u64) { let target = self.current_slot() + slots; self.svm.warp_to_slot(target); self.svm.expire_blockhash(); } + /// Move the Clock's timestamp by `seconds` (backwards when negative), + /// leaving the slot where it is. Interest accrues on this clock. + pub fn shift_timestamp(&mut self, seconds: i64) { + let mut clock = self.svm.get_sysvar::(); + clock.unix_timestamp += seconds; + self.svm.set_sysvar(&clock); + self.svm.expire_blockhash(); + } + + /// Let `seconds` of wall-clock time pass: the timestamp moves by `seconds` + /// and the slot by five a second, the network's 200 ms target, so prices + /// age as they would. Interest accrues for exactly `seconds`, however many + /// slots that turns out to be. + pub fn warp_seconds(&mut self, seconds: i64) { + let target = self.current_slot() + seconds as u64 * 5; + self.svm.warp_to_slot(target); + self.shift_timestamp(seconds); + } + /// Simulate a cluster restart at `slot`: prices stamped at or before it /// must be rejected until the publisher posts again. pub fn set_last_restart_slot(&mut self, slot: u64) { @@ -797,12 +822,8 @@ 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; +/// A tenth of a 365-day year, in seconds: long enough for interest to show. +pub const TENTH_OF_A_YEAR: i64 = lending::constants::SECONDS_PER_YEAR as i64 / 10; pub fn default_config() -> ReserveConfig { ReserveConfig { @@ -815,6 +836,5 @@ 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 864c9423f..0b7814480 100644 --- a/finance/lending/anchor/programs/lending/tests/test_interest.rs +++ b/finance/lending/anchor/programs/lending/tests/test_interest.rs @@ -1,10 +1,10 @@ mod common; use anchor_v2_testing::Signer; -use common::{ata, default_config, dollars, Env, SLOTS_PER_YEAR}; +use common::{ata, default_config, dollars, Env, TENTH_OF_A_YEAR}; use lending::constants::FIXED_POINT_SCALE; -/// Borrowing at non-zero utilization, then letting slots pass, must grow the +/// Borrowing at non-zero utilization, then letting time pass, must grow the /// reserve's accumulation factor, the borrower's debt, and the share exchange rate. #[test] fn interest_accrues_on_borrows_over_time() { @@ -41,9 +41,8 @@ fn interest_accrues_on_borrows_over_time() { FIXED_POINT_SCALE ); - // 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); + // Let a tenth of a year pass, then re-publish prices and refresh. + env.warp_seconds(TENTH_OF_A_YEAR); env.set_price(collateral.mint, dollars(1)); env.set_price(borrow.mint, dollars(1)); env.refresh_reserve_only(&borrower, &borrow); @@ -104,7 +103,7 @@ fn protocol_fees_accrue_and_owner_can_collect() { // No interest has accrued yet, so no fees. assert_eq!(env.reserve(&borrow).accumulated_protocol_fees, 0); - env.warp_slots(7_884_000); + env.warp_seconds(TENTH_OF_A_YEAR); env.refresh_reserve_only(&borrower, &borrow); // Fees accrued, and they are ~10% (the reserve factor) of total interest. diff --git a/finance/lending/anchor/programs/lending/tests/test_reserve.rs b/finance/lending/anchor/programs/lending/tests/test_reserve.rs index c1cd2af92..b73a236c9 100644 --- a/finance/lending/anchor/programs/lending/tests/test_reserve.rs +++ b/finance/lending/anchor/programs/lending/tests/test_reserve.rs @@ -2,7 +2,8 @@ mod common; use lending::errors::LendingError; -use common::{default_config, Env, SLOTS_PER_YEAR}; +use common::{default_config, Env}; +use lending::state::Reserve; use lending::constants::FIXED_POINT_SCALE; #[test] @@ -57,80 +58,121 @@ fn accepts_valid_config_update() { 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()); +/// A reserve at 50% utilization with a borrower drawing half the pool, so the +/// kinked curve resolves to a non-zero rate. Returns the collateral and borrow +/// reserves. +fn half_borrowed_reserve(env: &mut Env) -> (common::ReserveHandle, common::ReserveHandle) { + let collateral = env.add_reserve(6, common::dollars(1), default_config()); + let borrow = env.add_reserve(6, common::dollars(1), default_config()); + + let supplier = env.create_user(); + env.fund(&supplier, borrow.mint, 1_000_000_000); + env.supply(&supplier, &borrow, 1_000_000_000); + + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, borrow.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], + &[], + &borrow, + 500_000_000, + ) + .unwrap(); + (collateral, borrow) +} - let mut bad = default_config(); - bad.slots_per_year = 0; - let result = env.try_update_config(&usdc, bad); - common::assert_program_error!(result, LendingError::InvalidConfig); +/// The factor after one refresh `seconds` after the last: one multiply by +/// `1 + rate_per_second * seconds`, floored, exactly as the program does it. +fn factor_after(reserve: &Reserve, seconds: u128) -> u128 { + let rate = reserve.current_borrow_rate_per_second().unwrap(); + reserve.borrow_accumulation_factor * (FIXED_POINT_SCALE + rate * seconds) / FIXED_POINT_SCALE } -/// 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. +/// The rate fields are annual, and a year is a length of wall-clock time, so +/// interest accrues for the seconds on the Clock's timestamp and ignores the +/// slot count. Slots passing on their own accrue nothing; seconds passing on +/// their own accrue exactly the per-second rate times the seconds. A shorter +/// or longer slot therefore cannot change what a borrower pays. #[test] -fn slots_per_year_scales_the_per_slot_rate() { +fn interest_accrues_by_seconds_not_slots() { let mut env = Env::new(); - let collateral = env.add_reserve(6, common::dollars(1), default_config()); + let (_collateral, borrow) = half_borrowed_reserve(&mut env); + let refresher = env.create_user(); + let before = env.reserve(&borrow); + + env.warp_slots(1_000_000); + env.refresh_reserve_only(&refresher, &borrow); + assert_eq!( + env.reserve(&borrow).borrow_accumulation_factor, + before.borrow_accumulation_factor, + "a million slots with the clock standing still must accrue nothing" + ); - 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 seconds = common::TENTH_OF_A_YEAR; + env.shift_timestamp(seconds); + env.refresh_reserve_only(&refresher, &borrow); + let after = env.reserve(&borrow); + assert!(after.borrow_accumulation_factor > before.borrow_accumulation_factor); + assert_eq!( + after.borrow_accumulation_factor, + factor_after(&before, seconds as u128) + ); + assert_eq!(after.last_accrual_timestamp, env.current_timestamp()); +} +/// The leader writes the timestamp, and a timestamp at or before the stored +/// one accrues nothing and leaves the stored stamp alone. When the clock +/// moves forward again, only the seconds past the stored stamp are charged, +/// so no second is charged twice. +#[test] +fn a_timestamp_behind_the_last_accrual_charges_nothing() { + let mut env = Env::new(); + let (_collateral, borrow) = half_borrowed_reserve(&mut env); 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" + let before = env.reserve(&borrow); + + env.shift_timestamp(-600); + env.refresh_reserve_only(&refresher, &borrow); + let behind = env.reserve(&borrow); + assert_eq!(behind.borrow_accumulation_factor, before.borrow_accumulation_factor); + assert_eq!(behind.last_accrual_timestamp, before.last_accrual_timestamp); + + // 1,600 seconds forward from the shifted clock is 1,000 past the stamp. + env.shift_timestamp(1_600); + env.refresh_reserve_only(&refresher, &borrow); + assert_eq!( + env.reserve(&borrow).borrow_accumulation_factor, + factor_after(&before, 1_000) ); +} - // 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}" +/// Changing the rate curve accrues at the old curve first, so the seconds +/// since the last refresh are charged at the rates that applied to them +/// rather than repriced by the new ones. +#[test] +fn a_config_update_accrues_at_the_old_rates_first() { + let mut env = Env::new(); + let (_collateral, borrow) = half_borrowed_reserve(&mut env); + let before = env.reserve(&borrow); + + let seconds = common::TENTH_OF_A_YEAR; + env.warp_seconds(seconds); + let mut steeper = default_config(); + steeper.min_borrow_rate_bps = 1_000; + steeper.optimal_borrow_rate_bps = 5_000; + steeper.max_borrow_rate_bps = 20_000; + env.try_update_config(&borrow, steeper).unwrap(); + + let after = env.reserve(&borrow); + assert_eq!(after.config.optimal_borrow_rate_bps, 5_000); + assert_eq!( + after.borrow_accumulation_factor, + factor_after(&before, seconds as u128) ); } diff --git a/finance/lending/anchor/programs/lending/tests/test_rounding.rs b/finance/lending/anchor/programs/lending/tests/test_rounding.rs index f135e4dbe..cc0e2d0fe 100644 --- a/finance/lending/anchor/programs/lending/tests/test_rounding.rs +++ b/finance/lending/anchor/programs/lending/tests/test_rounding.rs @@ -35,7 +35,7 @@ fn deposit_that_would_mint_zero_shares_is_rejected() { .unwrap(); // Accrue enough interest that total liquidity exceeds the share supply. - env.warp_slots(7_884_000); + env.warp_seconds(common::TENTH_OF_A_YEAR); env.refresh_reserve_only(&borrower, &borrow); assert!( env.reserve(&borrow).borrow_accumulation_factor > lending::constants::FIXED_POINT_SCALE diff --git a/finance/lending/kani-proofs/src/lib.rs b/finance/lending/kani-proofs/src/lib.rs index 067308dc0..41e4d67d5 100644 --- a/finance/lending/kani-proofs/src/lib.rs +++ b/finance/lending/kani-proofs/src/lib.rs @@ -164,7 +164,7 @@ fn proof_utilization_in_range() { assert!(util <= BPS_DENOMINATOR); } -/// The kinked borrow-rate APR (bps) from `current_borrow_rate_per_slot`, given a +/// The kinked borrow-rate APR (bps) from `current_borrow_rate_per_second`, given a /// utilization and the curve parameters. Mirrors the two-segment formula. /// /// `full_utilization` is the 100%-utilization denominator — `BPS_DENOMINATOR` diff --git a/finance/perpetual-futures/anchor-v1/CHANGELOG.md b/finance/perpetual-futures/anchor-v1/CHANGELOG.md index 3af811d66..2aa4502b9 100644 --- a/finance/perpetual-futures/anchor-v1/CHANGELOG.md +++ b/finance/perpetual-futures/anchor-v1/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 2026-09-22 + +Accrue funding by the wall clock instead of by slots. The rate was quoted per +slot, so what a position cost per hour moved with the cluster's slot time, and +the reduction to 200 ms slots doubled it. `Pool::funding_rate_per_slot` is now +`funding_rate_per_second`, and `last_funding_slot` is now +`last_funding_timestamp`, the Clock's `unix_timestamp` at the last accrual; the +same rename applies to `PoolParameters` and `set_funding_rate`'s argument. A +timestamp at or before the stored one accrues nothing. Tested by +`test_funding_follows_seconds_not_slots`, with +`test_set_funding_rate_settles_at_the_old_rate_first` and +`test_funding_charged_to_long` now counting seconds. + ## 2026-09-10 Remove the separate dataless signing PDA (seeds `["authority", pool]`) that diff --git a/finance/perpetual-futures/anchor-v1/README.md b/finance/perpetual-futures/anchor-v1/README.md index 244310404..18796c42c 100644 --- a/finance/perpetual-futures/anchor-v1/README.md +++ b/finance/perpetual-futures/anchor-v1/README.md @@ -46,9 +46,9 @@ So a winning trader can always be paid, the pool **reserves** liquidity to back ### Funding -[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. +[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_second` for each second on the Clock's `unix_timestamp`; 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 operator 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. +Funding runs on the wall clock rather than the slot count, so what a position costs per hour is set by the rate alone and does not change when the cluster's slot time does. The timestamp is written by each block's leader, but the runtime bounds how far one block can move it, so the elapsed time behind a position's funding is out by a second or two at most; a timestamp at or before the stored `last_funding_timestamp` accrues nothing. `set_funding_rate(funding_rate_per_second)` lets the pool operator change the rate; it advances the index at the old rate first, so seconds already elapsed are charged at the rate that was in force for them. ### Maintenance margin and liquidation @@ -212,7 +212,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, 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. +The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover liquidity round-trips, opening and closing longs and shorts in profit and loss, leverage and slippage rejection, stale-price, pre-restart-price, and wide-confidence rejection, funding accrual, funding-rate retuning (including that it settles elapsed seconds at the old rate, and that only the authority may call it), funding that follows seconds rather than slots, liquidation (and the refusal to liquidate a healthy position), reserved-liquidity behaviour (profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity), and fee collection. ```bash anchor build diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs index d7476ae4f..a2cd3a6f1 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs @@ -6,8 +6,8 @@ use anchor_lang::prelude::*; pub const BASIS_POINTS_DENOMINATOR: u64 = 10_000; /// Fixed-point precision for the cumulative funding index. The index is carried -/// as `i128` scaled by this factor so per-slot funding (a tiny ratio) keeps its -/// precision when integrated over many slots. +/// as `i128` scaled by this factor so per-second funding (a tiny ratio) keeps its +/// precision when integrated over many seconds. pub const FUNDING_PRECISION: i128 = 1_000_000_000; /// Fixed-point precision for the aggregate `size / entry_price` accumulators the diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs index 1389fcb49..7b597e0c6 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs @@ -17,9 +17,9 @@ pub struct PoolParameters { /// Decimal places the oracle quotes its price in (e.g. 8). pub oracle_scale: u32, - /// Funding accrued per slot, in `FUNDING_PRECISION` units, charged to the + /// Funding accrued per second, in `FUNDING_PRECISION` units, charged to the /// heavier side. - pub funding_rate_per_slot: u64, + pub funding_rate_per_second: u64, pub open_fee_bps: u16, pub close_fee_bps: u16, @@ -90,8 +90,8 @@ pub fn handle_initialize_pool( pool.long_size_scaled = 0; pool.short_size_scaled = 0; pool.cumulative_funding = 0; - pool.last_funding_slot = Clock::get()?.slot; - pool.funding_rate_per_slot = parameters.funding_rate_per_slot; + pool.last_funding_timestamp = Clock::get()?.unix_timestamp; + pool.funding_rate_per_second = parameters.funding_rate_per_second; pool.open_fee_bps = parameters.open_fee_bps; pool.close_fee_bps = parameters.close_fee_bps; pool.max_leverage = parameters.max_leverage; diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/set_funding_rate.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/set_funding_rate.rs index 03a98fb05..454979e67 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/set_funding_rate.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/set_funding_rate.rs @@ -4,22 +4,18 @@ 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. +/// Retune the pool's funding rate, quoted per second of wall-clock time. /// -/// Funding is accrued at the old rate first, so the slots already elapsed are +/// Funding is accrued at the old rate first, so the seconds 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, + funding_rate_per_second: u64, ) -> Result<()> { let pool = &mut context.accounts.pool; - accrue_funding(pool, Clock::get()?.slot)?; - pool.funding_rate_per_slot = funding_rate_per_slot; + accrue_funding(pool, Clock::get()?.unix_timestamp)?; + pool.funding_rate_per_second = funding_rate_per_second; Ok(()) } diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs index e8e322092..af20cdfb4 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs @@ -67,20 +67,27 @@ pub fn settle_position(pool: &mut Pool, position: &Position, price: u64) -> Resu }) } -/// Advance the pool's cumulative funding index to `current_slot`. +/// Advance the pool's cumulative funding index to `current_timestamp`, the +/// Clock's `unix_timestamp`. /// /// The heavier open-interest side pays funding to the pool: while longs are /// larger the index rises (longs owe), while shorts are larger it falls (shorts /// owe). No positions means no one to charge, so the index is left untouched and /// only the timestamp moves forward. -pub fn accrue_funding(pool: &mut Pool, current_slot: u64) -> Result<()> { - let elapsed = current_slot.saturating_sub(pool.last_funding_slot); - if elapsed == 0 { +/// +/// The timestamp is written by each block's leader. A timestamp at or before +/// the stored one is treated as no time elapsed and leaves the stored stamp +/// where it is, so no second is charged twice or skipped. +pub fn accrue_funding(pool: &mut Pool, current_timestamp: i64) -> Result<()> { + if current_timestamp <= pool.last_funding_timestamp { return Ok(()); } + let elapsed = current_timestamp + .checked_sub(pool.last_funding_timestamp) + .ok_or(PerpError::MathOverflow)?; if pool.long_size != 0 || pool.short_size != 0 { - let magnitude = (pool.funding_rate_per_slot as i128) + let magnitude = (pool.funding_rate_per_second as i128) .checked_mul(elapsed as i128) .ok_or(PerpError::MathOverflow)?; let delta = if pool.long_size >= pool.short_size { @@ -94,7 +101,7 @@ pub fn accrue_funding(pool: &mut Pool, current_slot: u64) -> Result<()> { .ok_or(PerpError::MathOverflow)?; } - pool.last_funding_slot = current_slot; + pool.last_funding_timestamp = current_timestamp; Ok(()) } @@ -210,7 +217,7 @@ pub fn basis_points_of(amount: u64, basis_points: u16) -> Result { } /// The preamble every price-sensitive handler runs: read a validated oracle -/// price, then bring the pool's funding index up to the current slot, so the +/// price, then bring the pool's funding index up to the current time, so the /// settlement that follows uses fresh numbers for both. Centralized so no /// handler can settle a position against a stale funding index. pub fn refresh_price_and_funding(pool: &mut Pool, oracle_feed: &AccountInfo) -> Result { @@ -219,6 +226,6 @@ pub fn refresh_price_and_funding(pool: &mut Pool, oracle_feed: &AccountInfo) -> pool.oracle_scale, pool.max_confidence_bps, )?; - accrue_funding(pool, Clock::get()?.slot)?; + accrue_funding(pool, Clock::get()?.unix_timestamp)?; Ok(price) } diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs index 3044c8337..0f49f100d 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/lib.rs @@ -80,12 +80,12 @@ pub mod perpetual_futures { instructions::handle_collect_fees(context) } - /// The pool operator retunes the per-slot funding rate, accruing at the + /// The pool operator retunes the per-second funding rate, accruing at the /// old rate first. pub fn set_funding_rate( context: Context, - funding_rate_per_slot: u64, + funding_rate_per_second: u64, ) -> Result<()> { - instructions::handle_set_funding_rate(context, funding_rate_per_slot) + instructions::handle_set_funding_rate(context, funding_rate_per_second) } } diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs index f50167db1..9e3eea141 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs @@ -66,11 +66,14 @@ pub struct Pool { /// open and close. pub cumulative_funding: i128, - pub last_funding_slot: u64, + /// The Clock's `unix_timestamp` when funding last accrued. Funding runs on + /// the wall clock, so what a position costs per hour does not depend on + /// the cluster's slot time. + pub last_funding_timestamp: i64, - /// Funding accrued per slot, in `FUNDING_PRECISION` units, applied to the + /// Funding accrued per second, in `FUNDING_PRECISION` units, applied to the /// heavier side. The funding paid by traders accrues to the pool. - pub funding_rate_per_slot: u64, + pub funding_rate_per_second: u64, /// Fee charged on notional when opening a position, in basis points. pub open_fee_bps: u16, diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs index 40022aeea..ea34726ce 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -60,13 +60,13 @@ struct Market { } impl Market { - /// Stand up a market with the given starting oracle price and per-slot + /// Stand up a market with the given starting oracle price and per-second /// funding rate. The admin is both the pool operator and the oracle feed /// authority. - fn new(initial_price: i128, funding_rate_per_slot: u64) -> Market { + fn new(initial_price: i128, funding_rate_per_second: u64) -> Market { let parameters = PoolParameters { oracle_scale: ORACLE_SCALE, - funding_rate_per_slot, + funding_rate_per_second, open_fee_bps: 10, close_fee_bps: 10, max_leverage: 10, @@ -209,11 +209,26 @@ impl Market { .unwrap(); } + /// Move to `slot`, leaving the Clock's timestamp where it is. Price + /// freshness is counted in slots, so this ages prices; funding is counted + /// in seconds, so on its own this accrues none. fn warp(&mut self, slot: u64) { self.svm.warp_to_slot(slot); self.svm.expire_blockhash(); } + /// Let `seconds` of wall-clock time pass: the timestamp moves by `seconds` + /// and the slot by five a second, the network's 200 ms target. Funding + /// accrues for exactly `seconds`, however many slots that turns out to be. + fn pass_seconds(&mut self, seconds: i64) { + let target = self.current_slot() + seconds as u64 * 5; + self.svm.warp_to_slot(target); + let mut clock = self.svm.get_sysvar::(); + clock.unix_timestamp += seconds; + self.svm.set_sysvar(&clock); + self.svm.expire_blockhash(); + } + fn current_slot(&self) -> u64 { self.svm.get_sysvar::().slot } @@ -488,7 +503,7 @@ impl Market { let instruction = Instruction::new_with_bytes( perpetual_futures::id(), &perpetual_futures::instruction::SetFundingRate { - funding_rate_per_slot: rate, + funding_rate_per_second: rate, } .data(), perpetual_futures::accounts::SetFundingRateAccountConstraints { @@ -895,10 +910,8 @@ fn test_funding_charged_to_long() { let liquidity_before = market.pool_state().liquidity; // Let funding accrue, then refresh the feed so the price is fresh again and - // close at the same price (no profit/loss). Warp relative to the current - // slot: LiteSVM starts the clock at a mainnet-like slot, not at zero. - let opened_at = market.current_slot(); - market.warp(opened_at + 2_000); + // close at the same price (no profit/loss). + market.pass_seconds(2_000); market.set_price(dollars(100)); market .close_position(&trader, trader_collateral, Side::Long, 0) @@ -920,56 +933,55 @@ 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 operator retunes the rate, and the retune must settle the slots already -/// elapsed at the old rate rather than repricing them at the new one. +/// Funding held open for a window, closed at an unchanged price: returns the +/// funding the trader paid. `between` runs halfway through. +fn funding_paid_over(rate: u64, window: i64, between: impl Fn(&mut Market)) -> 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(); + + market.pass_seconds(window); + between(&mut market); + market.pass_seconds(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 +} + +/// Retuning the rate settles the seconds 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 + // Same position and the same total elapsed seconds 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); + let flat = funding_paid_over(rate, window, |_| {}); + let retuned = funding_paid_over(rate, window, |market| { + let admin = market.admin.insecure_clone(); + market.set_funding_rate(&admin, rate * 2).unwrap(); + }); 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. + // Half the elapsed seconds 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 + // second and this would be 2x. assert_eq!( retuned * 2, flat * 3, @@ -977,6 +989,22 @@ fn test_set_funding_rate_settles_at_the_old_rate_first() { ); } +/// Funding is quoted per second of wall-clock time, so slots passing without +/// the clock moving charge nothing. A million extra slots halfway through the +/// window, as a much shorter slot would produce, leave the funding unchanged. +#[test] +fn test_funding_follows_seconds_not_slots() { + let rate = 5_000; + let window = 2_000; + let flat = funding_paid_over(rate, window, |_| {}); + let with_extra_slots = funding_paid_over(rate, window, |market| { + let slot = market.current_slot(); + market.warp(slot + 1_000_000); + }); + assert!(flat > 0); + assert_eq!(with_extra_slots, flat); +} + #[test] fn test_only_authority_can_set_funding_rate() { let mut market = Market::new(dollars(100), 5_000); @@ -1201,7 +1229,7 @@ fn test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin() { // close, so initialize_pool refuses the configuration. let parameters = PoolParameters { oracle_scale: ORACLE_SCALE, - funding_rate_per_slot: 0, + funding_rate_per_second: 0, open_fee_bps: 10, close_fee_bps: 600, max_leverage: 10, diff --git a/finance/perpetual-futures/anchor/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index 1e6a02f49..1780e5358 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 2026-09-22 + +Accrue funding by the wall clock instead of by slots. The rate was quoted per +slot, so what a position cost per hour moved with the cluster's slot time, and +the reduction to 200 ms slots doubled it. `Pool::funding_rate_per_slot` is now +`funding_rate_per_second`, and `last_funding_slot` is now +`last_funding_timestamp`, the Clock's `unix_timestamp` at the last accrual; the +same rename applies to `PoolParameters` and `set_funding_rate`'s argument. A +timestamp at or before the stored one accrues nothing. Tested by +`test_funding_follows_seconds_not_slots`, with +`test_set_funding_rate_settles_at_the_old_rate_first` and +`test_funding_charged_to_long` now counting seconds. + ## 2026-09-10 Remove the separate dataless signing PDA (seeds `["authority", pool]`) that diff --git a/finance/perpetual-futures/anchor/README.md b/finance/perpetual-futures/anchor/README.md index 38d34e5e5..6681cff0e 100644 --- a/finance/perpetual-futures/anchor/README.md +++ b/finance/perpetual-futures/anchor/README.md @@ -46,9 +46,9 @@ So a winning trader can always be paid, the pool **reserves** liquidity to back ### Funding -[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. +[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_second` for each second on the Clock's `unix_timestamp`; 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 operator 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. +Funding runs on the wall clock rather than the slot count, so what a position costs per hour is set by the rate alone and does not change when the cluster's slot time does. The timestamp is written by each block's leader, but the runtime bounds how far one block can move it, so the elapsed time behind a position's funding is out by a second or two at most; a timestamp at or before the stored `last_funding_timestamp` accrues nothing. `set_funding_rate(funding_rate_per_second)` lets the pool operator change the rate; it advances the index at the old rate first, so seconds already elapsed are charged at the rate that was in force for them. ### Maintenance margin and liquidation @@ -212,7 +212,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, 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. +The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover liquidity round-trips, opening and closing longs and shorts in profit and loss, leverage and slippage rejection, stale-price, pre-restart-price, and wide-confidence rejection, funding accrual, funding-rate retuning (including that it settles elapsed seconds at the old rate, and that only the authority may call it), funding that follows seconds rather than slots, liquidation (and the refusal to liquidate a healthy position), reserved-liquidity behaviour (profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity), and fee collection. ```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 d7476ae4f..a2cd3a6f1 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs @@ -6,8 +6,8 @@ use anchor_lang::prelude::*; pub const BASIS_POINTS_DENOMINATOR: u64 = 10_000; /// Fixed-point precision for the cumulative funding index. The index is carried -/// as `i128` scaled by this factor so per-slot funding (a tiny ratio) keeps its -/// precision when integrated over many slots. +/// as `i128` scaled by this factor so per-second funding (a tiny ratio) keeps its +/// precision when integrated over many seconds. pub const FUNDING_PRECISION: i128 = 1_000_000_000; /// Fixed-point precision for the aggregate `size / entry_price` accumulators the diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs index c93eeb8d4..6fd9d3edc 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs @@ -19,9 +19,9 @@ pub struct PoolParameters { /// Decimal places the oracle quotes its price in (e.g. 8). pub oracle_scale: u32, - /// Funding accrued per slot, in `FUNDING_PRECISION` units, charged to the + /// Funding accrued per second, in `FUNDING_PRECISION` units, charged to the /// heavier side. - pub funding_rate_per_slot: u64, + pub funding_rate_per_second: u64, pub open_fee_bps: u16, pub close_fee_bps: u16, @@ -92,8 +92,8 @@ pub fn handle_initialize_pool( pool.long_size_scaled = 0; pool.short_size_scaled = 0; pool.cumulative_funding = 0; - pool.last_funding_slot = Clock::get()?.slot; - pool.funding_rate_per_slot = parameters.funding_rate_per_slot; + pool.last_funding_timestamp = Clock::get()?.unix_timestamp; + pool.funding_rate_per_second = parameters.funding_rate_per_second; pool.open_fee_bps = parameters.open_fee_bps; pool.close_fee_bps = parameters.close_fee_bps; pool.max_leverage = parameters.max_leverage; 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 index 80a3fc1cf..2f5cbdcaf 100644 --- 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 @@ -4,22 +4,18 @@ 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. +/// Retune the pool's funding rate, quoted per second of wall-clock time. /// -/// Funding is accrued at the old rate first, so the slots already elapsed are +/// Funding is accrued at the old rate first, so the seconds 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: &mut Context, - funding_rate_per_slot: u64, + funding_rate_per_second: u64, ) -> Result<()> { let pool = &mut context.accounts.pool; - accrue_funding(pool, Clock::get()?.slot)?; - pool.funding_rate_per_slot = funding_rate_per_slot; + accrue_funding(pool, Clock::get()?.unix_timestamp)?; + pool.funding_rate_per_second = funding_rate_per_second; Ok(()) } diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs index 2fb9ae7f3..4e47e3235 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs @@ -67,20 +67,27 @@ pub fn settle_position(pool: &mut Pool, position: &Position, price: u64) -> Resu }) } -/// Advance the pool's cumulative funding index to `current_slot`. +/// Advance the pool's cumulative funding index to `current_timestamp`, the +/// Clock's `unix_timestamp`. /// /// The heavier open-interest side pays funding to the pool: while longs are /// larger the index rises (longs owe), while shorts are larger it falls (shorts /// owe). No positions means no one to charge, so the index is left untouched and /// only the timestamp moves forward. -pub fn accrue_funding(pool: &mut Pool, current_slot: u64) -> Result<()> { - let elapsed = current_slot.saturating_sub(pool.last_funding_slot); - if elapsed == 0 { +/// +/// The timestamp is written by each block's leader. A timestamp at or before +/// the stored one is treated as no time elapsed and leaves the stored stamp +/// where it is, so no second is charged twice or skipped. +pub fn accrue_funding(pool: &mut Pool, current_timestamp: i64) -> Result<()> { + if current_timestamp <= pool.last_funding_timestamp { return Ok(()); } + let elapsed = current_timestamp + .checked_sub(pool.last_funding_timestamp) + .ok_or(PerpError::MathOverflow)?; if pool.long_size != 0 || pool.short_size != 0 { - let magnitude = (pool.funding_rate_per_slot as i128) + let magnitude = (pool.funding_rate_per_second as i128) .checked_mul(elapsed as i128) .ok_or(PerpError::MathOverflow)?; let delta = if pool.long_size >= pool.short_size { @@ -94,7 +101,7 @@ pub fn accrue_funding(pool: &mut Pool, current_slot: u64) -> Result<()> { .ok_or(PerpError::MathOverflow)?; } - pool.last_funding_slot = current_slot; + pool.last_funding_timestamp = current_timestamp; Ok(()) } @@ -210,7 +217,7 @@ pub fn basis_points_of(amount: u64, basis_points: u16) -> Result { } /// The preamble every price-sensitive handler runs: read a validated oracle -/// price, then bring the pool's funding index up to the current slot, so the +/// price, then bring the pool's funding index up to the current time, so the /// settlement that follows uses fresh numbers for both. Centralized so no /// handler can settle a position against a stale funding index. pub fn refresh_price_and_funding(pool: &mut Pool, oracle_feed: &AccountView) -> Result { @@ -219,6 +226,6 @@ pub fn refresh_price_and_funding(pool: &mut Pool, oracle_feed: &AccountView) -> pool.oracle_scale, pool.max_confidence_bps, )?; - accrue_funding(pool, Clock::get()?.slot)?; + accrue_funding(pool, Clock::get()?.unix_timestamp)?; Ok(price) } 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 58758eb55..c06613b60 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/lib.rs @@ -83,12 +83,12 @@ pub mod perpetual_futures { instructions::handle_collect_fees(context) } - /// The pool operator retunes the per-slot funding rate, accruing at the + /// The pool operator retunes the per-second funding rate, accruing at the /// old rate first. pub fn set_funding_rate( context: &mut Context, - funding_rate_per_slot: u64, + funding_rate_per_second: u64, ) -> Result<()> { - instructions::handle_set_funding_rate(context, funding_rate_per_slot) + instructions::handle_set_funding_rate(context, funding_rate_per_second) } } diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs index 8278dfbac..e1e12afdd 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs @@ -66,11 +66,14 @@ pub struct Pool { /// open and close. pub cumulative_funding: i128, - pub last_funding_slot: u64, + /// The Clock's `unix_timestamp` when funding last accrued. Funding runs on + /// the wall clock, so what a position costs per hour does not depend on + /// the cluster's slot time. + pub last_funding_timestamp: i64, - /// Funding accrued per slot, in `FUNDING_PRECISION` units, applied to the + /// Funding accrued per second, in `FUNDING_PRECISION` units, applied to the /// heavier side. The funding paid by traders accrues to the pool. - pub funding_rate_per_slot: u64, + pub funding_rate_per_second: u64, /// Fee charged on notional when opening a position, in basis points. pub open_fee_bps: u16, 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 0e2944fac..4f90ffbdc 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 @@ -58,13 +58,13 @@ struct Market { } impl Market { - /// Stand up a market with the given starting oracle price and per-slot + /// Stand up a market with the given starting oracle price and per-second /// funding rate. The admin is both the pool operator and the oracle feed /// authority. - fn new(initial_price: i128, funding_rate_per_slot: u64) -> Market { + fn new(initial_price: i128, funding_rate_per_second: u64) -> Market { let parameters = PoolParameters { oracle_scale: ORACLE_SCALE, - funding_rate_per_slot, + funding_rate_per_second, open_fee_bps: 10, close_fee_bps: 10, max_leverage: 10, @@ -207,11 +207,26 @@ impl Market { .unwrap(); } + /// Move to `slot`, leaving the Clock's timestamp where it is. Price + /// freshness is counted in slots, so this ages prices; funding is counted + /// in seconds, so on its own this accrues none. fn warp(&mut self, slot: u64) { self.svm.warp_to_slot(slot); self.svm.expire_blockhash(); } + /// Let `seconds` of wall-clock time pass: the timestamp moves by `seconds` + /// and the slot by five a second, the network's 200 ms target. Funding + /// accrues for exactly `seconds`, however many slots that turns out to be. + fn pass_seconds(&mut self, seconds: i64) { + let target = self.current_slot() + seconds as u64 * 5; + self.svm.warp_to_slot(target); + let mut clock = self.svm.get_sysvar::(); + clock.unix_timestamp += seconds; + self.svm.set_sysvar(&clock); + self.svm.expire_blockhash(); + } + fn current_slot(&self) -> u64 { self.svm.get_sysvar::().slot } @@ -486,7 +501,7 @@ impl Market { let instruction = Instruction::new_with_bytes( perpetual_futures::id(), &perpetual_futures::instruction::SetFundingRate { - funding_rate_per_slot: rate, + funding_rate_per_second: rate, } .data(), perpetual_futures::accounts::SetFundingRateAccountConstraints { @@ -891,7 +906,7 @@ fn test_funding_charged_to_long() { // Let funding accrue, then refresh the feed so the price is fresh again and // close at the same price (no profit/loss). - market.warp(2_000); + market.pass_seconds(2_000); market.set_price(dollars(100)); market .close_position(&trader, trader_collateral, Side::Long, 0) @@ -913,56 +928,55 @@ 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 operator retunes the rate, and the retune must settle the slots already -/// elapsed at the old rate rather than repricing them at the new one. +/// Funding held open for a window, closed at an unchanged price: returns the +/// funding the trader paid. `between` runs halfway through. +fn funding_paid_over(rate: u64, window: i64, between: impl Fn(&mut Market)) -> 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(); + + market.pass_seconds(window); + between(&mut market); + market.pass_seconds(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 +} + +/// Retuning the rate settles the seconds 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 + // Same position and the same total elapsed seconds 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); + let flat = funding_paid_over(rate, window, |_| {}); + let retuned = funding_paid_over(rate, window, |market| { + let admin = market.admin.insecure_clone(); + market.set_funding_rate(&admin, rate * 2).unwrap(); + }); 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. + // Half the elapsed seconds 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 + // second and this would be 2x. assert_eq!( retuned * 2, flat * 3, @@ -970,6 +984,22 @@ fn test_set_funding_rate_settles_at_the_old_rate_first() { ); } +/// Funding is quoted per second of wall-clock time, so slots passing without +/// the clock moving charge nothing. A million extra slots halfway through the +/// window, as a much shorter slot would produce, leave the funding unchanged. +#[test] +fn test_funding_follows_seconds_not_slots() { + let rate = 5_000; + let window = 2_000; + let flat = funding_paid_over(rate, window, |_| {}); + let with_extra_slots = funding_paid_over(rate, window, |market| { + let slot = market.current_slot(); + market.warp(slot + 1_000_000); + }); + assert!(flat > 0); + assert_eq!(with_extra_slots, flat); +} + #[test] fn test_only_authority_can_set_funding_rate() { let mut market = Market::new(dollars(100), 5_000); @@ -1194,7 +1224,7 @@ fn test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin() { // close, so initialize_pool refuses the configuration. let parameters = PoolParameters { oracle_scale: ORACLE_SCALE, - funding_rate_per_slot: 0, + funding_rate_per_second: 0, open_fee_bps: 10, close_fee_bps: 600, max_leverage: 10, From 5f0cfd6516c4812364bf6956196f140bd0aa8d74 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Tue, 22 Sep 2026 23:35:01 +0000 Subject: [PATCH 2/3] Accrue lending interest and perpetual futures funding by the wall clock (Quasar) The Quasar variants follow the Anchor ones. Lending: Reserve loses slots_per_year and gains last_accrual_timestamp; borrow_rate_per_slot is now borrow_rate_per_second; accrue() takes the slot and the timestamp; initialize_reserve loses its slots_per_year argument; and update_slots_per_year (discriminator 12, the last) is removed, so no other discriminator moves. Perpetual futures: last_funding_slot is now last_funding_timestamp, funding_rate_per_slot is now funding_rate_per_second, and advance_funding is replaced by accrue_funding(pool, current_timestamp). New tests: interest_accrues_by_seconds_not_slots, a_timestamp_behind_the_last_accrual_charges_nothing and funding_follows_seconds_not_slots. Price freshness stays in slots. Also add the root CHANGELOG entry covering all three variants. Claude-Session: https://claude.ai/code/session_01CXqcBq93zfcZ2F8jeoSaez --- CHANGELOG.md | 16 ++ finance/lending/quasar/CHANGELOG.md | 26 ++ finance/lending/quasar/Cargo.toml | 15 +- finance/lending/quasar/README.md | 28 ++- finance/lending/quasar/src/constants.rs | 6 + .../lending/quasar/src/instructions/admin.rs | 48 +--- .../quasar/src/instructions/position.rs | 22 +- .../lending/quasar/src/instructions/supply.rs | 8 +- finance/lending/quasar/src/lib.rs | 10 - finance/lending/quasar/src/logic.rs | 45 +++- finance/lending/quasar/src/math.rs | 37 ++- finance/lending/quasar/src/state.rs | 14 +- finance/lending/quasar/src/tests.rs | 229 +++++++++++++----- finance/perpetual-futures/quasar/CHANGELOG.md | 13 + finance/perpetual-futures/quasar/README.md | 4 +- .../quasar/src/instructions/add_liquidity.rs | 8 +- .../quasar/src/instructions/close_position.rs | 8 +- .../src/instructions/initialize_pool.rs | 8 +- .../src/instructions/liquidate_position.rs | 8 +- .../quasar/src/instructions/open_position.rs | 8 +- .../src/instructions/remove_liquidity.rs | 8 +- .../src/instructions/set_funding_rate.rs | 30 +-- .../quasar/src/instructions/shared.rs | 80 +++--- finance/perpetual-futures/quasar/src/lib.rs | 8 +- finance/perpetual-futures/quasar/src/state.rs | 9 +- finance/perpetual-futures/quasar/src/tests.rs | 95 ++++++-- 26 files changed, 510 insertions(+), 281 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ed894106..64c86cff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this repository are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2026-09-22] - Lending interest and perpetual futures funding accrue by the wall clock + +Both programs accrued over elapsed slots, so a rate quoted per year or per +hour depended on a guess at the slot length. The lending reserve's +`slots_per_year` default assumed 400 ms slots and charged twice the advertised +APR once the network moved to 200 ms. Interest and funding now accrue for the +seconds between the Clock's `unix_timestamp` and a stored +`last_accrual_timestamp` (lending) or `last_funding_timestamp` (perpetual +futures). `slots_per_year` is removed from `ReserveConfig`, and +`funding_rate_per_slot` is now `funding_rate_per_second`. A timestamp at or +before the stored one accrues nothing. In the Anchor variants, +`update_reserve_config` now accrues at the old curve before storing a new one; +the Quasar lending variant drops its `update_slots_per_year` instruction. +Price freshness is still counted in slots. All three variants (Anchor v2, +Anchor v1, Quasar) of both programs change. + ## [2026-09-22] - Vault strategy rejects prices from before a cluster restart The vault strategy checks Pyth freshness in seconds. Under Alpenglow each diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index 40ed93e3f..fa17e700b 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [2026-09-22] + +### Changed + +- Accrue interest by the wall clock instead of by slots. The reserve's + `slots_per_year` field was a guess at the cluster's slot length, and the + test default of 78,840,000 (a 400 ms slot) charged twice the advertised APR + once the network moved to 200 ms slots. Interest now accrues for the seconds + between the Clock's `unix_timestamp` and the new + `Reserve::last_accrual_timestamp`, at the APR divided by `SECONDS_PER_YEAR`; + `slots_per_year` is gone from `Reserve` and from `initialize_reserve`'s + arguments, and `borrow_rate_per_slot` is now `borrow_rate_per_second`. A + timestamp at or before the stored one accrues nothing. `last_update_slot` + stays, as the record of the last accrual's slot. Tested by + `interest_accrues_by_seconds_not_slots` and + `a_timestamp_behind_the_last_accrual_charges_nothing`, which replace + `retuning_slots_per_year_rescales_accrual`. The low-level test module that + moves the slot and the timestamp independently is now `clock_warp` (was + `slot_warp`). + +### Removed + +- `update_slots_per_year` (discriminator 12). The field it retuned is gone. + Discriminators are declared explicitly, so every other instruction keeps its + number. + ## [2026-08-14] ### Added diff --git a/finance/lending/quasar/Cargo.toml b/finance/lending/quasar/Cargo.toml index 9fea3faf4..afa4f9caf 100644 --- a/finance/lending/quasar/Cargo.toml +++ b/finance/lending/quasar/Cargo.toml @@ -46,13 +46,14 @@ zeropod = "=0.3.3" [dev-dependencies] quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } -# Direct quasar-svm dev-dependency, unique to this example: the program -# computes interest from `Clock::get()?.slot`, and two test scenarios must -# warp the SLOT. quasar-test only exposes `warp_to_timestamp` (which leaves -# the slot untouched), so those scenarios drive the low-level QuasarSvm -# harness directly via `svm.sysvars.warp_to_slot(...)`. Same crates.io -# version quasar-test itself depends on, so the two harnesses share types. +# Direct quasar-svm dev-dependency, unique to this example: interest accrues +# on the Clock's `unix_timestamp` while price freshness is counted in slots, +# and several test scenarios must move the two independently. quasar-test only +# exposes `warp_to_timestamp` (which leaves the slot untouched), so those +# scenarios drive the low-level QuasarSvm harness directly through +# `svm.sysvars`. Same crates.io version quasar-test itself depends on, so the +# two harnesses share types. quasar-svm = "=0.1.0" -# For decoding/constructing raw SPL accounts in the low-level slot-warp +# For decoding/constructing raw SPL accounts in the low-level clock-warp # scenarios; matches quasar-svm 0.1.0's own spl-token major. spl-token = { version = "9", default-features = false, features = ["no-entrypoint"] } diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index ae517dc98..65b29de9a 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -69,16 +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. +- **Interest on the wall clock**: a reserve's rate curve is annual, and the + conversion to a per-second rate divides by `SECONDS_PER_YEAR`. Elapsed time is + the Clock's `unix_timestamp` minus the reserve's `last_accrual_timestamp`, so a + borrower pays the advertised APR over a real year whatever the cluster's slot + time is. Counting slots instead would need a slots-per-year divisor, which is a + guess at the slot length: the network changes that length by feature gate, and + does not deliver it exactly between changes. The timestamp is written by each + block's leader, but the runtime bounds how far one block can move it, so an + elapsed time is out by a second or two at most, which is nothing against an + annual rate. A timestamp at or before the stored one accrues nothing. ### Instruction handlers (numeric discriminators) @@ -87,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), `update_slots_per_year` (12). +`collect_protocol_fees` (11). ## Setup @@ -105,5 +105,7 @@ cargo test tests:: # runs the quasar-svm integration tests `cargo build-sbf` must run first: the tests load the compiled `target/deploy/quasar_lending.so` into `quasar-svm`. The suite drives the full lifecycle: supply/redeem (1:1 first deposit), borrow up to the LTV limit (and -rejection beyond it), repay, interest accrual lifting the share value after slots -pass, and liquidation of an unhealthy position (with a healthy position rejected). +rejection beyond it), repay, interest accrual lifting the share value after time +passes, interest that follows seconds rather than slots (and charges nothing for a +timestamp behind the last accrual), and liquidation of an unhealthy position (with +a healthy position rejected). diff --git a/finance/lending/quasar/src/constants.rs b/finance/lending/quasar/src/constants.rs index bb5d18e25..2d109352d 100644 --- a/finance/lending/quasar/src/constants.rs +++ b/finance/lending/quasar/src/constants.rs @@ -14,6 +14,12 @@ pub const FIXED_POINT_SCALE_DECIMALS: i32 = 18; /// 100% expressed in basis points. pub const BPS_DENOMINATOR: u128 = 10_000; +/// Seconds in a 365-day year: the divisor that turns an annual rate into the +/// per-second rate interest accrues at. Interest runs on the wall clock, not +/// the slot count, because a rate quoted per year is a promise about wall-clock +/// time and a slots-per-year divisor is only a guess at the slot length. +pub const SECONDS_PER_YEAR: u128 = 31_536_000; + /// 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 diff --git a/finance/lending/quasar/src/instructions/admin.rs b/finance/lending/quasar/src/instructions/admin.rs index c93d0d281..fd973b369 100644 --- a/finance/lending/quasar/src/instructions/admin.rs +++ b/finance/lending/quasar/src/instructions/admin.rs @@ -87,7 +87,6 @@ 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( @@ -100,7 +99,6 @@ impl InitializeReserve { min_borrow_rate_bps, optimal_borrow_rate_bps, max_borrow_rate_bps, - slots_per_year, )?; let reserve_address = *self.reserve.address(); @@ -152,6 +150,7 @@ impl InitializeReserve { .initialize_mint2(&self.share_mint, decimals, &reserve_address, None) .invoke()?; + let (slot, timestamp) = now()?; self.reserve.set_inner(ReserveInner { lending_market: *self.lending_market.address(), liquidity_mint: *self.liquidity_mint.address(), @@ -163,8 +162,8 @@ impl InitializeReserve { accumulated_protocol_fees: 0, borrowed_principal: 0, borrow_accumulation_factor: crate::constants::FIXED_POINT_SCALE, - last_update_slot: now()?, - slots_per_year, + last_update_slot: slot, + last_accrual_timestamp: timestamp, liquidity_decimals: decimals, loan_to_value_bps, liquidation_threshold_bps, @@ -181,39 +180,6 @@ 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) // --------------------------------------------------------------------------- @@ -240,12 +206,14 @@ impl SetPrice { exponent: i32, bumps: &SetPriceBumps, ) -> Result<(), ProgramError> { + // Prices are stamped with the slot: freshness is counted in slots. + let (slot, _) = now()?; self.price_feed.set_inner(PriceFeedInner { market: *self.lending_market.address(), mint: *self.mint.address(), price_mantissa, exponent, - last_updated_slot: now()?, + last_updated_slot: slot, bump: bumps.price_feed, }); Ok(()) @@ -284,9 +252,9 @@ impl CollectProtocolFees { /// the liquidity currently sitting in the vault. #[inline(always)] pub fn run(&mut self) -> Result<(), ProgramError> { - let slot = now()?; + let (slot, timestamp) = now()?; let mut reserve = snapshot_reserve(&self.reserve); - accrue(&mut reserve, slot)?; + accrue(&mut reserve, slot, timestamp)?; let amount = reserve .accumulated_protocol_fees diff --git a/finance/lending/quasar/src/instructions/position.rs b/finance/lending/quasar/src/instructions/position.rs index 5ebcf928c..fae3cfc73 100644 --- a/finance/lending/quasar/src/instructions/position.rs +++ b/finance/lending/quasar/src/instructions/position.rs @@ -157,7 +157,7 @@ impl BorrowObligationLiquidity { #[inline(always)] pub fn run(&mut self, amount: u64) -> Result<(), ProgramError> { require!(amount > 0, LendingError::ZeroAmount); - let slot = now()?; + let (slot, timestamp) = now()?; require_keys_eq!( self.obligation.collateral_reserve, @@ -176,9 +176,9 @@ impl BorrowObligationLiquidity { ); let mut collateral = snapshot_reserve(&self.collateral_reserve); - accrue(&mut collateral, slot)?; + accrue(&mut collateral, slot, timestamp)?; let mut borrow = snapshot_reserve(&self.borrow_reserve); - accrue(&mut borrow, slot)?; + accrue(&mut borrow, slot, timestamp)?; let mut obligation = snapshot_obligation(&self.obligation); if obligation.borrow_reserve != Address::default() { require_keys_eq!( @@ -300,7 +300,7 @@ impl RepayObligationLiquidity { #[inline(always)] pub fn run(&mut self, amount: u64) -> Result<(), ProgramError> { require!(amount > 0, LendingError::ZeroAmount); - let slot = now()?; + let (slot, timestamp) = now()?; require_keys_eq!( self.obligation.borrow_reserve, @@ -309,7 +309,7 @@ impl RepayObligationLiquidity { ); let mut borrow = snapshot_reserve(&self.borrow_reserve); - accrue(&mut borrow, slot)?; + accrue(&mut borrow, slot, timestamp)?; let mut obligation = snapshot_obligation(&self.obligation); let debt = current_debt( @@ -382,7 +382,7 @@ impl WithdrawObligationCollateral { #[inline(always)] pub fn run(&mut self, shares: u64) -> Result<(), ProgramError> { require!(shares > 0, LendingError::ZeroAmount); - let slot = now()?; + let (slot, timestamp) = now()?; require_keys_eq!( self.obligation.collateral_reserve, @@ -396,7 +396,7 @@ impl WithdrawObligationCollateral { ); let mut collateral = snapshot_reserve(&self.collateral_reserve); - accrue(&mut collateral, slot)?; + accrue(&mut collateral, slot, timestamp)?; let mut obligation = snapshot_obligation(&self.obligation); require!( obligation.deposited_shares >= shares, @@ -441,7 +441,7 @@ impl WithdrawObligationCollateral { LendingError::WrongReserve ); let mut borrow = snapshot_reserve(&self.borrow_reserve); - accrue(&mut borrow, slot)?; + accrue(&mut borrow, slot, timestamp)?; let debt = current_debt( obligation.borrowed_principal, borrow.borrow_accumulation_factor, @@ -519,7 +519,7 @@ impl LiquidateObligation { #[inline(always)] pub fn run(&mut self, amount: u64) -> Result<(), ProgramError> { require!(amount > 0, LendingError::ZeroAmount); - let slot = now()?; + let (slot, timestamp) = now()?; require_keys_eq!( self.obligation.collateral_reserve, @@ -543,9 +543,9 @@ impl LiquidateObligation { ); let mut collateral = snapshot_reserve(&self.collateral_reserve); - accrue(&mut collateral, slot)?; + accrue(&mut collateral, slot, timestamp)?; let mut borrow = snapshot_reserve(&self.borrow_reserve); - accrue(&mut borrow, slot)?; + accrue(&mut borrow, slot, timestamp)?; let mut obligation = snapshot_obligation(&self.obligation); let collateral_price = price_scaled(&self.collateral_price, slot)?; diff --git a/finance/lending/quasar/src/instructions/supply.rs b/finance/lending/quasar/src/instructions/supply.rs index c7bae1897..121eb9a35 100644 --- a/finance/lending/quasar/src/instructions/supply.rs +++ b/finance/lending/quasar/src/instructions/supply.rs @@ -53,10 +53,10 @@ impl DepositReserveLiquidity { #[inline(always)] pub fn run(&mut self, amount: u64) -> Result<(), ProgramError> { require!(amount > 0, LendingError::ZeroAmount); - let slot = now()?; + let (slot, timestamp) = now()?; let mut reserve = snapshot_reserve(&self.reserve); - accrue(&mut reserve, slot)?; + accrue(&mut reserve, slot, timestamp)?; let total = net_total_liquidity( reserve.available_liquidity, @@ -141,10 +141,10 @@ impl RedeemReserveCollateral { #[inline(always)] pub fn run(&mut self, shares: u64) -> Result<(), ProgramError> { require!(shares > 0, LendingError::ZeroAmount); - let slot = now()?; + let (slot, timestamp) = now()?; let mut reserve = snapshot_reserve(&self.reserve); - accrue(&mut reserve, slot)?; + accrue(&mut reserve, slot, timestamp)?; require!( reserve.share_mint_supply > 0, LendingError::InsufficientLiquidity diff --git a/finance/lending/quasar/src/lib.rs b/finance/lending/quasar/src/lib.rs index eaf93cd6e..2c95c64ca 100644 --- a/finance/lending/quasar/src/lib.rs +++ b/finance/lending/quasar/src/lib.rs @@ -55,7 +55,6 @@ 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, @@ -67,7 +66,6 @@ mod quasar_lending { min_borrow_rate_bps, optimal_borrow_rate_bps, max_borrow_rate_bps, - slots_per_year, &ctx.bumps, ) } @@ -146,12 +144,4 @@ 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 48622c4eb..751eb77b7 100644 --- a/finance/lending/quasar/src/logic.rs +++ b/finance/lending/quasar/src/logic.rs @@ -15,9 +15,11 @@ use crate::{ use crate::constants::BPS_DENOMINATOR; -/// Current slot as a native `u64`. -pub fn now() -> Result { - Ok(u64::from(Clock::get()?.slot)) +/// The Clock's slot and `unix_timestamp`, as native types. The slot measures +/// price freshness; the timestamp measures interest. +pub fn now() -> Result<(u64, i64), ProgramError> { + let clock = Clock::get()?; + Ok((u64::from(clock.slot), i64::from(clock.unix_timestamp))) } /// Read a reserve into a native-typed, mutable snapshot. @@ -34,7 +36,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), + last_accrual_timestamp: i64::from(reserve.last_accrual_timestamp), 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), @@ -62,9 +64,29 @@ pub fn snapshot_obligation(obligation: &Account) -> ObligationInner } } -/// Advance a reserve snapshot's accumulation factor to `slot` (a single -/// `factor *= 1 + rate_per_slot * elapsed` per call, compounding across calls). -pub fn accrue(reserve: &mut ReserveInner, slot: u64) -> Result<(), ProgramError> { +/// Advance a reserve snapshot's accumulation factor for the seconds elapsed +/// since the last accrual, and record `current_slot` as the slot of this one. +/// `new_factor = factor * (1 + rate_per_second * elapsed_seconds)`, a single +/// multiply per call that compounds across calls (Solend's approach, on the +/// wall clock rather than the slot count). +/// +/// The timestamp is written by each block's leader. The runtime rejects a +/// block whose time goes backwards, but a timestamp at or before the stored +/// one is still treated as no time elapsed, and the stored stamp is left +/// where it is, so no second is ever charged twice or skipped. +pub fn accrue( + reserve: &mut ReserveInner, + current_slot: u64, + current_timestamp: i64, +) -> Result<(), ProgramError> { + let elapsed = if current_timestamp > reserve.last_accrual_timestamp { + current_timestamp + .checked_sub(reserve.last_accrual_timestamp) + .ok_or(LendingError::MathOverflow)? as u128 + } else { + 0 + }; + let borrowed_before = current_debt( reserve.borrowed_principal, reserve.borrow_accumulation_factor, @@ -73,13 +95,11 @@ pub fn accrue(reserve: &mut ReserveInner, slot: u64) -> Result<(), ProgramError> reserve.borrow_accumulation_factor, reserve.borrowed_principal, reserve.available_liquidity, - reserve.last_update_slot, - slot, + elapsed, reserve.optimal_utilization_bps, 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. @@ -97,7 +117,10 @@ pub fn accrue(reserve: &mut ReserveInner, slot: u64) -> Result<(), ProgramError> .accumulated_protocol_fees .checked_add(u64::try_from(fee).map_err(|_| LendingError::MathOverflow)?) .ok_or(LendingError::MathOverflow)?; - reserve.last_update_slot = slot; + if elapsed > 0 { + reserve.last_accrual_timestamp = current_timestamp; + } + reserve.last_update_slot = current_slot; Ok(()) } diff --git a/finance/lending/quasar/src/math.rs b/finance/lending/quasar/src/math.rs index 17f9c58ea..f14c150d2 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}, + constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, FIXED_POINT_SCALE_DECIMALS, SECONDS_PER_YEAR}, error::LendingError, }; @@ -149,15 +149,13 @@ pub fn utilization_bps( ) } -/// Per-slot borrow rate (FIXED_POINT_SCALE-scaled) from the kinked curve. -#[allow(clippy::too_many_arguments)] -pub fn borrow_rate_per_slot( +/// Per-second borrow rate (FIXED_POINT_SCALE-scaled) from the kinked curve. +pub fn borrow_rate_per_second( utilization: u128, optimal_utilization_bps: u16, 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 { @@ -185,45 +183,41 @@ pub fn borrow_rate_per_slot( .checked_add(mul_div_floor(range, above, span.max(1))?) .ok_or(LendingError::MathOverflow)? }; - let denominator = BPS_DENOMINATOR - .checked_mul(slots_per_year as u128) + // apr_bps / (BPS_DENOMINATOR * SECONDS_PER_YEAR), carried at FIXED_POINT_SCALE. + let per_year_denominator = BPS_DENOMINATOR + .checked_mul(SECONDS_PER_YEAR) .ok_or(LendingError::MathOverflow)?; - mul_div_floor(apr_bps, FIXED_POINT_SCALE, denominator) + mul_div_floor(apr_bps, FIXED_POINT_SCALE, per_year_denominator) } -/// Advance the accumulation factor for elapsed slots: -/// `new_factor = factor * (1 + rate_per_slot * elapsed)`. +/// Advance the accumulation factor for `elapsed_seconds`: +/// `new_factor = factor * (1 + rate_per_second * elapsed_seconds)`. #[allow(clippy::too_many_arguments)] pub fn accrue_factor( factor: u128, borrowed_principal: u128, available: u64, - last_update_slot: u64, - now: u64, + elapsed_seconds: u128, optimal_utilization_bps: u16, 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) - .ok_or(LendingError::MathOverflow)?; - if elapsed == 0 || borrowed_principal == 0 { + if elapsed_seconds == 0 || borrowed_principal == 0 { return Ok(factor); } let utilization = utilization_bps(available, borrowed_principal, factor)?; - let rate = borrow_rate_per_slot( + let rate_per_second = borrow_rate_per_second( utilization, optimal_utilization_bps, 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) + rate_per_second + .checked_mul(elapsed_seconds) .ok_or(LendingError::MathOverflow)?, ) .ok_or(LendingError::MathOverflow)?; @@ -241,7 +235,6 @@ 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!( @@ -267,7 +260,5 @@ 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 6fc8bb249..8296271fa 100644 --- a/finance/lending/quasar/src/state.rs +++ b/finance/lending/quasar/src/state.rs @@ -36,14 +36,14 @@ pub struct Reserve { pub accumulated_protocol_fees: u64, pub borrowed_principal: u128, pub borrow_accumulation_factor: u128, + /// The slot of the last accrual. Every handler that reads the reserve's + /// value accrues it inline first, so this records when that last ran. It + /// is not the accrual clock. 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, + /// The Clock's `unix_timestamp` at the last accrual. Interest accrues for + /// the seconds since, so it follows wall-clock time whatever the slot + /// length is. + pub last_accrual_timestamp: i64, 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 4831fae46..0c1b729c1 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -1,7 +1,8 @@ //! Integration tests. Most scenarios drive the program through `quasar-test` -//! (`#[quasar_test]` fixtures + `crate::cpi` builders). The two scenarios that -//! must warp the SLOT (interest accrual is computed from `Clock::get()?.slot`) -//! keep the low-level QuasarSvm harness — see `slot_warp` at the bottom. +//! (`#[quasar_test]` fixtures + `crate::cpi` builders). The scenarios that must +//! move the slot and the Clock's timestamp independently (interest accrues on +//! the timestamp, price freshness is counted in slots) keep the low-level +//! QuasarSvm harness — see `clock_warp` at the bottom. use { crate::{ @@ -29,11 +30,8 @@ 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; +/// A tenth of a 365-day year, in seconds: long enough for interest to show. +const TENTH_OF_A_YEAR: i64 = crate::constants::SECONDS_PER_YEAR as i64 / 10; // Deterministic addresses. const OWNER: Pubkey = Pubkey::new_from_array([1; 32]); @@ -158,7 +156,6 @@ 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(); } @@ -354,23 +351,33 @@ fn unhealthy_position_is_liquidated_and_healthy_is_rejected(test: &mut Test) { ); } -/// The two scenarios below warp the SLOT so that interest accrues -/// (`Clock::get()?.slot` drives the accumulation factor). quasar-test has no slot -/// warp — `warp_to_timestamp` only moves `unix_timestamp` — so these keep the -/// low-level quasar-svm harness (`QuasarSvm` + `sysvars.warp_to_slot` + raw -/// instructions), loading the compiled program at runtime. -mod slot_warp { +/// The scenarios below move the slot and the Clock's timestamp independently: +/// interest accrues on `unix_timestamp`, while price freshness and the restart +/// check are counted in slots. quasar-test has no slot warp (`warp_to_timestamp` +/// only moves `unix_timestamp`), so these keep the low-level quasar-svm harness +/// (`QuasarSvm` + `sysvars` + raw instructions), loading the compiled program at +/// runtime. +mod clock_warp { use { - super::{dollars, EXP}, + super::{dollars, EXP, TENTH_OF_A_YEAR}, super::{ BORROWER, BORROWER_BORROW, BORROWER_COLLATERAL, BORROWER_COLLATERAL_SHARE, BORROW_MINT, COLLATERAL_MINT, DECIMALS, MARKET_ID, OWNER, OWNER_BORROW, QUOTE_MINT, SUPPLIER, SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, UNIT, }, + crate::{ + constants::FIXED_POINT_SCALE, + math::{borrow_rate_per_second, utilization_bps}, + state::Reserve, + }, + quasar_lang::traits::Discriminator, quasar_svm::{Account, AccountMeta, Instruction, Pubkey, QuasarSvm}, spl_token::state::{Account as SplToken, AccountState, Mint as SplMint}, }; + /// The reserve's zero-copy fields, as the program reads them. + type ReserveState = ::Target; + fn pda(seeds: &[&[u8]]) -> (Pubkey, u8) { Pubkey::find_program_address(seeds, &crate::ID) } @@ -521,6 +528,51 @@ mod slot_warp { } } + fn current_timestamp(&self) -> i64 { + self.svm.sysvars.clock.unix_timestamp + } + + /// Advance the slot only, leaving the Clock's timestamp where it is. + /// Price freshness is counted in slots, so this ages prices; interest + /// is counted in seconds, so on its own this accrues none. + /// quasar-svm's `warp_to_slot` resets the timestamp to zero, so this + /// puts it back. + fn warp_slots(&mut self, slots: u64) { + let timestamp = self.current_timestamp(); + let target = self.svm.sysvars.clock.slot + slots; + self.svm.sysvars.warp_to_slot(target); + self.svm.sysvars.clock.unix_timestamp = timestamp; + } + + /// Move the Clock's timestamp by `seconds` (backwards when negative), + /// leaving the slot where it is. Interest accrues on this clock. + fn shift_timestamp(&mut self, seconds: i64) { + self.svm.sysvars.clock.unix_timestamp += seconds; + } + + /// Let `seconds` of wall-clock time pass: the timestamp moves by + /// `seconds` and the slot by five a second, the network's 200 ms + /// target, so prices age as they would. Interest accrues for exactly + /// `seconds`, however many slots that turns out to be. + fn warp_seconds(&mut self, seconds: i64) { + self.warp_slots(seconds as u64 * 5); + self.shift_timestamp(seconds); + } + + /// Read a reserve's fields from its committed bytes: the discriminator, + /// then the zero-copy layout, which is how `quasar-test`'s `Test::read` + /// decodes an account. + fn reserve(&self, address: Pubkey) -> ReserveState { + let account = self.svm.get_account(&address).expect("reserve present"); + let discriminator = ::DISCRIMINATOR; + assert_eq!(&account.data[..discriminator.len()], discriminator); + let fields = &account.data[discriminator.len()..]; + assert!(fields.len() >= core::mem::size_of::()); + // SAFETY: the zero-copy layout has alignment one and no padding, and + // the length check above keeps the read in bounds. + unsafe { core::ptr::read_unaligned(fields.as_ptr() as *const ReserveState) } + } + fn run(&mut self, data: Vec, metas: Vec) -> quasar_svm::ExecutionResult { let instruction = Instruction { program_id: crate::ID, @@ -572,7 +624,6 @@ 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), @@ -731,20 +782,18 @@ mod slot_warp { self.post_collateral(1_000 * UNIT).assert_success(); } + /// Accrue the borrow reserve. This port has no `refresh_reserve`: every + /// handler that reads a reserve accrues it first. Redeeming one share + /// is the smallest such call that needs no price, so it stands in for + /// the refresh here. + fn refresh_borrow_reserve(&mut self) { + self.redeem(SUPPLIER_BORROW, SUPPLIER_BORROW_SHARE, 1) + .assert_success(); + } + /// 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), @@ -765,8 +814,8 @@ mod slot_warp { world.bootstrap_position(); world.borrow(500 * UNIT).assert_success(); - // ~0.1 year passes; re-publish prices so feeds stay fresh. - world.svm.sysvars.warp_to_slot(7_884_000); + // A tenth of a year passes; re-publish prices so feeds stay fresh. + world.warp_seconds(TENTH_OF_A_YEAR); world.set_price(COLLATERAL_MINT, world.collateral_price, dollars(1)); world.set_price(BORROW_MINT, world.borrow_price, dollars(1)); @@ -808,41 +857,99 @@ 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. + /// The factor after one accrual `seconds` after the last: one multiply by + /// `1 + rate_per_second * seconds`, floored, exactly as the program does it. + fn factor_after(reserve: &ReserveState, seconds: u128) -> u128 { + let factor = u128::from(reserve.borrow_accumulation_factor); + let utilization = utilization_bps( + u64::from(reserve.available_liquidity), + u128::from(reserve.borrowed_principal), + factor, + ) + .unwrap(); + let rate = borrow_rate_per_second( + utilization, + u16::from(reserve.optimal_utilization_bps), + u16::from(reserve.min_borrow_rate_bps), + u16::from(reserve.optimal_borrow_rate_bps), + u16::from(reserve.max_borrow_rate_bps), + ) + .unwrap(); + factor * (FIXED_POINT_SCALE + rate * seconds) / FIXED_POINT_SCALE + } + + /// The rate fields are annual, and a year is a length of wall-clock time, so + /// interest accrues for the seconds on the Clock's timestamp and ignores the + /// slot count. Slots passing on their own accrue nothing; seconds passing on + /// their own accrue exactly the per-second rate times the seconds. A shorter + /// or longer slot therefore cannot change what a borrower pays. #[test] - fn retuning_slots_per_year_rescales_accrual() { + fn interest_accrues_by_seconds_not_slots() { let mut world = World::new(); world.bootstrap_position(); world.borrow(500 * UNIT).assert_success(); + let before = world.reserve(world.borrow_reserve); + + world.warp_slots(1_000_000); + world.refresh_borrow_reserve(); + let idle = world.reserve(world.borrow_reserve); + assert_eq!( + u128::from(idle.borrow_accumulation_factor), + u128::from(before.borrow_accumulation_factor), + "a million slots with the clock standing still must accrue nothing" + ); - // 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(); + let seconds = TENTH_OF_A_YEAR; + world.shift_timestamp(seconds); + world.refresh_borrow_reserve(); + let after = world.reserve(world.borrow_reserve); + assert!( + u128::from(after.borrow_accumulation_factor) + > u128::from(before.borrow_accumulation_factor) + ); + assert_eq!( + u128::from(after.borrow_accumulation_factor), + factor_after(&idle, seconds as u128) + ); + assert_eq!( + i64::from(after.last_accrual_timestamp), + world.current_timestamp() + ); + } - // 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; + /// The leader writes the timestamp, and a timestamp at or before the stored + /// one accrues nothing and leaves the stored stamp alone. When the clock + /// moves forward again, only the seconds past the stored stamp are charged, + /// so no second is charged twice. + #[test] + fn a_timestamp_behind_the_last_accrual_charges_nothing() { + let mut world = World::new(); + world.bootstrap_position(); + world.borrow(500 * UNIT).assert_success(); + let before = world.reserve(world.borrow_reserve); + + world.shift_timestamp(-600); + world.refresh_borrow_reserve(); + let behind = world.reserve(world.borrow_reserve); + assert_eq!( + u128::from(behind.borrow_accumulation_factor), + u128::from(before.borrow_accumulation_factor) + ); + assert_eq!( + i64::from(behind.last_accrual_timestamp), + i64::from(before.last_accrual_timestamp) + ); - // 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}" + // 1,600 seconds forward from the shifted clock is 1,000 past the stamp. + world.shift_timestamp(1_600); + world.refresh_borrow_reserve(); + assert_eq!( + u128::from( + world + .reserve(world.borrow_reserve) + .borrow_accumulation_factor + ), + factor_after(&behind, 1_000) ); } @@ -852,9 +959,9 @@ mod slot_warp { world.bootstrap_position(); world.borrow(500 * UNIT).assert_success(); - // ~0.1 year passes; interest accrues, and the reserve factor (10%) - // sets some of it aside for the market owner. - world.svm.sysvars.warp_to_slot(7_884_000); + // A tenth of a year passes; interest accrues, and the reserve factor + // (10%) sets some of it aside for the market owner. + world.warp_seconds(TENTH_OF_A_YEAR); let result = world.collect_borrow_fees(); result.assert_success(); diff --git a/finance/perpetual-futures/quasar/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index 1cd9b932c..84f3bb168 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 2026-09-22 + +Accrue funding by the wall clock instead of by slots. The rate was quoted per +slot, so what a position cost per hour moved with the cluster's slot time, and +the reduction to 200 ms slots doubled it. `Pool::funding_rate_per_slot` is now +`funding_rate_per_second`, and `last_funding_slot` is now +`last_funding_timestamp`, the Clock's `unix_timestamp` at the last accrual; the +same rename applies to `initialize_pool`'s and `set_funding_rate`'s arguments. A +timestamp at or before the stored one accrues nothing. `advance_funding` is +replaced by `accrue_funding`, which updates the pool in place as the Anchor +version's does. Tested by `funding_follows_seconds_not_slots`, with +`set_funding_rate_settles_at_the_old_rate_first` now counting seconds. + ## 2026-09-10 Remove the separate dataless signing PDA (seeds `["authority", pool]`) that diff --git a/finance/perpetual-futures/quasar/README.md b/finance/perpetual-futures/quasar/README.md index dfcda2cfd..0ad945d47 100644 --- a/finance/perpetual-futures/quasar/README.md +++ b/finance/perpetual-futures/quasar/README.md @@ -31,7 +31,9 @@ math. This page only covers what differs in the Quasar version. Tests run in-process with [`quasar-svm`](https://github.com/blueshift-gg/quasar-svm). They build the program, set up a collateral mint, oracle feed, and funded wallets, then exercise pool initialization, liquidity add/remove, opening and -closing a long in profit, leverage rejection, liquidation, and fee collection. +closing a long in profit, leverage rejection, funding-rate retuning (including +that it settles elapsed seconds at the old rate), funding that follows seconds +rather than slots, liquidation, and fee collection. ```bash cargo build-sbf diff --git a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs index 6a964a9f9..a180a56cc 100644 --- a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs @@ -53,7 +53,13 @@ pub fn handle_add_liquidity( } let slot = accounts.clock.slot.get(); - let price = refresh_price_and_funding(&mut accounts.pool, &accounts.oracle_feed, slot)?; + let unix_timestamp = accounts.clock.unix_timestamp.get(); + let price = refresh_price_and_funding( + &mut accounts.pool, + &accounts.oracle_feed, + slot, + unix_timestamp, + )?; let lp_supply = accounts.lp_mint.supply(); let shares: u64 = if lp_supply == 0 { diff --git a/finance/perpetual-futures/quasar/src/instructions/close_position.rs b/finance/perpetual-futures/quasar/src/instructions/close_position.rs index c7e88070f..340078248 100644 --- a/finance/perpetual-futures/quasar/src/instructions/close_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/close_position.rs @@ -47,7 +47,13 @@ pub fn handle_close_position( bumps: &ClosePositionBumps, ) -> Result<(), ProgramError> { let slot = accounts.clock.slot.get(); - let price = refresh_price_and_funding(&mut accounts.pool, &accounts.oracle_feed, slot)?; + let unix_timestamp = accounts.clock.unix_timestamp.get(); + let price = refresh_price_and_funding( + &mut accounts.pool, + &accounts.oracle_feed, + slot, + unix_timestamp, + )?; let side = accounts.position.side; let size = accounts.position.size.get(); diff --git a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs index 66699f158..9d3f42464 100644 --- a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs @@ -52,7 +52,7 @@ pub struct InitializePool { pub fn handle_initialize_pool( accounts: &mut InitializePool, oracle_scale: u32, - funding_rate_per_slot: u64, + funding_rate_per_second: u64, open_fee_bps: u16, close_fee_bps: u16, max_leverage: u16, @@ -86,7 +86,7 @@ pub fn handle_initialize_pool( return Err(err(error::INVALID_PARAMETER)); } - let slot = accounts.clock.slot.get(); + let unix_timestamp = accounts.clock.unix_timestamp.get(); accounts.pool.set_inner(PoolInner { authority: *accounts.authority.address(), collateral_mint: *accounts.collateral_mint.address(), @@ -103,8 +103,8 @@ pub fn handle_initialize_pool( long_size_scaled: 0, short_size_scaled: 0, cumulative_funding: 0, - last_funding_slot: slot, - funding_rate_per_slot, + last_funding_timestamp: unix_timestamp, + funding_rate_per_second, open_fee_bps, close_fee_bps, max_leverage, diff --git a/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs b/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs index d8ac30742..7195468f4 100644 --- a/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs @@ -59,7 +59,13 @@ pub fn handle_liquidate_position( bumps: &LiquidatePositionBumps, ) -> Result<(), ProgramError> { let slot = accounts.clock.slot.get(); - let price = refresh_price_and_funding(&mut accounts.pool, &accounts.oracle_feed, slot)?; + let unix_timestamp = accounts.clock.unix_timestamp.get(); + let price = refresh_price_and_funding( + &mut accounts.pool, + &accounts.oracle_feed, + slot, + unix_timestamp, + )?; let side = accounts.position.side; let size = accounts.position.size.get(); diff --git a/finance/perpetual-futures/quasar/src/instructions/open_position.rs b/finance/perpetual-futures/quasar/src/instructions/open_position.rs index d4f7ebe56..6a2c76397 100644 --- a/finance/perpetual-futures/quasar/src/instructions/open_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/open_position.rs @@ -57,7 +57,13 @@ pub fn handle_open_position( } let slot = accounts.clock.slot.get(); - let price = refresh_price_and_funding(&mut accounts.pool, &accounts.oracle_feed, slot)?; + let unix_timestamp = accounts.clock.unix_timestamp.get(); + let price = refresh_price_and_funding( + &mut accounts.pool, + &accounts.oracle_feed, + slot, + unix_timestamp, + )?; if acceptable_price != 0 { let acceptable = if side == SIDE_LONG { diff --git a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs index e7bce4664..7b547f42a 100644 --- a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs @@ -52,7 +52,13 @@ pub fn handle_remove_liquidity( } let slot = accounts.clock.slot.get(); - let price = refresh_price_and_funding(&mut accounts.pool, &accounts.oracle_feed, slot)?; + let unix_timestamp = accounts.clock.unix_timestamp.get(); + let price = refresh_price_and_funding( + &mut accounts.pool, + &accounts.oracle_feed, + slot, + unix_timestamp, + )?; let lp_supply = accounts.lp_mint.supply(); let traders = traders_unrealized_pnl( diff --git a/finance/perpetual-futures/quasar/src/instructions/set_funding_rate.rs b/finance/perpetual-futures/quasar/src/instructions/set_funding_rate.rs index dde1edb86..09d72c95d 100644 --- a/finance/perpetual-futures/quasar/src/instructions/set_funding_rate.rs +++ b/finance/perpetual-futures/quasar/src/instructions/set_funding_rate.rs @@ -1,5 +1,5 @@ use { - crate::{instructions::shared::advance_funding, state::Pool}, + crate::{instructions::shared::accrue_funding, state::Pool}, quasar_lang::{prelude::*, sysvars::Sysvar}, }; @@ -18,32 +18,18 @@ pub struct SetFundingRate { 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. +/// Retune the pool's funding rate, quoted per second of wall-clock time. /// -/// 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. +/// Funding is accrued at the old rate first, so the seconds 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, + funding_rate_per_second: 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); + accrue_funding(pool, i64::from(Clock::get()?.unix_timestamp))?; + pool.funding_rate_per_second.set(funding_rate_per_second); Ok(()) } diff --git a/finance/perpetual-futures/quasar/src/instructions/shared.rs b/finance/perpetual-futures/quasar/src/instructions/shared.rs index a11065328..9e14cb46c 100644 --- a/finance/perpetual-futures/quasar/src/instructions/shared.rs +++ b/finance/perpetual-futures/quasar/src/instructions/shared.rs @@ -129,29 +129,50 @@ pub fn read_oracle_price( u64::try_from(price).map_err(|_| overflow()) } -/// New cumulative funding index after advancing to `current_slot`. The heavier -/// side pays: the index rises while longs lead, falls while shorts lead. -pub fn advance_funding( - cumulative_funding: i128, - last_funding_slot: u64, - current_slot: u64, - funding_rate_per_slot: u64, - long_size: u128, - short_size: u128, -) -> Result { - let elapsed = current_slot.saturating_sub(last_funding_slot); - if elapsed == 0 || (long_size == 0 && short_size == 0) { - return Ok(cumulative_funding); +/// Advance the pool's cumulative funding index to `current_timestamp`, the +/// Clock's `unix_timestamp`. +/// +/// The heavier open-interest side pays funding to the pool: while longs are +/// larger the index rises (longs owe), while shorts are larger it falls (shorts +/// owe). No positions means no one to charge, so the index is left untouched and +/// only the timestamp moves forward. +/// +/// The timestamp is written by each block's leader. A timestamp at or before +/// the stored one is treated as no time elapsed and leaves the stored stamp +/// where it is, so no second is charged twice or skipped. +pub fn accrue_funding( + pool: &mut Account, + current_timestamp: i64, +) -> Result<(), ProgramError> { + let last_funding_timestamp = pool.last_funding_timestamp.get(); + if current_timestamp <= last_funding_timestamp { + return Ok(()); } - let magnitude = (funding_rate_per_slot as i128) - .checked_mul(elapsed as i128) + let elapsed = current_timestamp + .checked_sub(last_funding_timestamp) .ok_or_else(overflow)?; - let delta = if long_size >= short_size { - magnitude - } else { - -magnitude - }; - cumulative_funding.checked_add(delta).ok_or_else(overflow) + + let long_size = pool.long_size.get(); + let short_size = pool.short_size.get(); + if long_size != 0 || short_size != 0 { + let magnitude = (pool.funding_rate_per_second.get() as i128) + .checked_mul(elapsed as i128) + .ok_or_else(overflow)?; + let delta = if long_size >= short_size { + magnitude + } else { + -magnitude + }; + let new_funding = pool + .cumulative_funding + .get() + .checked_add(delta) + .ok_or_else(overflow)?; + pool.cumulative_funding.set(new_funding); + } + + pool.last_funding_timestamp.set(current_timestamp); + Ok(()) } pub fn scale_size(size: u64, entry_price: u64) -> Result { @@ -247,13 +268,15 @@ pub fn basis_points_of(amount: u64, basis_points: u16) -> Result, oracle_feed: &UncheckedAccount, slot: u64, + unix_timestamp: i64, ) -> Result { let price = { let view = oracle_feed.to_account_view(); @@ -268,15 +291,6 @@ pub fn refresh_price_and_funding( )? }; - 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); + accrue_funding(pool, unix_timestamp)?; Ok(price) } diff --git a/finance/perpetual-futures/quasar/src/lib.rs b/finance/perpetual-futures/quasar/src/lib.rs index 444bc5833..978cb9b68 100644 --- a/finance/perpetual-futures/quasar/src/lib.rs +++ b/finance/perpetual-futures/quasar/src/lib.rs @@ -37,7 +37,7 @@ mod quasar_perpetual_futures { pub fn initialize_pool( ctx: Ctx, oracle_scale: u32, - funding_rate_per_slot: u64, + funding_rate_per_second: u64, open_fee_bps: u16, close_fee_bps: u16, max_leverage: u16, @@ -48,7 +48,7 @@ mod quasar_perpetual_futures { instructions::handle_initialize_pool( &mut ctx.accounts, oracle_scale, - funding_rate_per_slot, + funding_rate_per_second, open_fee_bps, close_fee_bps, max_leverage, @@ -126,8 +126,8 @@ mod quasar_perpetual_futures { #[instruction(discriminator = 7)] pub fn set_funding_rate( ctx: Ctx, - funding_rate_per_slot: u64, + funding_rate_per_second: u64, ) -> Result<(), ProgramError> { - instructions::handle_set_funding_rate(&mut ctx.accounts, funding_rate_per_slot) + instructions::handle_set_funding_rate(&mut ctx.accounts, funding_rate_per_second) } } diff --git a/finance/perpetual-futures/quasar/src/state.rs b/finance/perpetual-futures/quasar/src/state.rs index fc6032cfa..452b107af 100644 --- a/finance/perpetual-futures/quasar/src/state.rs +++ b/finance/perpetual-futures/quasar/src/state.rs @@ -28,8 +28,13 @@ pub struct Pool { pub long_size_scaled: u128, pub short_size_scaled: u128, pub cumulative_funding: i128, - pub last_funding_slot: u64, - pub funding_rate_per_slot: u64, + /// The Clock's `unix_timestamp` when funding last accrued. Funding runs on + /// the wall clock, so what a position costs per hour does not depend on + /// the cluster's slot time. + pub last_funding_timestamp: i64, + /// Funding accrued per second, in `FUNDING_PRECISION` units, applied to the + /// heavier side. The funding paid by traders accrues to the pool. + pub funding_rate_per_second: u64, pub open_fee_bps: u16, pub close_fee_bps: u16, pub max_leverage: u16, diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index bbf836dbd..26b15ac72 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -20,6 +20,9 @@ const ORACLE_SCALE: u32 = 8; // quasar-test worlds run at the default slot (0); the feed is stamped with the // same slot so the staleness check passes. const SLOT: u64 = 0; +/// How fast the funding tests move the slot while the clock moves: five a +/// second, the network's 200 ms target, so prices age as they would. +const SLOTS_PER_SECOND: u64 = 5; // Deterministic addresses. const ADMIN: Pubkey = Pubkey::new_from_array([1; 32]); @@ -54,16 +57,17 @@ fn set_feed_at_slot(test: &mut Test, price: i128, slot: u64, confidence: u64) { test.set_account(Account::new(FEED, system_program::ID, 1_000_000, data)); } -/// Pin the Clock sysvar account at `slot`. Clock's bincode layout is the raw -/// little-endian fields: slot, epoch_start_timestamp, epoch, +/// Pin the Clock sysvar account at `slot` and `unix_timestamp`. Price freshness +/// is counted in the slot; funding is counted in the timestamp. Clock's bincode +/// layout is the raw little-endian fields: slot, epoch_start_timestamp, epoch, /// leader_schedule_epoch, unix_timestamp. -fn set_clock_at(test: &mut Test, slot: u64) { +fn set_clock_at(test: &mut Test, slot: u64, unix_timestamp: i64) { let mut data = Vec::with_capacity(40); data.extend_from_slice(&slot.to_le_bytes()); data.extend_from_slice(&0i64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); - data.extend_from_slice(&0i64.to_le_bytes()); + data.extend_from_slice(&unix_timestamp.to_le_bytes()); let clock_id: Pubkey = "SysvarC1ock11111111111111111111111111111111" .parse() .unwrap(); @@ -99,14 +103,14 @@ fn init_pool_with_funding( test: &mut Test, maintenance_margin_bps: u16, close_fee_bps: u16, - funding_rate_per_slot: u64, + funding_rate_per_second: u64, ) -> Outcome { test.send(InitializePoolInstruction { authority: ADMIN, collateral_mint: COLLATERAL_MINT, oracle_feed: FEED, oracle_scale: ORACLE_SCALE, - funding_rate_per_slot, + funding_rate_per_second, open_fee_bps: 10, close_fee_bps, max_leverage: 10, @@ -130,13 +134,13 @@ 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 { +/// Like `setup`, but with a non-zero per-second funding rate so funding accrues +/// as time passes. +fn setup_with_funding(test: &mut Test, funding_rate_per_second: u64) -> Env { test.add(Wallet::new().at(ADMIN)); test.add(Mint::new(ADMIN).at(COLLATERAL_MINT).decimals(6)); set_feed(test, dollars(100), 0); - init_pool_with_funding(test, 500, 10, funding_rate_per_slot).succeeds(); + init_pool_with_funding(test, 500, 10, funding_rate_per_second).succeeds(); let pool = test.derive_pda(Pool::seeds(&COLLATERAL_MINT, &FEED)); Env { @@ -278,7 +282,7 @@ fn open_rejects_price_from_before_a_restart(test: &mut Test) { // The feed sits at slot 5, fresh by the 150-slot staleness bound, but the // cluster restarted at slot 7: only the restart check can catch the // pre-halt price. - set_clock_at(test, 10); + set_clock_at(test, 10, 0); set_feed_at_slot(test, dollars(100), 5, 0); set_last_restart_slot(test, 7); assert!( @@ -382,12 +386,10 @@ 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 operator retunes the rate, and the retune settles the slots already -/// elapsed at the old rate rather than repricing them at the new one. +/// Retuning the rate settles the seconds already elapsed at the old rate +/// rather than repricing them at the new one. /// -/// Both halves below hold the same position for the same slots at the same +/// Both halves below hold the same position for the same seconds at the same /// price, so the size and price scaling cancels and only the rates differ: the /// spanning position pays one window at the old rate plus one at the new (3 /// window-rates), and the position opened afterwards pays one window wholly at @@ -399,6 +401,7 @@ fn set_funding_rate_settles_at_the_old_rate_first(test: &mut Test) { 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 window_slots = window as u64 * SLOTS_PER_SECOND; let env = setup_with_funding(test, rate); fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); @@ -408,24 +411,24 @@ fn set_funding_rate_settles_at_the_old_rate_first(test: &mut Test) { // 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); + set_clock_at(test, window_slots, window); test.send(SetFundingRateInstruction { authority: ADMIN, collateral_mint: COLLATERAL_MINT, oracle_feed: FEED, - funding_rate_per_slot: rate * 2, + funding_rate_per_second: rate * 2, }) .succeeds(); - set_clock_at(test, 2 * window); - set_feed_at_slot(test, dollars(100), 2 * window, 0); + set_clock_at(test, 2 * window_slots, 2 * window); + set_feed_at_slot(test, dollars(100), 2 * window_slots, 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); + set_clock_at(test, 3 * window_slots, 3 * window); + set_feed_at_slot(test, dollars(100), 3 * window_slots, 0); close_position(test, &env).succeeds(); let doubled = (before_doubled - test.tokens(TRADER_COLLATERAL)) - fees; @@ -441,6 +444,52 @@ fn set_funding_rate_settles_at_the_old_rate_first(test: &mut Test) { ); } +/// Funding is quoted per second of wall-clock time, so slots passing without +/// the clock moving charge nothing. A million extra slots halfway through the +/// window, as a much shorter slot would produce, leave the funding unchanged. +/// +/// Both halves hold the same position for the same seconds at the same price, +/// so they must pay the same funding; only the second sees the extra slots. +#[quasar_test] +fn funding_follows_seconds_not_slots(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 window_slots = window as u64 * SLOTS_PER_SECOND; + let extra_slots = 1_000_000; + + 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); + + // Two windows at the network's slot pace. + let before_flat = test.tokens(TRADER_COLLATERAL); + open_position(test, &env, 0, collateral, size).succeeds(); + set_clock_at(test, 2 * window_slots, 2 * window); + set_feed_at_slot(test, dollars(100), 2 * window_slots, 0); + close_position(test, &env).succeeds(); + let flat = (before_flat - test.tokens(TRADER_COLLATERAL)) - fees; + + // Two more windows, with a million extra slots passing at the midpoint + // while the clock stands still. + let before_extra = test.tokens(TRADER_COLLATERAL); + open_position(test, &env, 0, collateral, size).succeeds(); + set_clock_at(test, 3 * window_slots + extra_slots, 3 * window); + set_clock_at(test, 4 * window_slots + extra_slots, 4 * window); + set_feed_at_slot(test, dollars(100), 4 * window_slots + extra_slots, 0); + close_position(test, &env).succeeds(); + let with_extra_slots = (before_extra - test.tokens(TRADER_COLLATERAL)) - fees; + + assert!( + flat > 0, + "the flat run must pay some funding to compare against" + ); + assert_eq!(with_extra_slots, flat); +} + #[quasar_test] fn only_the_authority_can_set_the_funding_rate(test: &mut Test) { let env = setup_with_funding(test, 5_000); @@ -451,7 +500,7 @@ fn only_the_authority_can_set_the_funding_rate(test: &mut Test) { authority: TRADER, collateral_mint: COLLATERAL_MINT, oracle_feed: FEED, - funding_rate_per_slot: 1, + funding_rate_per_second: 1, }) .is_err(), "a non-authority must not be able to retune the funding rate" From 2851433e1910da48577d1e53773cd5d9c517d8ed Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Tue, 22 Sep 2026 23:35:32 +0000 Subject: [PATCH 3/3] lending (Anchor v2): rustfmt the new reserve tests Claude-Session: https://claude.ai/code/session_01CXqcBq93zfcZ2F8jeoSaez --- .../lending/anchor/programs/lending/tests/test_reserve.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/finance/lending/anchor/programs/lending/tests/test_reserve.rs b/finance/lending/anchor/programs/lending/tests/test_reserve.rs index b73a236c9..63c6c2686 100644 --- a/finance/lending/anchor/programs/lending/tests/test_reserve.rs +++ b/finance/lending/anchor/programs/lending/tests/test_reserve.rs @@ -3,8 +3,8 @@ mod common; use lending::errors::LendingError; use common::{default_config, Env}; -use lending::state::Reserve; use lending::constants::FIXED_POINT_SCALE; +use lending::state::Reserve; #[test] fn init_market_and_reserve() { @@ -140,7 +140,10 @@ fn a_timestamp_behind_the_last_accrual_charges_nothing() { env.shift_timestamp(-600); env.refresh_reserve_only(&refresher, &borrow); let behind = env.reserve(&borrow); - assert_eq!(behind.borrow_accumulation_factor, before.borrow_accumulation_factor); + assert_eq!( + behind.borrow_accumulation_factor, + before.borrow_accumulation_factor + ); assert_eq!(behind.last_accrual_timestamp, before.last_accrual_timestamp); // 1,600 seconds forward from the shifted clock is 1,000 past the stamp.