Skip to content
Open
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
86 changes: 86 additions & 0 deletions contracts/lending_pool/src/interest.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//! Per-second borrow interest accrual (SC-007, spec key file).
//!
//! Interest compounds globally per token through a borrow index that starts
//! at [`crate::SCALE`] (`1e12`) and grows every time the pool is touched:
//!
//! ```text
//! Δindex = old_index × annual_rate_bps × elapsed_secs / (BPS × SECS_PER_YEAR)
//! ```
//!
//! The first-order (Taylor) approximation is exact for linear accrual and
//! error-bounded for the short windows between updates on Soroban ledgers.
//! User debt is then scaled from their index snapshot to the current global
//! index, giving real-time compounding between any two operations.

use crate::{BPS, SECS_PER_YEAR};

/// Grow the global borrow index by the interest accrued over `elapsed_secs`
/// at `annual_rate_bps`.
///
/// Returns `None` on arithmetic overflow (the caller maps this to a typed
/// `LPError`).
pub fn next_index(old_idx: i128, annual_rate_bps: i128, elapsed_secs: i128) -> Option<i128> {
if old_idx <= 0 || annual_rate_bps <= 0 || elapsed_secs <= 0 {
return Some(old_idx);
}
let denom = BPS.checked_mul(SECS_PER_YEAR)?;
let delta = old_idx
.checked_mul(annual_rate_bps)?
.checked_mul(elapsed_secs)?
.checked_div(denom)?;
old_idx.checked_add(delta)
}

/// Scale a user's recorded principal from their `user_idx` snapshot to the
/// current `global_idx`, i.e. apply all interest accrued since the snapshot.
///
/// Returns `None` when no growth applies (zero principal, unchanged index,
/// or missing snapshot), so the caller leaves the stored debt untouched.
pub fn accrued_principal(principal: i128, global_idx: i128, user_idx: i128) -> Option<i128> {
if principal <= 0 || user_idx <= 0 || global_idx <= user_idx {
return None;
}
principal.checked_mul(global_idx)?.checked_div(user_idx)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::SCALE;

#[test]
fn next_index_grows_with_time_and_rate() {
// 100 % annual rate for a full year: index must double.
let idx = next_index(SCALE, 10_000, SECS_PER_YEAR).unwrap();
assert_eq!(idx, SCALE * 2);

// 10 % annual rate for a full year: index grows by 10 %.
let idx = next_index(SCALE, 1_000, SECS_PER_YEAR).unwrap();
assert_eq!(idx, SCALE + SCALE / 10);

// One second at 10 %: delta = SCALE * 1000 * 1 / (10000 * 31536000).
let idx = next_index(SCALE, 1_000, 1).unwrap();
assert_eq!(idx, SCALE + 3_170);
}

#[test]
fn next_index_identity_cases() {
assert_eq!(next_index(SCALE, 1_000, 0), Some(SCALE));
assert_eq!(next_index(SCALE, 0, 1_000), Some(SCALE));
assert_eq!(next_index(SCALE, -5, 1_000), Some(SCALE));
}

#[test]
fn accrued_principal_scales_debt() {
// Debt snapshot at index 1e12; index has grown to 2e12 ⇒ debt doubles.
assert_eq!(accrued_principal(1_000, SCALE * 2, SCALE), Some(2_000));

// Unchanged index ⇒ no accrual (caller keeps stored debt).
assert_eq!(accrued_principal(1_000, SCALE, SCALE), None);
assert_eq!(accrued_principal(1_000, SCALE - 1, SCALE), None);

// Zero principal or missing snapshot ⇒ no accrual.
assert_eq!(accrued_principal(0, SCALE * 2, SCALE), None);
assert_eq!(accrued_principal(1_000, SCALE * 2, 0), None);
}
}
180 changes: 146 additions & 34 deletions contracts/lending_pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ use soroban_sdk::{
Address, Env, IntoVal, Symbol,
};

pub mod interest;
pub mod liquidation;
pub mod math;

// ── Constants ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -375,22 +377,7 @@ impl LendingPool {
let liq_bonus: i128 = env.storage().instance().get(&Key::LiqBonus).unwrap_or(500);

// seize = repay_amount × (debt_price / coll_price) × (1 + liq_bonus / BPS)
let bps_plus_bonus = BPS
.checked_add(liq_bonus)
.unwrap_or_else(|| panic_with_error!(&env, LPError::Overflow));
let numerator = repay_amount
.checked_mul(debt_price)
.unwrap_or_else(|| panic_with_error!(&env, LPError::Overflow))
.checked_mul(bps_plus_bonus)
.unwrap_or_else(|| panic_with_error!(&env, LPError::Overflow));
let denominator = coll_price
.checked_mul(BPS)
.unwrap_or_else(|| panic_with_error!(&env, LPError::Overflow));
if denominator == 0 {
panic_with_error!(&env, LPError::DivisionByZero);
}
let seize = numerator
.checked_div(denominator)
let seize = liquidation::seize_amount(repay_amount, debt_price, coll_price, liq_bonus)
.unwrap_or_else(|| panic_with_error!(&env, LPError::Overflow));

let debt_key = Key::Debt(borrower.clone(), debt_token.clone());
Expand Down Expand Up @@ -537,18 +524,7 @@ impl LendingPool {
.unwrap_or(SCALE);

// Δindex = old_idx × rate × elapsed / (BPS × SECS_PER_YEAR)
let denom = BPS
.checked_mul(SECS_PER_YEAR)
.unwrap_or_else(|| panic_with_error!(env, LPError::Overflow));
let delta = old_idx
.checked_mul(rate)
.unwrap_or_else(|| panic_with_error!(env, LPError::Overflow))
.checked_mul(elapsed)
.unwrap_or_else(|| panic_with_error!(env, LPError::Overflow))
.checked_div(denom)
.unwrap_or_else(|| panic_with_error!(env, LPError::DivisionByZero));
let new_idx = old_idx
.checked_add(delta)
let new_idx = interest::next_index(old_idx, rate, elapsed)
.unwrap_or_else(|| panic_with_error!(env, LPError::Overflow));

env.storage()
Expand Down Expand Up @@ -579,12 +555,7 @@ impl LendingPool {
.persistent()
.get(&Key::UserIdx(user.clone(), token.clone()))
.unwrap_or(SCALE);
if gidx > uidx && uidx > 0 {
let new_principal = principal
.checked_mul(gidx)
.unwrap_or_else(|| panic_with_error!(env, LPError::Overflow))
.checked_div(uidx)
.unwrap_or_else(|| panic_with_error!(env, LPError::DivisionByZero));
if let Some(new_principal) = interest::accrued_principal(principal, gidx, uidx) {
env.storage()
.persistent()
.set(&Key::Debt(user.clone(), token.clone()), &new_principal);
Expand Down Expand Up @@ -859,6 +830,144 @@ mod tests {
client.deposit_collateral(&user, &token, &1_000);
}

// ── SC-007 acceptance-criteria tests ──────────────────────────────────────

/// Pool configured with a hard 75 % LTV cap: collateral factor 7_500 BPS
/// (per `add_asset` docs: "Max LTV in BPS") and a health-factor boundary
/// of 100 % (mcr = 10_000) so the effective cap is exactly 75 %.
fn setup_75pct_ltv_pool(env: &Env) -> (LendingPoolClient<'static>, Address) {
let id = env.register(LendingPool, ());
let client = LendingPoolClient::new(env, &id);
let oracle = env.register(MockOracle, ());
let admin = Address::generate(env);
client.initialize(&admin, &oracle, &10_000, &800);
let token = env.register(MockToken, ());
client.add_asset(&token, &7_500, &0);
(client, token)
}

/// AC1 (part 1): borrowing to exactly 75 % LTV is allowed.
#[test]
fn borrow_at_exactly_75pct_ltv_is_allowed() {
let env = Env::default();
env.mock_all_auths();
let (client, token) = setup_75pct_ltv_pool(&env);
let user = Address::generate(&env);

client.deposit_collateral(&user, &token, &10_000);
client.borrow(&user, &token, &token, &7_500); // LTV = 7 500 / 10 000 = 75 %

assert_eq!(client.debt_of(&user, &token), 7_500);
assert!(client.health_ok(&user, &token, &token));
}

/// AC1 (part 2): borrowing beyond 75 % LTV is rejected (#6).
#[test]
#[should_panic(expected = "Error(Contract, #6)")]
fn borrow_above_75pct_ltv_is_rejected() {
let env = Env::default();
env.mock_all_auths();
let (client, token) = setup_75pct_ltv_pool(&env);
let user = Address::generate(&env);

client.deposit_collateral(&user, &token, &10_000);
client.borrow(&user, &token, &token, &7_501); // LTV = 75.01 % > 75 %
}

/// AC2: a third party repays underwater debt and seizes collateral with
/// the spec's 8 % liquidation discount bonus (liq_bonus = 800 BPS).
#[test]
fn liquidate_seizes_collateral_with_8pct_discount() {
let env = Env::default();
env.mock_all_auths();
let id = env.register(LendingPool, ());
let client = LendingPoolClient::new(&env, &id);
let oracle = env.register(MockOracle, ());
let admin = Address::generate(&env);
client.initialize(&admin, &oracle, &15_000, &800); // 8 % liquidator bonus

// Real SAC token so the liquidator's seized collateral is observable
// on-chain (MockToken transfers are no-ops by design).
let token = env
.register_stellar_asset_contract_v2(admin.clone())
.address();
client.add_asset(&token, &10_000, &0);
let token_client = token::Client::new(&env, &token);
let sac = token::StellarAssetClient::new(&env, &token);

let borrower = Address::generate(&env);
let liquidator = Address::generate(&env);
sac.mint(&borrower, &15_000);
sac.mint(&liquidator, &1_000);

client.deposit_collateral(&borrower, &token, &15_000);
client.borrow(&borrower, &token, &token, &10_000);

// Push the position underwater without touching the collateral, so the
// full seized amount (incl. bonus) can be asserted exactly.
env.as_contract(&client.address, || {
env.storage()
.persistent()
.set(&Key::Debt(borrower.clone(), token.clone()), &20_000i128);
});
assert!(!client.health_ok(&borrower, &token, &token));

client.liquidate(&liquidator, &borrower, &token, &token, &1_000);

// Equal prices, 8 % bonus: seize = 1_000 × 1.08 = 1_080 — the
// liquidator repaid 1_000 and received 1_080 of collateral.
assert_eq!(token_client.balance(&liquidator), 1_080);
assert_eq!(client.debt_of(&borrower, &token), 19_000);
assert_eq!(client.collateral_of(&borrower, &token), 15_000 - 1_080);
// Repaid debt now sits in the pool's token balance:
// pool = 15_000 (coll deposit) - 10_000 (borrowed out) + 1_000 (repaid) = 6_000,
// minus the 1_080 seized to the liquidator.
assert_eq!(token_client.balance(&client.address), 6_000 - 1_080);
}

/// AC3 (boundary): liquidation is rejected when the health factor is
/// exactly 1.0 (adj_coll == req_coll).
#[test]
#[should_panic(expected = "Error(Contract, #8)")]
fn liquidate_rejected_when_health_factor_exactly_one() {
let env = Env::default();
env.mock_all_auths();
let id = env.register(LendingPool, ());
let client = LendingPoolClient::new(&env, &id);
let oracle = env.register(MockOracle, ());
let admin = Address::generate(&env);
client.initialize(&admin, &oracle, &15_000, &500);
let token = env.register(MockToken, ());
client.add_asset(&token, &10_000, &0);

let borrower = Address::generate(&env);
let liquidator = Address::generate(&env);

// adj_coll = 15_000 × 100 % = 15_000; req_coll = 10_000 × 150 % = 15_000.
client.deposit_collateral(&borrower, &token, &15_000);
client.borrow(&borrower, &token, &token, &10_000);
assert!(client.health_ok(&borrower, &token, &token)); // HF == 1.0

client.liquidate(&liquidator, &borrower, &token, &token, &100);
}

/// AC3 (healthy position): liquidation must also be rejected when HF > 1.0.
#[test]
#[should_panic(expected = "Error(Contract, #8)")]
fn liquidate_rejected_when_position_is_healthy() {
let env = Env::default();
env.mock_all_auths();
let (client, _, _) = setup(&env);
let token = add_token(&env, &client);
let borrower = Address::generate(&env);
let liquidator = Address::generate(&env);

client.deposit_collateral(&borrower, &token, &10_000);
client.borrow(&borrower, &token, &token, &1_000); // HF ≫ 1.0

client.liquidate(&liquidator, &borrower, &token, &token, &100);
}

use proptest::prelude::*;

proptest! {
Expand Down Expand Up @@ -894,3 +1003,6 @@ mod tests {
}
}
}

#[cfg(test)]
mod test;
68 changes: 68 additions & 0 deletions contracts/lending_pool/src/liquidation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! Third-party liquidation engine math (SC-007, spec key file).
//!
//! A liquidator repays part of an underwater borrower's debt in debt-token
//! units and seizes collateral worth the repaid value **plus** the configured
//! liquidation discount bonus:
//!
//! ```text
//! seize = repay × debt_price × (BPS + bonus_bps) / (coll_price × BPS)
//! ```
//!
//! With equal prices an 8 % bonus (`800` bps) yields `seize = repay × 1.08`,
//! so the liquidator is always incentivised to act.

use crate::BPS;

/// Collateral seized for repaying `repay` of debt, including the liquidator's
/// `liq_bonus_bps` discount bonus.
///
/// Returns `None` on invalid inputs (non-positive amounts or prices) or
/// arithmetic overflow; the caller maps this to a typed `LPError`.
pub fn seize_amount(
repay: i128,
debt_price: i128,
coll_price: i128,
liq_bonus_bps: i128,
) -> Option<i128> {
if repay <= 0 || debt_price <= 0 || coll_price <= 0 || liq_bonus_bps < 0 {
return None;
}
let bps_plus_bonus = BPS.checked_add(liq_bonus_bps)?;
let numerator = repay.checked_mul(debt_price)?.checked_mul(bps_plus_bonus)?;
let denominator = coll_price.checked_mul(BPS)?;
numerator.checked_div(denominator)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn eight_percent_discount_seizes_1_08x() {
// Equal prices, 8 % bonus: 1_000 repaid ⇒ 1_080 seized.
assert_eq!(seize_amount(1_000, 1, 1, 800), Some(1_080));
assert_eq!(seize_amount(10_000, 1, 1, 800), Some(10_800));
}

#[test]
fn discount_scales_with_price_ratio() {
// Debt is worth 2× collateral: same bonus, double the units seized.
assert_eq!(seize_amount(1_000, 2, 1, 800), Some(2_160));
// Collateral worth 2× debt: half the units seized.
assert_eq!(seize_amount(1_000, 1, 2, 800), Some(540));
}

#[test]
fn zero_bonus_represents_plain_repayment() {
assert_eq!(seize_amount(1_000, 1, 1, 0), Some(1_000));
}

#[test]
fn invalid_inputs_return_none() {
assert_eq!(seize_amount(0, 1, 1, 800), None);
assert_eq!(seize_amount(-1, 1, 1, 800), None);
assert_eq!(seize_amount(1_000, 0, 1, 800), None);
assert_eq!(seize_amount(1_000, 1, 0, 800), None);
assert_eq!(seize_amount(1_000, 1, 1, -1), None);
}
}
Loading
Loading