From 366309599de8315d906c99460a732426d02f37d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 14:48:47 +0000 Subject: [PATCH] lending: name the interest index for what it is cumulative_borrow_rate_index was wrong twice. It is not a rate, since a rate is per unit of time and this value is the accumulated product of (1 + rate_per_slot * elapsed_slots); and "index" says nothing about what the number is for. Its own doc comment already described it correctly: starts at 1.0 and only ever multiplies by factors >= 1. That is an accumulation factor. cumulative_borrow_rate_index -> borrow_accumulation_factor borrowed_amount_scaled -> borrowed_principal The second follows from the first: "scaled" only meant "scaled by the index", so once the index is a factor the name points at nothing. The pair now reads as the arithmetic it is, borrowed_principal times borrow_accumulation_factor over FIXED_POINT_SCALE. Both variants, and everything that mirrored the old vocabulary: the Quasar accrue_index() and its borrowed_scaled, the Kani proof's grow_index() and proof_interest_index_monotonic, the doc comments spelling out new_index = old_index * (...), and the three READMEs. Array positions keep the word index: borrow_index and collateral_index in liquidate_obligation and repay_obligation_liquidity are positions in obligation.borrows and obligation.deposits, which is what an index is. The perpetual futures venue's cumulative_funding is left alone. It accumulates additively, funding owed being size times the change since entry, so neither "factor" nor "multiplier" would describe it. cargo check passes on both variants with --all-targets, and the Kani crate's unit tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ABFpszmPxRorwWtWGR2jXj --- finance/lending/anchor/README.md | 4 +- .../instructions/admin/initialize_reserve.rs | 4 +- .../borrow_obligation_liquidity.rs | 10 ++-- .../src/instructions/liquidate_obligation.rs | 16 +++--- .../src/instructions/refresh_obligation.rs | 4 +- .../repay_obligation_liquidity.rs | 16 +++--- .../programs/lending/src/state/obligation.rs | 6 +- .../programs/lending/src/state/reserve.rs | 26 ++++----- .../lending/tests/test_borrow_repay.rs | 4 +- .../programs/lending/tests/test_interest.rs | 8 +-- .../programs/lending/tests/test_reserve.rs | 6 +- .../programs/lending/tests/test_rounding.rs | 2 +- finance/lending/kani-proofs/README.md | 2 +- finance/lending/kani-proofs/src/lib.rs | 39 ++++++------- finance/lending/quasar/README.md | 2 +- .../lending/quasar/src/instructions/admin.rs | 4 +- .../quasar/src/instructions/position.rs | 56 +++++++++---------- .../lending/quasar/src/instructions/supply.rs | 8 +-- finance/lending/quasar/src/logic.rs | 26 ++++----- finance/lending/quasar/src/math.rs | 42 +++++++------- finance/lending/quasar/src/state.rs | 6 +- finance/lending/quasar/src/tests.rs | 2 +- 22 files changed, 147 insertions(+), 146 deletions(-) diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index 2891f81dd..50e07f7f6 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -56,7 +56,7 @@ 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 `cumulative_borrow_rate_index` by +Each `refresh_reserve` advances `borrow_accumulation_factor` by `(1 + rate_per_slot * elapsed_slots)`. `rate_per_slot` 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 @@ -185,7 +185,7 @@ Suppliers deposit a token with `deposit_reserve_liquidity` and receive share tok ### How does interest accrue without looping over every account? -Through a cumulative interest index: `refresh_reserve` advances a per-reserve index along a utilization-based rate curve, and each obligation stores the index value from its last interaction. The gap between the two is the interest owed, so no per-account accrual loop is needed. This is the same technique the most-used Solana lending protocols share. +Through a cumulative accumulation factor: `refresh_reserve` advances a per-reserve factor along a utilization-based rate curve, and each obligation stores the index value from its last interaction. The gap between the two is the interest owed, so no per-account accrual loop is needed. This is the same technique the most-used Solana lending protocols share. ### How are prices fed into the protocol? 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 0ebd91f67..96478950b 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 @@ -18,8 +18,8 @@ pub fn handle_initialize_reserve(context: Context, config: Re reserve.liquidity_decimals = context.accounts.liquidity_mint.decimals; reserve.available_liquidity = 0; reserve.share_mint_supply = 0; - reserve.borrowed_amount_scaled = 0; - reserve.cumulative_borrow_rate_index = FIXED_POINT_SCALE; + reserve.borrowed_principal = 0; + reserve.borrow_accumulation_factor = FIXED_POINT_SCALE; reserve.last_update_slot = Clock::get()?.slot; reserve.accumulated_protocol_fees = 0; reserve.config = config; diff --git a/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs b/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs index ebd1b411f..723a29d1b 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs @@ -45,13 +45,13 @@ pub fn handle_borrow_obligation_liquidity( let scaled_added = mul_div_ceil( liquidity_amount as u128, FIXED_POINT_SCALE, - context.accounts.reserve.cumulative_borrow_rate_index, + context.accounts.reserve.borrow_accumulation_factor, )?; { let reserve = &mut context.accounts.reserve; - reserve.borrowed_amount_scaled = reserve - .borrowed_amount_scaled + reserve.borrowed_principal = reserve + .borrowed_principal .checked_add(scaled_added) .ok_or(LendingError::MathOverflow)?; reserve.available_liquidity = reserve @@ -63,8 +63,8 @@ pub fn handle_borrow_obligation_liquidity( { let obligation = &mut context.accounts.obligation; let index = obligation.upsert_borrow(reserve_key)?; - obligation.borrows[index].borrowed_scaled = obligation.borrows[index] - .borrowed_scaled + obligation.borrows[index].borrowed_principal = obligation.borrows[index] + .borrowed_principal .checked_add(scaled_added) .ok_or(LendingError::MathOverflow)?; obligation.stale = true; diff --git a/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs b/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs index ee9668fd0..bb24f1369 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs @@ -49,12 +49,12 @@ pub fn handle_liquidate_obligation( let borrow_index = obligation.find_borrow(repay_reserve.key())?; let collateral_index = obligation.find_collateral(collateral_reserve.key())?; - let borrowed_scaled = obligation.borrows[borrow_index].borrowed_scaled; + let borrowed_principal = obligation.borrows[borrow_index].borrowed_principal; let deposited_shares = obligation.deposits[collateral_index].deposited_shares; // How much debt this liquidation repays, capped by the close factor. - let interest_index = repay_reserve.cumulative_borrow_rate_index; - let debt_now = mul_div_ceil(borrowed_scaled, interest_index, FIXED_POINT_SCALE)?; + let accumulation_factor = repay_reserve.borrow_accumulation_factor; + let debt_now = mul_div_ceil(borrowed_principal, accumulation_factor, FIXED_POINT_SCALE)?; let debt_now = u64::try_from(debt_now).map_err(|_| LendingError::MathOverflow)?; let max_repay = mul_div_floor( debt_now as u128, @@ -100,13 +100,13 @@ pub fn handle_liquidate_obligation( ); let scaled_removed = - mul_div_floor(repay as u128, FIXED_POINT_SCALE, interest_index)?.min(borrowed_scaled); + mul_div_floor(repay as u128, FIXED_POINT_SCALE, accumulation_factor)?.min(borrowed_principal); // Effects: repay side. { let repay_reserve = &mut context.accounts.repay_reserve; - repay_reserve.borrowed_amount_scaled = repay_reserve - .borrowed_amount_scaled + repay_reserve.borrowed_principal = repay_reserve + .borrowed_principal .checked_sub(scaled_removed) .ok_or(LendingError::MathOverflow)?; repay_reserve.available_liquidity = repay_reserve @@ -118,10 +118,10 @@ pub fn handle_liquidate_obligation( // Effects: obligation debt and collateral. let (lending_market, owner, obligation_bump) = { let obligation = &mut context.accounts.obligation; - obligation.borrows[borrow_index].borrowed_scaled = borrowed_scaled + obligation.borrows[borrow_index].borrowed_principal = borrowed_principal .checked_sub(scaled_removed) .ok_or(LendingError::MathOverflow)?; - if obligation.borrows[borrow_index].borrowed_scaled == 0 { + if obligation.borrows[borrow_index].borrowed_principal == 0 { obligation.borrows.remove(borrow_index); } obligation.deposits[collateral_index].deposited_shares = deposited_shares diff --git a/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs b/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs index 2333dad94..21460be55 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs @@ -64,8 +64,8 @@ pub fn handle_refresh_obligation(context: Context) -> Result< read_pair(accounts, &mut cursor, borrow.reserve, lending_market, slot)?; let debt = mul_div_ceil( - borrow.borrowed_scaled, - reserve.cumulative_borrow_rate_index, + borrow.borrowed_principal, + reserve.borrow_accumulation_factor, crate::constants::FIXED_POINT_SCALE, )?; let debt = u64::try_from(debt).map_err(|_| LendingError::MathOverflow)?; diff --git a/finance/lending/anchor/programs/lending/src/instructions/repay_obligation_liquidity.rs b/finance/lending/anchor/programs/lending/src/instructions/repay_obligation_liquidity.rs index ecf671f9f..d71a4217b 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/repay_obligation_liquidity.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/repay_obligation_liquidity.rs @@ -20,23 +20,23 @@ pub fn handle_repay_obligation_liquidity( let reserve_key = context.accounts.reserve.key(); context.accounts.reserve.require_refreshed()?; - let index = context.accounts.reserve.cumulative_borrow_rate_index; + let index = context.accounts.reserve.borrow_accumulation_factor; let decimals = context.accounts.reserve.liquidity_decimals; let borrow_index = context.accounts.obligation.find_borrow(reserve_key)?; - let borrowed_scaled = context.accounts.obligation.borrows[borrow_index].borrowed_scaled; + let borrowed_principal = context.accounts.obligation.borrows[borrow_index].borrowed_principal; - let debt_now = mul_div_ceil(borrowed_scaled, index, FIXED_POINT_SCALE)?; + let debt_now = mul_div_ceil(borrowed_principal, index, FIXED_POINT_SCALE)?; let debt_now = u64::try_from(debt_now).map_err(|_| LendingError::MathOverflow)?; let repay = liquidity_amount.min(debt_now); require!(repay > 0, LendingError::ZeroAmount); - let scaled_removed = mul_div_floor(repay as u128, FIXED_POINT_SCALE, index)?.min(borrowed_scaled); + let scaled_removed = mul_div_floor(repay as u128, FIXED_POINT_SCALE, index)?.min(borrowed_principal); { let reserve = &mut context.accounts.reserve; - reserve.borrowed_amount_scaled = reserve - .borrowed_amount_scaled + reserve.borrowed_principal = reserve + .borrowed_principal .checked_sub(scaled_removed) .ok_or(LendingError::MathOverflow)?; reserve.available_liquidity = reserve @@ -47,10 +47,10 @@ pub fn handle_repay_obligation_liquidity( { let obligation = &mut context.accounts.obligation; - obligation.borrows[borrow_index].borrowed_scaled = borrowed_scaled + obligation.borrows[borrow_index].borrowed_principal = borrowed_principal .checked_sub(scaled_removed) .ok_or(LendingError::MathOverflow)?; - if obligation.borrows[borrow_index].borrowed_scaled == 0 { + if obligation.borrows[borrow_index].borrowed_principal == 0 { obligation.borrows.remove(borrow_index); } obligation.stale = true; diff --git a/finance/lending/anchor/programs/lending/src/state/obligation.rs b/finance/lending/anchor/programs/lending/src/state/obligation.rs index af161e36a..20be5b92d 100644 --- a/finance/lending/anchor/programs/lending/src/state/obligation.rs +++ b/finance/lending/anchor/programs/lending/src/state/obligation.rs @@ -54,8 +54,8 @@ pub struct ObligationLiquidity { pub reserve: Pubkey, /// Borrowed principal, scaled by the reserve's index at borrow time so the /// live debt grows automatically as that index advances: - /// `debt = borrowed_scaled * reserve.cumulative_borrow_rate_index / FIXED_POINT_SCALE`. - pub borrowed_scaled: u128, + /// `debt = borrowed_principal * reserve.borrow_accumulation_factor / FIXED_POINT_SCALE`. + pub borrowed_principal: u128, pub market_value: u128, } @@ -102,7 +102,7 @@ impl Obligation { ); self.borrows.push(ObligationLiquidity { reserve, - borrowed_scaled: 0, + borrowed_principal: 0, market_value: 0, }); Ok(self.borrows.len() - 1) diff --git a/finance/lending/anchor/programs/lending/src/state/reserve.rs b/finance/lending/anchor/programs/lending/src/state/reserve.rs index 9f13037f1..c6648c956 100644 --- a/finance/lending/anchor/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor/programs/lending/src/state/reserve.rs @@ -55,13 +55,13 @@ pub struct Reserve { /// out more than it holds. pub share_mint_supply: u64, - /// Total borrowed principal, scaled so that the live debt is - /// `borrowed_amount_scaled * cumulative_borrow_rate_index / FIXED_POINT_SCALE`. - pub borrowed_amount_scaled: u128, + /// Total borrowed principal. The live debt is + /// `borrowed_principal * borrow_accumulation_factor / FIXED_POINT_SCALE`. + pub borrowed_principal: u128, - /// Monotonically increasing interest index, FIXED_POINT_SCALE-scaled. + /// Monotonically increasing accumulation factor, FIXED_POINT_SCALE-scaled. /// Starts at FIXED_POINT_SCALE (1.0) and only ever multiplies by factors >= 1. - pub cumulative_borrow_rate_index: u128, + pub borrow_accumulation_factor: u128, pub last_update_slot: u64, @@ -138,8 +138,8 @@ impl Reserve { /// Live total debt owed to the pool, rounded up (protocol-favourable). pub fn current_borrowed_amount(&self) -> Result { let amount = mul_div_ceil( - self.borrowed_amount_scaled, - self.cumulative_borrow_rate_index, + self.borrowed_principal, + self.borrow_accumulation_factor, FIXED_POINT_SCALE, )?; u64::try_from(amount).map_err(|_| LendingError::MathOverflow.into()) @@ -209,15 +209,15 @@ impl Reserve { mul_div_floor(apr_bps, FIXED_POINT_SCALE, per_year_denominator) } - /// Advance the interest index for the slots elapsed since the last refresh. - /// `new_index = old_index * (1 + rate_per_slot * elapsed_slots)`, a single + /// 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)?; - if elapsed > 0 && self.borrowed_amount_scaled > 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 @@ -226,13 +226,13 @@ impl Reserve { let growth_factor = FIXED_POINT_SCALE .checked_add(accrued) .ok_or(LendingError::MathOverflow)?; - self.cumulative_borrow_rate_index = mul_div_floor( - self.cumulative_borrow_rate_index, + self.borrow_accumulation_factor = mul_div_floor( + self.borrow_accumulation_factor, growth_factor, FIXED_POINT_SCALE, )?; - // Borrowers owe the full interest (the index grew for all of it); the + // Borrowers owe the full interest (the factor grew for all of it); the // protocol keeps `reserve_factor_bps` of the newly accrued interest, // and the remainder lifts the supplier exchange rate. Flooring the fee // rounds the owner's cut down, in the suppliers' favour. diff --git a/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs b/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs index 181fffac4..9d829e4aa 100644 --- a/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs +++ b/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs @@ -105,7 +105,7 @@ fn repay_reduces_debt_and_over_repay_clamps() { let (mut env, collateral, borrow, borrower, obligation) = setup(); env.try_borrow(&borrower, obligation, &[&collateral], &[], &borrow, 500_000_000) .unwrap(); - assert_eq!(env.reserve(&borrow).borrowed_amount_scaled > 0, true); + assert_eq!(env.reserve(&borrow).borrowed_principal > 0, true); env.repay(&borrower, obligation, &borrow, 200_000_000); let obligation_state = env.obligation(obligation); @@ -113,7 +113,7 @@ fn repay_reduces_debt_and_over_repay_clamps() { // Over-repay: ask to repay far more than owed; it clamps to the remaining debt. env.repay(&borrower, obligation, &borrow, 1_000_000_000); - assert_eq!(env.reserve(&borrow).borrowed_amount_scaled, 0); + assert_eq!(env.reserve(&borrow).borrowed_principal, 0); assert!(env.obligation(obligation).borrows.is_empty()); } diff --git a/finance/lending/anchor/programs/lending/tests/test_interest.rs b/finance/lending/anchor/programs/lending/tests/test_interest.rs index 34315d9e8..5d8190262 100644 --- a/finance/lending/anchor/programs/lending/tests/test_interest.rs +++ b/finance/lending/anchor/programs/lending/tests/test_interest.rs @@ -5,7 +5,7 @@ use lending::constants::FIXED_POINT_SCALE; use solana_signer::Signer; /// Borrowing at non-zero utilization, then letting slots pass, must grow the -/// reserve's interest index, the borrower's debt, and the share exchange rate. +/// reserve's accumulation factor, the borrower's debt, and the share exchange rate. #[test] fn interest_accrues_on_borrows_over_time() { let mut env = Env::new(); @@ -29,7 +29,7 @@ fn interest_accrues_on_borrows_over_time() { env.try_borrow(&borrower, obligation, &[&collateral], &[], &borrow, 500_000_000) .unwrap(); - assert_eq!(env.reserve(&borrow).cumulative_borrow_rate_index, FIXED_POINT_SCALE); + 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); @@ -37,10 +37,10 @@ fn interest_accrues_on_borrows_over_time() { env.set_price(borrow.mint, dollars(1)); env.refresh_reserve_only(&borrower, &borrow); - let index_after = env.reserve(&borrow).cumulative_borrow_rate_index; + let index_after = env.reserve(&borrow).borrow_accumulation_factor; assert!( index_after > FIXED_POINT_SCALE, - "interest index must grow once time passes with outstanding borrows" + "accumulation factor must grow once time passes with outstanding borrows" ); // The borrower now owes more than the principal. diff --git a/finance/lending/anchor/programs/lending/tests/test_reserve.rs b/finance/lending/anchor/programs/lending/tests/test_reserve.rs index 4af509ee1..a0e494acc 100644 --- a/finance/lending/anchor/programs/lending/tests/test_reserve.rs +++ b/finance/lending/anchor/programs/lending/tests/test_reserve.rs @@ -14,9 +14,9 @@ fn init_market_and_reserve() { assert_eq!(reserve.liquidity_decimals, 6); assert_eq!(reserve.available_liquidity, 0); assert_eq!(reserve.share_mint_supply, 0); - assert_eq!(reserve.borrowed_amount_scaled, 0); - // The interest index starts at 1.0. - assert_eq!(reserve.cumulative_borrow_rate_index, FIXED_POINT_SCALE); + assert_eq!(reserve.borrowed_principal, 0); + // The accumulation factor starts at 1.0. + assert_eq!(reserve.borrow_accumulation_factor, FIXED_POINT_SCALE); } #[test] diff --git a/finance/lending/anchor/programs/lending/tests/test_rounding.rs b/finance/lending/anchor/programs/lending/tests/test_rounding.rs index 55e0bd1ce..7cebb803c 100644 --- a/finance/lending/anchor/programs/lending/tests/test_rounding.rs +++ b/finance/lending/anchor/programs/lending/tests/test_rounding.rs @@ -28,7 +28,7 @@ fn deposit_that_would_mint_zero_shares_is_rejected() { // Accrue enough interest that total liquidity exceeds the share supply. env.warp_slots(7_884_000); env.refresh_reserve_only(&borrower, &borrow); - assert!(env.reserve(&borrow).cumulative_borrow_rate_index > lending::constants::FIXED_POINT_SCALE); + assert!(env.reserve(&borrow).borrow_accumulation_factor > lending::constants::FIXED_POINT_SCALE); let dust_depositor = env.create_user(); env.fund(&dust_depositor, borrow.mint, 1); diff --git a/finance/lending/kani-proofs/README.md b/finance/lending/kani-proofs/README.md index 6b7d23b8f..0aaacafc1 100644 --- a/finance/lending/kani-proofs/README.md +++ b/finance/lending/kani-proofs/README.md @@ -35,7 +35,7 @@ crossing boundary is still exercised. Two harnesses go further and make a normally-constant denominator a **parameter** so the proof can use a small one: -- the interest index uses a small symbolic `scale` instead of the real +- the accumulation factor uses a small symbolic `scale` instead of the real `FIXED_POINT_SCALE = 10^18` (the monotonicity property is scale-invariant); - the rate curve takes `full_utilization` instead of the constant `10_000` (dividing by a symbolic value near 10_000 is intractable; the in-bounds diff --git a/finance/lending/kani-proofs/src/lib.rs b/finance/lending/kani-proofs/src/lib.rs index a4cd2a113..bff811a18 100644 --- a/finance/lending/kani-proofs/src/lib.rs +++ b/finance/lending/kani-proofs/src/lib.rs @@ -5,7 +5,7 @@ //! //! The lending program is the richest of the finance examples: a Solend-style //! pool with `mul_div` floor/ceil rounding (`math.rs`), a kinked interest-rate -//! curve and a compounding interest index (`state::reserve`), a share-token +//! curve and a compounding accumulation factor (`state::reserve`), a share-token //! exchange rate (`deposit`/`redeem`), and liquidation sizing with a close //! factor and bonus (`liquidate_obligation`). All of that is pure integer //! arithmetic; the token movement is delegated to SPL CPIs that Kani cannot @@ -103,36 +103,37 @@ fn proof_rounding_is_protocol_favourable() { } // =========================================================================== -// 2. Compounding interest index (reserve::accrue_interest) +// 2. Compounding accumulation factor (reserve::accrue_interest) // =========================================================================== -/// Index update `new = floor(old * growth / scale)` where the growth factor is -/// `scale + accrued` (so always `>= scale`). Generic in `scale` because the -/// property is scale-invariant (the real code uses `FIXED_POINT_SCALE = 10^18`). -pub fn grow_index(old_index: u128, accrued: u128, scale: u128) -> Option { - let growth_factor = scale.checked_add(accrued)?; - mul_div_floor(old_index, growth_factor, scale) +/// Factor update `new = floor(old * growth / scale)` where the growth per +/// accrual is `scale + accrued` (so always `>= scale`). Generic in `scale` +/// because the property is scale-invariant (the real code uses +/// `FIXED_POINT_SCALE = 10^18`). +pub fn grow_factor(old_factor: u128, accrued: u128, scale: u128) -> Option { + let growth = scale.checked_add(accrued)?; + mul_div_floor(old_factor, growth, scale) } -/// The cumulative borrow-rate index is monotonically non-decreasing: each -/// accrual multiplies by a factor `>= 1`, so `new_index >= old_index`. A debt -/// indexed to this value can therefore never shrink from interest accrual — the +/// The borrow accumulation factor is monotonically non-decreasing: each +/// accrual multiplies by a factor `>= 1`, so `new_factor >= old_factor`. A debt +/// scaled by this value can therefore never shrink from interest accrual, the /// core guarantee that borrowers always owe at least their principal. #[cfg(kani)] #[kani::proof] #[kani::solver(cadical)] -fn proof_interest_index_monotonic() { - let old_index: u128 = kani::any(); +fn proof_accumulation_factor_monotonic() { + let old_factor: u128 = kani::any(); let accrued: u128 = kani::any(); let scale: u128 = kani::any(); // Bounded model checking with a small symbolic scale (the 10^18 real scale // is scale-invariant for this property and would be intractable). kani::assume(scale >= 1 && scale <= 127); - kani::assume(old_index <= 255); + kani::assume(old_factor <= 255); kani::assume(accrued <= 255); - let new_index = grow_index(old_index, accrued, scale).unwrap(); - assert!(new_index >= old_index); // index never decreases + let new_factor = grow_factor(old_factor, accrued, scale).unwrap(); + assert!(new_factor >= old_factor); // the factor never decreases } // =========================================================================== @@ -330,11 +331,11 @@ mod tests { } #[test] - fn index_grows() { + fn factor_grows() { // scale 100, old 150 (=1.5), accrued 10 (=0.1) -> 150*110/100 = 165. - assert_eq!(grow_index(150, 10, 100).unwrap(), 165); + assert_eq!(grow_factor(150, 10, 100).unwrap(), 165); // zero accrual leaves the index unchanged. - assert_eq!(grow_index(150, 0, 100).unwrap(), 150); + assert_eq!(grow_factor(150, 0, 100).unwrap(), 150); } #[test] diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index 99c2e2815..243574434 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -3,7 +3,7 @@ A Kamino/Solend-style borrow/lend program written with [Quasar](https://quasar-lang.com), a zero-copy, `no_std` Solana framework. It is the Quasar counterpart to the Anchor version in [`../anchor`](../anchor) and keeps the same core techniques: share-token -deposits, a kinked-curve interest index, oracle-priced obligation health, and +deposits, a kinked-curve accumulation factor, oracle-priced obligation health, and close-factor liquidation with a bonus. ## What's different from the Anchor version diff --git a/finance/lending/quasar/src/instructions/admin.rs b/finance/lending/quasar/src/instructions/admin.rs index 3b9ffb0e5..4b7b6a93c 100644 --- a/finance/lending/quasar/src/instructions/admin.rs +++ b/finance/lending/quasar/src/instructions/admin.rs @@ -151,8 +151,8 @@ impl InitializeReserve { available_liquidity: 0, share_mint_supply: 0, accumulated_protocol_fees: 0, - borrowed_amount_scaled: 0, - cumulative_borrow_rate_index: crate::constants::FIXED_POINT_SCALE, + borrowed_principal: 0, + borrow_accumulation_factor: crate::constants::FIXED_POINT_SCALE, last_update_slot: now()?, liquidity_decimals: decimals, loan_to_value_bps, diff --git a/finance/lending/quasar/src/instructions/position.rs b/finance/lending/quasar/src/instructions/position.rs index 46d94f928..2c62f3163 100644 --- a/finance/lending/quasar/src/instructions/position.rs +++ b/finance/lending/quasar/src/instructions/position.rs @@ -49,7 +49,7 @@ impl InitializeObligation { collateral_reserve: Address::default(), deposited_shares: 0, borrow_reserve: Address::default(), - borrowed_scaled: 0, + borrowed_principal: 0, bump: bumps.obligation, }); Ok(()) @@ -179,8 +179,8 @@ impl BorrowObligationLiquidity { // Borrow power from collateral value. let collateral_total = net_total_liquidity( collateral.available_liquidity, - collateral.borrowed_amount_scaled, - collateral.cumulative_borrow_rate_index, + collateral.borrowed_principal, + collateral.borrow_accumulation_factor, collateral.accumulated_protocol_fees, )?; let collateral_liquidity = mul_div_floor( @@ -198,16 +198,16 @@ impl BorrowObligationLiquidity { // Existing debt value + the new borrow, both rounded up. let borrow_price = price_scaled(&self.borrow_price, slot)?; - let existing_debt = current_debt(obligation.borrowed_scaled, borrow.cumulative_borrow_rate_index)?; + let existing_debt = current_debt(obligation.borrowed_principal, borrow.borrow_accumulation_factor)?; let existing_value = market_value(existing_debt, borrow.liquidity_decimals, borrow_price, Rounding::Up)?; let new_value = market_value(amount, borrow.liquidity_decimals, borrow_price, Rounding::Up)?; let projected = existing_value.checked_add(new_value).ok_or(LendingError::MathOverflow)?; require!(projected <= allowed, LendingError::BorrowTooLarge); require!(amount <= borrow.available_liquidity, LendingError::InsufficientLiquidity); - let scaled_added = mul_div_ceil(amount as u128, SCALE, borrow.cumulative_borrow_rate_index)?; - borrow.borrowed_amount_scaled = borrow - .borrowed_amount_scaled + let scaled_added = mul_div_ceil(amount as u128, SCALE, borrow.borrow_accumulation_factor)?; + borrow.borrowed_principal = borrow + .borrowed_principal .checked_add(scaled_added) .ok_or(LendingError::MathOverflow)?; borrow.available_liquidity = borrow @@ -215,8 +215,8 @@ impl BorrowObligationLiquidity { .checked_sub(amount) .ok_or(LendingError::MathOverflow)?; obligation.borrow_reserve = *self.borrow_reserve.address(); - obligation.borrowed_scaled = obligation - .borrowed_scaled + obligation.borrowed_principal = obligation + .borrowed_principal .checked_add(scaled_added) .ok_or(LendingError::MathOverflow)?; @@ -278,22 +278,22 @@ impl RepayObligationLiquidity { accrue(&mut borrow, slot)?; let mut obligation = snapshot_obligation(&self.obligation); - let debt = current_debt(obligation.borrowed_scaled, borrow.cumulative_borrow_rate_index)?; + let debt = current_debt(obligation.borrowed_principal, borrow.borrow_accumulation_factor)?; let repay = amount.min(debt); require!(repay > 0, LendingError::ZeroAmount); - let scaled_removed = mul_div_floor(repay as u128, SCALE, borrow.cumulative_borrow_rate_index)? - .min(obligation.borrowed_scaled); + let scaled_removed = mul_div_floor(repay as u128, SCALE, borrow.borrow_accumulation_factor)? + .min(obligation.borrowed_principal); - borrow.borrowed_amount_scaled = borrow - .borrowed_amount_scaled + borrow.borrowed_principal = borrow + .borrowed_principal .checked_sub(scaled_removed) .ok_or(LendingError::MathOverflow)?; borrow.available_liquidity = borrow .available_liquidity .checked_add(repay) .ok_or(LendingError::MathOverflow)?; - obligation.borrowed_scaled = obligation - .borrowed_scaled + obligation.borrowed_principal = obligation + .borrowed_principal .checked_sub(scaled_removed) .ok_or(LendingError::MathOverflow)?; @@ -330,7 +330,7 @@ pub struct WithdrawObligationCollateral { pub collateral_price: Account, pub share_mint: Account, /// Pass the borrow reserve + price when the obligation has debt; ignored when - /// `borrowed_scaled == 0` (nothing to value). + /// `borrowed_principal == 0` (nothing to value). pub borrow_reserve: Account, pub borrow_price: Account, #[account(mut, address = ObligationVaultPda::seeds(collateral_reserve.address(), obligation.address()))] @@ -366,8 +366,8 @@ impl WithdrawObligationCollateral { let remaining_shares = obligation.deposited_shares - shares; let collateral_total = net_total_liquidity( collateral.available_liquidity, - collateral.borrowed_amount_scaled, - collateral.cumulative_borrow_rate_index, + collateral.borrowed_principal, + collateral.borrow_accumulation_factor, collateral.accumulated_protocol_fees, )?; let remaining_liquidity = mul_div_floor( @@ -384,7 +384,7 @@ impl WithdrawObligationCollateral { let allowed = mul_div_floor(remaining_value, collateral.loan_to_value_bps as u128, BPS_DENOMINATOR)?; // Debt value (zero when the obligation has no borrow). - let debt_value = if obligation.borrowed_scaled > 0 { + let debt_value = if obligation.borrowed_principal > 0 { require_keys_eq!( obligation.borrow_reserve, *self.borrow_reserve.address(), @@ -397,7 +397,7 @@ impl WithdrawObligationCollateral { ); let mut borrow = snapshot_reserve(&self.borrow_reserve); accrue(&mut borrow, slot)?; - let debt = current_debt(obligation.borrowed_scaled, borrow.cumulative_borrow_rate_index)?; + let debt = current_debt(obligation.borrowed_principal, borrow.borrow_accumulation_factor)?; market_value(debt, borrow.liquidity_decimals, price_scaled(&self.borrow_price, slot)?, Rounding::Up)? } else { 0 @@ -480,8 +480,8 @@ impl LiquidateObligation { // Health: unhealthy when debt value exceeds collateral value * liquidation threshold. let collateral_total = net_total_liquidity( collateral.available_liquidity, - collateral.borrowed_amount_scaled, - collateral.cumulative_borrow_rate_index, + collateral.borrowed_principal, + collateral.borrow_accumulation_factor, collateral.accumulated_protocol_fees, )?; let collateral_liquidity = mul_div_floor( @@ -496,7 +496,7 @@ impl LiquidateObligation { Rounding::Down, )?; let unhealthy_threshold = mul_div_floor(collateral_value, collateral.liquidation_threshold_bps as u128, BPS_DENOMINATOR)?; - let debt = current_debt(obligation.borrowed_scaled, borrow.cumulative_borrow_rate_index)?; + let debt = current_debt(obligation.borrowed_principal, borrow.borrow_accumulation_factor)?; let debt_value = market_value(debt, borrow.liquidity_decimals, borrow_price, Rounding::Up)?; require!(debt_value > unhealthy_threshold, LendingError::ObligationHealthy); @@ -525,12 +525,12 @@ impl LiquidateObligation { LendingError::LiquidationTooLarge ); - let scaled_removed = mul_div_floor(repay as u128, SCALE, borrow.cumulative_borrow_rate_index)? - .min(obligation.borrowed_scaled); + let scaled_removed = mul_div_floor(repay as u128, SCALE, borrow.borrow_accumulation_factor)? + .min(obligation.borrowed_principal); - borrow.borrowed_amount_scaled = borrow.borrowed_amount_scaled.checked_sub(scaled_removed).ok_or(LendingError::MathOverflow)?; + borrow.borrowed_principal = borrow.borrowed_principal.checked_sub(scaled_removed).ok_or(LendingError::MathOverflow)?; borrow.available_liquidity = borrow.available_liquidity.checked_add(repay).ok_or(LendingError::MathOverflow)?; - obligation.borrowed_scaled = obligation.borrowed_scaled.checked_sub(scaled_removed).ok_or(LendingError::MathOverflow)?; + obligation.borrowed_principal = obligation.borrowed_principal.checked_sub(scaled_removed).ok_or(LendingError::MathOverflow)?; obligation.deposited_shares = obligation.deposited_shares.checked_sub(seize_shares).ok_or(LendingError::MathOverflow)?; let share_decimals = self.share_mint.decimals; diff --git a/finance/lending/quasar/src/instructions/supply.rs b/finance/lending/quasar/src/instructions/supply.rs index 477f15faf..029883e5a 100644 --- a/finance/lending/quasar/src/instructions/supply.rs +++ b/finance/lending/quasar/src/instructions/supply.rs @@ -55,8 +55,8 @@ impl DepositReserveLiquidity { let total = net_total_liquidity( reserve.available_liquidity, - reserve.borrowed_amount_scaled, - reserve.cumulative_borrow_rate_index, + reserve.borrowed_principal, + reserve.borrow_accumulation_factor, reserve.accumulated_protocol_fees, )?; let shares = if reserve.share_mint_supply == 0 { @@ -134,8 +134,8 @@ impl RedeemReserveCollateral { let total = net_total_liquidity( reserve.available_liquidity, - reserve.borrowed_amount_scaled, - reserve.cumulative_borrow_rate_index, + reserve.borrowed_principal, + reserve.borrow_accumulation_factor, reserve.accumulated_protocol_fees, )?; let liquidity = mul_div_floor(shares as u128, total, reserve.share_mint_supply as u128)?; diff --git a/finance/lending/quasar/src/logic.rs b/finance/lending/quasar/src/logic.rs index 9ac2cfecf..5f7c820fb 100644 --- a/finance/lending/quasar/src/logic.rs +++ b/finance/lending/quasar/src/logic.rs @@ -9,7 +9,7 @@ use crate::{ constants::{FIXED_POINT_SCALE, MAX_PRICE_STALENESS_SLOTS}, error::LendingError, last_restart::LastRestartSlot, - math::{accrue_index, current_debt, mul_div_floor, price_mantissa_to_scaled}, + math::{accrue_factor, current_debt, mul_div_floor, price_mantissa_to_scaled}, state::{Obligation, ObligationInner, PriceFeed, Reserve, ReserveInner}, }; @@ -31,8 +31,8 @@ pub fn snapshot_reserve(reserve: &Account) -> ReserveInner { available_liquidity: u64::from(reserve.available_liquidity), share_mint_supply: u64::from(reserve.share_mint_supply), accumulated_protocol_fees: u64::from(reserve.accumulated_protocol_fees), - borrowed_amount_scaled: u128::from(reserve.borrowed_amount_scaled), - cumulative_borrow_rate_index: u128::from(reserve.cumulative_borrow_rate_index), + 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), liquidity_decimals: reserve.liquidity_decimals, loan_to_value_bps: u16::from(reserve.loan_to_value_bps), @@ -56,21 +56,21 @@ pub fn snapshot_obligation(obligation: &Account) -> ObligationInner collateral_reserve: obligation.collateral_reserve, deposited_shares: u64::from(obligation.deposited_shares), borrow_reserve: obligation.borrow_reserve, - borrowed_scaled: u128::from(obligation.borrowed_scaled), + borrowed_principal: u128::from(obligation.borrowed_principal), bump: obligation.bump, } } -/// Advance a reserve snapshot's interest index to `slot` (Solend-style: a single -/// `index *= 1 + rate_per_slot * elapsed` per call, compounding across calls). +/// 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> { let borrowed_before = current_debt( - reserve.borrowed_amount_scaled, - reserve.cumulative_borrow_rate_index, + reserve.borrowed_principal, + reserve.borrow_accumulation_factor, )?; - reserve.cumulative_borrow_rate_index = accrue_index( - reserve.cumulative_borrow_rate_index, - reserve.borrowed_amount_scaled, + reserve.borrow_accumulation_factor = accrue_factor( + reserve.borrow_accumulation_factor, + reserve.borrowed_principal, reserve.available_liquidity, reserve.last_update_slot, slot, @@ -82,8 +82,8 @@ pub fn accrue(reserve: &mut ReserveInner, slot: u64) -> Result<(), ProgramError> // 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. let borrowed_after = current_debt( - reserve.borrowed_amount_scaled, - reserve.cumulative_borrow_rate_index, + reserve.borrowed_principal, + reserve.borrow_accumulation_factor, )?; let interest = borrowed_after.saturating_sub(borrowed_before); let fee = mul_div_floor( diff --git a/finance/lending/quasar/src/math.rs b/finance/lending/quasar/src/math.rs index bd339211d..4abeec540 100644 --- a/finance/lending/quasar/src/math.rs +++ b/finance/lending/quasar/src/math.rs @@ -86,8 +86,8 @@ pub fn value_to_amount( // --- reserve interest / share helpers (free functions over reserve fields) --- /// Live total debt owed to the pool, rounded up (protocol-favourable). -pub fn current_debt(borrowed_scaled: u128, index: u128) -> Result { - let debt = mul_div_ceil(borrowed_scaled, index, FIXED_POINT_SCALE)?; +pub fn current_debt(borrowed_principal: u128, factor: u128) -> Result { + let debt = mul_div_ceil(borrowed_principal, factor, FIXED_POINT_SCALE)?; u64::try_from(debt).map_err(|_| LendingError::MathOverflow.into()) } @@ -95,11 +95,11 @@ pub fn current_debt(borrowed_scaled: u128, index: u128) -> Result Result { (available as u128) - .checked_add(current_debt(borrowed_scaled, index)? as u128) + .checked_add(current_debt(borrowed_principal, factor)? as u128) .ok_or(LendingError::MathOverflow.into()) } @@ -107,11 +107,11 @@ pub fn total_liquidity( /// owed to the owner, which belong to no supplier. pub fn net_total_liquidity( available: u64, - borrowed_scaled: u128, - index: u128, + borrowed_principal: u128, + factor: u128, protocol_fees: u64, ) -> Result { - total_liquidity(available, borrowed_scaled, index)? + total_liquidity(available, borrowed_principal, factor)? .checked_sub(protocol_fees as u128) .ok_or(LendingError::MathOverflow.into()) } @@ -119,14 +119,14 @@ pub fn net_total_liquidity( /// Borrowed fraction of the pool in basis points (0..=10_000). pub fn utilization_bps( available: u64, - borrowed_scaled: u128, - index: u128, + borrowed_principal: u128, + factor: u128, ) -> Result { - let total = total_liquidity(available, borrowed_scaled, index)?; + let total = total_liquidity(available, borrowed_principal, factor)?; if total == 0 { return Ok(0); } - mul_div_floor(current_debt(borrowed_scaled, index)? as u128, BPS_DENOMINATOR, total) + mul_div_floor(current_debt(borrowed_principal, factor)? as u128, BPS_DENOMINATOR, total) } /// Per-slot borrow rate (FIXED_POINT_SCALE-scaled) from the kinked curve. @@ -166,12 +166,12 @@ pub fn borrow_rate_per_slot( mul_div_floor(apr_bps, FIXED_POINT_SCALE, denominator) } -/// Advance the interest index for elapsed slots: -/// `new_index = index * (1 + rate_per_slot * elapsed)`. +/// Advance the accumulation factor for elapsed slots: +/// `new_factor = factor * (1 + rate_per_slot * elapsed)`. #[allow(clippy::too_many_arguments)] -pub fn accrue_index( - index: u128, - borrowed_scaled: u128, +pub fn accrue_factor( + factor: u128, + borrowed_principal: u128, available: u64, last_update_slot: u64, now: u64, @@ -183,10 +183,10 @@ pub fn accrue_index( let elapsed = now .checked_sub(last_update_slot) .ok_or(LendingError::MathOverflow)?; - if elapsed == 0 || borrowed_scaled == 0 { - return Ok(index); + if elapsed == 0 || borrowed_principal == 0 { + return Ok(factor); } - let utilization = utilization_bps(available, borrowed_scaled, index)?; + let utilization = utilization_bps(available, borrowed_principal, factor)?; let rate = borrow_rate_per_slot( utilization, optimal_utilization_bps, @@ -197,7 +197,7 @@ pub fn accrue_index( let growth = FIXED_POINT_SCALE .checked_add(rate.checked_mul(elapsed as u128).ok_or(LendingError::MathOverflow)?) .ok_or(LendingError::MathOverflow)?; - mul_div_floor(index, growth, FIXED_POINT_SCALE) + mul_div_floor(factor, growth, FIXED_POINT_SCALE) } #[allow(clippy::too_many_arguments)] diff --git a/finance/lending/quasar/src/state.rs b/finance/lending/quasar/src/state.rs index 2c2b82f8f..c6a70f0c1 100644 --- a/finance/lending/quasar/src/state.rs +++ b/finance/lending/quasar/src/state.rs @@ -34,8 +34,8 @@ pub struct Reserve { /// interest, carved out of total liquidity and withdrawn via /// `collect_protocol_fees`. pub accumulated_protocol_fees: u64, - pub borrowed_amount_scaled: u128, - pub cumulative_borrow_rate_index: u128, + pub borrowed_principal: u128, + pub borrow_accumulation_factor: u128, pub last_update_slot: u64, pub liquidity_decimals: u8, pub loan_to_value_bps: u16, @@ -61,7 +61,7 @@ pub struct Obligation { pub collateral_reserve: Address, pub deposited_shares: u64, pub borrow_reserve: Address, - pub borrowed_scaled: u128, + pub borrowed_principal: u128, pub bump: u8, } diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index e8d2d663b..e861bc76e 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -345,7 +345,7 @@ 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 interest index). quasar-test has no slot +/// (`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.