From cf82a1e3dc27b4ef611e8bb8339d6991cbbcbf7b Mon Sep 17 00:00:00 2001 From: Esther Date: Sun, 18 Oct 2026 18:32:13 +0100 Subject: [PATCH] feat(SC-007): 75% LTV cap, 8% liquidation discount, HF>=1.0 rejection (#1628) - Add contracts/lending_pool/src/interest.rs (spec key file): pure next_index / accrued_principal index math with unit tests; accrue and accrue_user now delegate to it - Add contracts/lending_pool/src/liquidation.rs (spec key file): pure seize_amount discount-bonus math with unit tests; liquidate delegates to it - AC1: borrow-at-exactly-75% allowed and 75.01% rejected (#6) under a pool configured with a hard 75% LTV cap (coll factor 7_500, HF boundary 10_000) - AC2: third-party liquidation seizes exactly repay x 1.08 with the 8% bonus (liq_bonus 800), asserted on a real SAC token for observable balances - AC3: liquidation rejected (#8) at health factor exactly 1.0 and when HF > 1 - Wire previously dead contracts/lending_pool/src/test.rs (fixes its broken LOCK reference to use the temporary RG_KEY guard), enabling 18 more tests --- contracts/lending_pool/src/interest.rs | 86 +++++++++++ contracts/lending_pool/src/lib.rs | 180 ++++++++++++++++++---- contracts/lending_pool/src/liquidation.rs | 68 ++++++++ contracts/lending_pool/src/test.rs | 16 +- 4 files changed, 309 insertions(+), 41 deletions(-) create mode 100644 contracts/lending_pool/src/interest.rs create mode 100644 contracts/lending_pool/src/liquidation.rs diff --git a/contracts/lending_pool/src/interest.rs b/contracts/lending_pool/src/interest.rs new file mode 100644 index 000000000..0429b5101 --- /dev/null +++ b/contracts/lending_pool/src/interest.rs @@ -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 { + 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 { + 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); + } +} diff --git a/contracts/lending_pool/src/lib.rs b/contracts/lending_pool/src/lib.rs index da99bb431..8af6d9f4a 100644 --- a/contracts/lending_pool/src/lib.rs +++ b/contracts/lending_pool/src/lib.rs @@ -42,6 +42,8 @@ use soroban_sdk::{ Address, Env, IntoVal, Symbol, }; +pub mod interest; +pub mod liquidation; pub mod math; // ── Constants ───────────────────────────────────────────────────────────────── @@ -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()); @@ -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() @@ -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); @@ -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! { @@ -894,3 +1003,6 @@ mod tests { } } } + +#[cfg(test)] +mod test; diff --git a/contracts/lending_pool/src/liquidation.rs b/contracts/lending_pool/src/liquidation.rs new file mode 100644 index 000000000..77564d3a9 --- /dev/null +++ b/contracts/lending_pool/src/liquidation.rs @@ -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 { + 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); + } +} diff --git a/contracts/lending_pool/src/test.rs b/contracts/lending_pool/src/test.rs index 9b83ecdc5..1f6e5e8c4 100644 --- a/contracts/lending_pool/src/test.rs +++ b/contracts/lending_pool/src/test.rs @@ -1,4 +1,5 @@ use super::*; +use soroban_sdk::testutils::Ledger as _; use soroban_sdk::{testutils::Address as _, Env}; /// Minimal mock oracle: always returns SCALE for any token. @@ -112,14 +113,13 @@ fn reentrancy_guard_blocks_double_entry() { let token = add_token(&env, &client); let user = Address::generate(&env); - // Now simulate reentrant state by directly setting LOCK on the contract instance + // Simulate a reentrant state by pre-setting the temporary guard key. env.as_contract(&client.address, || { - env.storage().instance().set(&LOCK, &true); + env.storage().temporary().set(&RG_KEY, &true); }); - // This deposit should be rejected because LOCK is already true + // This deposit should be rejected because the guard key exists. client.deposit_collateral(&user, &token, &1_000); - // Should panic before reaching assert } #[test] @@ -196,7 +196,10 @@ fn liquidate_reduces_borrower_debt_and_collateral() { client.liquidate(&liquidator, &borrower, &token, &token, &1_000); let debt_after = client.debt_of(&borrower, &token); - assert!(debt_after < debt_before, "debt must decrease after liquidation"); + assert!( + debt_after < debt_before, + "debt must decrease after liquidation" + ); // collateral of borrower should also decrease (seized to liquidator) let coll_after = client.collateral_of(&borrower, &token); assert!( @@ -268,8 +271,7 @@ fn interest_accrual_increases_debt_over_time() { assert_eq!(debt_snapshot, 10_000); // Advance by one year (31_536_000 seconds) - env.ledger() - .with_mut(|l| l.timestamp += 31_536_000u64); + env.ledger().with_mut(|l| l.timestamp += 31_536_000u64); // Trigger accrual by calling repay with 0 to nudge accrue_user — // instead call debt_of after accruing via borrow (re-use a borrow that