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
4 changes: 2 additions & 2 deletions finance/lending/anchor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ pub fn handle_initialize_reserve(context: Context<InitializeReserve>, 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ pub fn handle_refresh_obligation(context: Context<RefreshObligation>) -> 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)?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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)
Expand Down
26 changes: 13 additions & 13 deletions finance/lending/anchor/programs/lending/src/state/reserve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -138,8 +138,8 @@ impl Reserve {
/// Live total debt owed to the pool, rounded up (protocol-favourable).
pub fn current_borrowed_amount(&self) -> Result<u64> {
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())
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,15 @@ 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);
assert_eq!(obligation_state.borrows.len(), 1);

// 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());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -29,18 +29,18 @@ 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);
env.set_price(collateral.mint, dollars(1));
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.
Expand Down
6 changes: 3 additions & 3 deletions finance/lending/anchor/programs/lending/tests/test_reserve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion finance/lending/kani-proofs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 20 additions & 19 deletions finance/lending/kani-proofs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<u128> {
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<u128> {
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
}

// ===========================================================================
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading