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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] - Betting market events have a draft state and a close time

The betting market locked its outcome list implicitly, by refusing
Expand Down
17 changes: 17 additions & 0 deletions finance/lending/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
26 changes: 16 additions & 10 deletions finance/lending/anchor-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 7 additions & 1 deletion finance/lending/anchor-v1/programs/lending/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ pub fn handle_initialize_reserve(context: Context<InitializeReserve>, 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RefreshReserve>) -> 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)]
Expand Down
62 changes: 37 additions & 25 deletions finance/lending/anchor-v1/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};
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};

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(())
}
}
Expand Down Expand Up @@ -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<u128> {
pub fn current_borrow_rate_per_second(&self) -> Result<u128> {
let utilization = self.utilization_bps()?;
let optimal_utilization = self.config.optimal_utilization_bps as u128;

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(())
}
Expand Down
36 changes: 28 additions & 8 deletions finance/lending/anchor-v1/programs/lending/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,38 @@ impl Env {
}
}

/// Advance time so interest accrues and blockhashes differ.
pub fn current_timestamp(&self) -> i64 {
self.svm.get_sysvar::<anchor_lang::solana_program::clock::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.
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::<anchor_lang::solana_program::clock::Clock>();
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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
}
}
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading