diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index 2891f81d..50e07f7f 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 0ebd91f6..96478950 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 ebd1b411..723a29d1 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 ee9668fd..bb24f136 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 2333dad9..21460be5 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 ecf671f9..d71a4217 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 af161e36..20be5b92 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 9f13037f..c6648c95 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 181fffac..9d829e4a 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 34315d9e..5d819026 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 4af509ee..a0e494ac 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 55e0bd1c..7cebb803 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 6b7d23b8..0aaacafc 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 a4cd2a11..bff811a1 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 99c2e281..24357443 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 3b9ffb0e..4b7b6a93 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 46d94f92..2c62f316 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 477f15fa..029883e5 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 9ac2cfec..5f7c820f 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 bd339211..4abeec54 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 2c2b82f8..c6a70f0c 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 e8d2d663..e861bc76 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.