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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions finance/lending/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 2026-08-14

Move slots-per-year out of the code and into the reserve config. Turning an APR
into the per-slot rate interest accrues at needs a slots-per-year divisor, and
that divisor is the cluster's slot time in disguise. It was a `SLOTS_PER_YEAR`
constant fixed at a 400ms slot, so a protocol change to the slot time would have
raised the wall-clock rate every borrower pays with no code change and nothing
to show for it. `ReserveConfig` now carries `slots_per_year`, `validate()`
rejects zero, and the market owner retunes it with `update_reserve_config`.
Tested by `slots_per_year_scales_the_per_slot_rate` and
`rejects_zero_slots_per_year`.

## 2026-08-04

Reject oracle prices from before a cluster restart. A halt stops the slot
Expand Down
10 changes: 10 additions & 0 deletions finance/lending/anchor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ utilization. Each borrow stores its principal as **scaled debt** (principal ÷
index at borrow time), so every obligation's debt grows automatically as the
index advances: no per-obligation accrual loop.

Those curve parameters are annual, and the conversion to a per-slot rate divides
by `config.slots_per_year`. That divisor is the cluster's slot time expressed as
a count, which is why it is configuration and not a constant: Solana lowers the
slot time over time, and a reserve left on an old figure charges borrowers more
per day than the APR it advertises, with nothing in the program changed to say
so. Read the current slot time off the cluster you deploy against (two
[`getBlockTime`](https://solana.com/docs/rpc/http/getblocktime) results a known
number of slots apart) and keep the reserve in step with
`update_reserve_config`.

### Protocol fees (how the market earns)

Borrowers owe the full interest, but suppliers don't receive all of it. On each
Expand Down
12 changes: 5 additions & 7 deletions finance/lending/anchor/programs/lending/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,16 @@ pub const FIXED_POINT_SCALE_DECIMALS: i32 = 18;
/// Denominator for every basis-point config value. 100% == 10_000 bps.
pub const BPS_DENOMINATOR: u128 = 10_000;

/// Slots per year, for turning an APR (in bps) into a per-slot rate.
/// Solana targets ~2.5 slots/second: 2.5 * 60 * 60 * 24 * 365 = 78_840_000.
pub const SLOTS_PER_YEAR: u128 = 78_840_000;

/// Maximum distinct reserves an obligation may use as collateral, and
/// separately as borrows. Bounds the account size and the compute cost of
/// refresh_obligation (which iterates every entry).
pub const MAX_OBLIGATION_RESERVES: usize = 4;

/// A price feed older than this many slots is rejected as stale (~10s at 2.5
/// slots/second). Freshness is measured in slots, not unix time, because the
/// runtime guarantees slot progression while the timestamp is validator-influenced.
/// A price feed older than this many slots is rejected as stale. Freshness is
/// measured in slots, not unix time, because the runtime guarantees slot
/// progression while the timestamp is validator-influenced. How long the window
/// is in seconds follows the cluster's slot time, which the protocol lowers over
/// time, so the window tightens on its own and never loosens.
pub const MAX_PRICE_STALENESS_SLOTS: u64 = 25;

// PDA seeds.
Expand Down
16 changes: 13 additions & 3 deletions finance/lending/anchor/programs/lending/src/state/reserve.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use anchor_lang::prelude::*;

use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, RESERVE_SEED, SLOTS_PER_YEAR};
use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, RESERVE_SEED};
use crate::errors::LendingError;
use crate::math::{mul_div_ceil, mul_div_floor};

Expand Down Expand Up @@ -98,6 +98,14 @@ pub struct ReserveConfig {
pub optimal_borrow_rate_bps: u16,
/// Borrow APR at 100% utilization.
pub max_borrow_rate_bps: u16,
/// Slots in a year: the divisor that turns the APR fields above into the
/// per-slot rate interest actually accrues at. This is the cluster's slot
/// time expressed as a count, so it belongs in configuration rather than in
/// a constant. The protocol lowers the slot time over time, and a value left
/// behind here charges borrowers at the wrong wall-clock rate while every
/// other number in this struct still reads correctly. The owner updates it
/// with `update_reserve_config` when the slot time changes.
pub slots_per_year: u64,
}

impl ReserveConfig {
Expand Down Expand Up @@ -130,6 +138,8 @@ impl ReserveConfig {
&& self.optimal_borrow_rate_bps <= self.max_borrow_rate_bps,
LendingError::InvalidConfig
);
// Zero would divide by zero when converting the APR to a per-slot rate.
require!(self.slots_per_year > 0, LendingError::InvalidConfig);
Ok(())
}
}
Expand Down Expand Up @@ -202,9 +212,9 @@ impl Reserve {
.ok_or(LendingError::MathOverflow)?
};

// apr_bps / (BPS_DENOMINATOR * SLOTS_PER_YEAR), carried at FIXED_POINT_SCALE.
// apr_bps / (BPS_DENOMINATOR * slots_per_year), carried at FIXED_POINT_SCALE.
let per_year_denominator = BPS_DENOMINATOR
.checked_mul(SLOTS_PER_YEAR)
.checked_mul(self.config.slots_per_year as u128)
.ok_or(LendingError::MathOverflow)?;
mul_div_floor(apr_bps, FIXED_POINT_SCALE, per_year_denominator)
}
Expand Down
8 changes: 8 additions & 0 deletions finance/lending/anchor/programs/lending/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,13 @@ impl Env {
/// A reasonable default reserve config: 75% LTV, 80% liquidation threshold,
/// 5% bonus, 50% close factor, 10% reserve factor (protocol's cut of interest),
/// kink at 80% utilization, 2%/20%/150% APR curve.
/// Slots in a year, which is how a reserve turns an APR into a per-slot rate.
/// 78_840_000 is a 400ms slot: 2.5 slots/second * 60 * 60 * 24 * 365. It is a
/// test fixture, not a law: a deployment reads the slot time off the cluster it
/// points at (two `getBlockTime` results a known number of slots apart) and
/// updates the reserve when the protocol changes it.
pub const SLOTS_PER_YEAR: u64 = 78_840_000;

pub fn default_config() -> ReserveConfig {
ReserveConfig {
loan_to_value_bps: 7_500,
Expand All @@ -743,5 +750,6 @@ pub fn default_config() -> ReserveConfig {
min_borrow_rate_bps: 200,
optimal_borrow_rate_bps: 2_000,
max_borrow_rate_bps: 15_000,
slots_per_year: SLOTS_PER_YEAR,
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
mod common;

use common::{default_config, dollars, ata, Env};
use common::{default_config, dollars, ata, Env, SLOTS_PER_YEAR};
use lending::constants::FIXED_POINT_SCALE;
use solana_signer::Signer;

Expand Down Expand Up @@ -31,8 +31,9 @@ fn interest_accrues_on_borrows_over_time() {

assert_eq!(env.reserve(&borrow).borrow_accumulation_factor, FIXED_POINT_SCALE);

// Let ~0.1 year pass (2.5 slots/s => ~7.884M slots), re-publish prices, refresh.
env.warp_slots(7_884_000);
// Let a tenth of a year pass, counted at the reserve's own slots-per-year
// figure, then re-publish prices and refresh.
env.warp_slots(SLOTS_PER_YEAR / 10);
env.set_price(collateral.mint, dollars(1));
env.set_price(borrow.mint, dollars(1));
env.refresh_reserve_only(&borrower, &borrow);
Expand Down
76 changes: 75 additions & 1 deletion finance/lending/anchor/programs/lending/tests/test_reserve.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
mod common;

use common::{default_config, Env};
use common::{default_config, Env, SLOTS_PER_YEAR};
use lending::constants::FIXED_POINT_SCALE;

#[test]
Expand Down Expand Up @@ -57,3 +57,77 @@ fn accepts_valid_config_update() {
env.try_update_config(&usdc, updated).unwrap();
assert_eq!(env.reserve(&usdc).config.loan_to_value_bps, 6_000);
}

#[test]
fn rejects_zero_slots_per_year() {
let mut env = Env::new();
let usdc = env.add_reserve(6, common::dollars(1), default_config());

let mut bad = default_config();
bad.slots_per_year = 0;
let result = env.try_update_config(&usdc, bad);
assert!(
result.unwrap_err().contains("InvalidConfig"),
"a zero slots-per-year divisor must be rejected, not divided by"
);
}

/// The rate fields are annual; what a borrower is charged per slot is the APR
/// divided by `slots_per_year`. Two reserves differing only in that divisor
/// accrue in proportion to it over the same elapsed slots. This is why the
/// cluster's slot time has to be configured rather than compiled in: leave a
/// stale figure in place after the protocol shortens the slot and every
/// borrower pays more per day than the advertised APR, with nothing in the
/// program changed to say so.
#[test]
fn slots_per_year_scales_the_per_slot_rate() {
let mut env = Env::new();
let collateral = env.add_reserve(6, common::dollars(1), default_config());

let baseline = env.add_reserve(6, common::dollars(1), default_config());
let mut halved_config = default_config();
halved_config.slots_per_year = SLOTS_PER_YEAR / 2;
let halved = env.add_reserve(6, common::dollars(1), halved_config);

// Identical supply and borrow in both, so both sit at 50% utilization and
// therefore resolve to the same APR from the same kinked curve.
for reserve in [&baseline, &halved] {
let supplier = env.create_user();
env.fund(&supplier, reserve.mint, 1_000_000_000);
env.supply(&supplier, reserve, 1_000_000_000);

let borrower = env.create_user();
env.fund(&borrower, collateral.mint, 1_000_000_000);
env.fund(&borrower, reserve.mint, 0);
env.supply(&borrower, &collateral, 1_000_000_000);
let obligation = env.initialize_obligation(&borrower);
env.post_collateral(&borrower, obligation, &collateral, 1_000_000_000);
env.try_borrow(&borrower, obligation, &[&collateral], &[], reserve, 500_000_000)
.unwrap();
}

let elapsed = SLOTS_PER_YEAR / 100;
env.warp_slots(elapsed);
env.set_price(collateral.mint, common::dollars(1));
env.set_price(baseline.mint, common::dollars(1));
env.set_price(halved.mint, common::dollars(1));

let refresher = env.create_user();
env.refresh_reserve_only(&refresher, &baseline);
env.refresh_reserve_only(&refresher, &halved);

let baseline_growth = env.reserve(&baseline).borrow_accumulation_factor - FIXED_POINT_SCALE;
let halved_growth = env.reserve(&halved).borrow_accumulation_factor - FIXED_POINT_SCALE;
assert!(
baseline_growth > 0,
"the baseline reserve must have accrued something to compare against"
);

// The per-slot rate is floored, so the two rates can differ by one unit in
// the last place; over `elapsed` slots that is an `elapsed`-sized gap.
let doubled = baseline_growth * 2;
assert!(
halved_growth.abs_diff(doubled) <= elapsed as u128,
"halving slots_per_year should double the accrual: got {halved_growth}, expected about {doubled}"
);
}
21 changes: 21 additions & 0 deletions finance/lending/quasar/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# Changelog

## [2026-08-14]

### Added

- `update_slots_per_year` (discriminator 12, owner-only): retunes a reserve to
the cluster's current slot time. It accrues at the old figure before storing
the new one, so slots already elapsed are charged at the rate that was in
force for them. Every other config value is a policy choice the owner makes;
this one tracks a protocol parameter that changes without asking, which is why
it gets its own handler.

### Changed

- `Reserve` carries `slots_per_year`, and `initialize_reserve` takes it as a
parameter. Converting an APR into a per-slot rate needs a slots-per-year
divisor, and that divisor is the cluster's slot time in disguise; it was a
`SLOTS_PER_YEAR` constant fixed at a 400ms slot, so a protocol change to the
slot time would have raised the wall-clock rate every borrower pays with no
code change. `validate_config` rejects zero. Tested by
`retuning_slots_per_year_rescales_accrual`.

## [2026-08-04]

### Changed
Expand Down
12 changes: 11 additions & 1 deletion finance/lending/quasar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ Everything else mirrors the Anchor version.
the owner earns.
- **Integer-only math**: `u128`, scaled by `FIXED_POINT_SCALE` (10^18), every
conversion rounding in the protocol's favour.
- **`slots_per_year`**: the divisor that turns a reserve's annual rate curve into
the per-slot rate interest accrues at. It is the cluster's slot time expressed
as a count, which is why it is stored rather than compiled in: Solana lowers
the slot time over time, and a reserve left on an old figure charges borrowers
more per day than the APR it advertises. Read the current slot time off the
cluster you deploy against (two
[`getBlockTime`](https://solana.com/docs/rpc/http/getblocktime) results a known
number of slots apart) and keep the reserve in step with
`update_slots_per_year`, which accrues at the old figure before storing the new
one.

### Instruction handlers (numeric discriminators)

Expand All @@ -77,7 +87,7 @@ Everything else mirrors the Anchor version.
`initialize_obligation` (5), `deposit_obligation_collateral` (6),
`withdraw_obligation_collateral` (7), `borrow_obligation_liquidity` (8),
`repay_obligation_liquidity` (9), `liquidate_obligation` (10),
`collect_protocol_fees` (11).
`collect_protocol_fees` (11), `update_slots_per_year` (12).

## Setup

Expand Down
9 changes: 5 additions & 4 deletions finance/lending/quasar/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ pub const FIXED_POINT_SCALE_DECIMALS: i32 = 18;
/// 100% expressed in basis points.
pub const BPS_DENOMINATOR: u128 = 10_000;

/// Slots per year (~2.5 slots/s), for turning an APR in bps into a per-slot rate.
pub const SLOTS_PER_YEAR: u128 = 78_840_000;

/// Reject a price feed older than this many slots (~10s at 2.5 slots/s).
/// Reject a price feed older than this many slots. Freshness is counted in
/// slots, not unix time, because the runtime guarantees slot progression while
/// the timestamp is validator-influenced. How long the window is in seconds
/// follows the cluster's slot time, which the protocol lowers over time, so the
/// window tightens on its own and never loosens.
pub const MAX_PRICE_STALENESS_SLOTS: u64 = 25;

/// SPL token account size, for the rent-exempt vault created in `initialize_reserve`.
Expand Down
36 changes: 36 additions & 0 deletions finance/lending/quasar/src/instructions/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ impl InitializeReserve {
min_borrow_rate_bps: u16,
optimal_borrow_rate_bps: u16,
max_borrow_rate_bps: u16,
slots_per_year: u64,
bumps: &InitializeReserveBumps,
) -> Result<(), ProgramError> {
validate_config(
Expand All @@ -95,6 +96,7 @@ impl InitializeReserve {
min_borrow_rate_bps,
optimal_borrow_rate_bps,
max_borrow_rate_bps,
slots_per_year,
)?;

let reserve_address = *self.reserve.address();
Expand Down Expand Up @@ -154,6 +156,7 @@ impl InitializeReserve {
borrowed_principal: 0,
borrow_accumulation_factor: crate::constants::FIXED_POINT_SCALE,
last_update_slot: now()?,
slots_per_year,
liquidity_decimals: decimals,
loan_to_value_bps,
liquidation_threshold_bps,
Expand All @@ -170,6 +173,39 @@ impl InitializeReserve {
}
}

// ---------------------------------------------------------------------------
// update_slots_per_year
// ---------------------------------------------------------------------------

#[derive(Accounts)]
pub struct UpdateSlotsPerYear {
pub owner: Signer,
#[account(has_one(owner))]
pub lending_market: Account<LendingMarket>,
#[account(mut, has_one(lending_market))]
pub reserve: Account<Reserve>,
}

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)
// ---------------------------------------------------------------------------
Expand Down
10 changes: 10 additions & 0 deletions finance/lending/quasar/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ mod quasar_lending {
min_borrow_rate_bps: u16,
optimal_borrow_rate_bps: u16,
max_borrow_rate_bps: u16,
slots_per_year: u64,
) -> Result<(), ProgramError> {
ctx.accounts.run(
loan_to_value_bps,
Expand All @@ -66,6 +67,7 @@ mod quasar_lending {
min_borrow_rate_bps,
optimal_borrow_rate_bps,
max_borrow_rate_bps,
slots_per_year,
&ctx.bumps,
)
}
Expand Down Expand Up @@ -144,4 +146,12 @@ mod quasar_lending {
pub fn collect_protocol_fees(ctx: Ctx<CollectProtocolFees>) -> Result<(), ProgramError> {
ctx.accounts.run()
}

#[instruction(discriminator = 12)]
pub fn update_slots_per_year(
ctx: Ctx<UpdateSlotsPerYear>,
slots_per_year: u64,
) -> Result<(), ProgramError> {
ctx.accounts.run(slots_per_year)
}
}
Loading
Loading