diff --git a/stellar-lend/benchmarks/src/framework.rs b/stellar-lend/benchmarks/src/framework.rs index c55a4775..54f75ad5 100644 --- a/stellar-lend/benchmarks/src/framework.rs +++ b/stellar-lend/benchmarks/src/framework.rs @@ -73,6 +73,10 @@ impl RunConfig { m.insert("lending::get_user_position".into(), 400_000); m.insert("lending::set_oracle".into(), 300_000); m.insert("lending::set_pause".into(), 200_000); + m.insert("lending::interest_rate_model_linear".into(), 300_000); + m.insert("lending::interest_rate_model_kink".into(), 300_000); + m.insert("lending::interest_rate_model_jump".into(), 300_000); + m.insert("lending::interest_rate_model_exponential".into(), 300_000); // Hello-world (core lending) contract m.insert("hello_world::initialize".into(), 500_000); m.insert("hello_world::deposit_collateral".into(), 900_000); diff --git a/stellar-lend/benchmarks/src/lending_benchmarks.rs b/stellar-lend/benchmarks/src/lending_benchmarks.rs index 0f24ea2f..4d885d28 100644 --- a/stellar-lend/benchmarks/src/lending_benchmarks.rs +++ b/stellar-lend/benchmarks/src/lending_benchmarks.rs @@ -12,7 +12,10 @@ use crate::framework::{ fresh_env, get_budget, measure_instructions, BenchmarkResult, BenchmarkSuite, RunConfig, }; use soroban_sdk::{contract, contractimpl, testutils::Address as _, token, Address, Bytes, Env}; -use stellarlend_lending::{LendingContract, LendingContractClient, PauseType}; +use stellarlend_lending::{ + InterestRateConfigUpdate, InterestRateModelKind, LendingContract, LendingContractClient, + PauseType, +}; const CONTRACT: &str = "lending"; @@ -63,6 +66,10 @@ fn run_all(config: &RunConfig) -> Vec { bench_set_pause(config), bench_set_flash_loan_fee(config), bench_set_liquidation_threshold(config), + bench_update_interest_rate_model_linear(config), + bench_update_interest_rate_model_kink(config), + bench_update_interest_rate_model_jump(config), + bench_update_interest_rate_model_exponential(config), bench_deposit_multiple_assets_storage(config), ] } @@ -151,6 +158,19 @@ fn bench_initialize_deposit_settings(config: &RunConfig) -> BenchmarkResult { ) } +fn interest_model_update(model: InterestRateModelKind) -> InterestRateConfigUpdate { + InterestRateConfigUpdate { + model: Some(model as u32), + base_rate_bps: None, + kink_utilization_bps: None, + slope_bps: None, + jump_slope_bps: None, + rate_floor_bps: None, + rate_ceiling_bps: None, + spread_bps: None, + } +} + // ─── Deposit ────────────────────────────────────────────────────────────────── fn bench_deposit_cold(config: &RunConfig) -> BenchmarkResult { @@ -631,6 +651,69 @@ fn bench_set_liquidation_threshold(config: &RunConfig) -> BenchmarkResult { // ─── Storage pattern benchmarks ─────────────────────────────────────────────── /// Benchmark storage cost growth with multiple assets deposited +fn bench_update_interest_rate_model( + config: &RunConfig, + model: InterestRateModelKind, + op: &'static str, +) -> BenchmarkResult { + let env = fresh_env(); + let (client, admin) = setup_admin_initialized(&env); + let update = interest_model_update(model); + + let (insns, mem) = measure_instructions(&env, || { + client.update_interest_rate_model(&admin, &update); + }); + + BenchmarkResult::new( + op, + CONTRACT, + "Switch configurable interest rate model", + insns, + mem, + 1, + 1, + false, + get_budget(config, op), + vec![ + "admin".into(), + "interest_rate".into(), + "model_switch".into(), + ], + ) +} + +fn bench_update_interest_rate_model_linear(config: &RunConfig) -> BenchmarkResult { + bench_update_interest_rate_model( + config, + InterestRateModelKind::Linear, + "lending::interest_rate_model_linear", + ) +} + +fn bench_update_interest_rate_model_kink(config: &RunConfig) -> BenchmarkResult { + bench_update_interest_rate_model( + config, + InterestRateModelKind::Kink, + "lending::interest_rate_model_kink", + ) +} + +fn bench_update_interest_rate_model_jump(config: &RunConfig) -> BenchmarkResult { + bench_update_interest_rate_model( + config, + InterestRateModelKind::Jump, + "lending::interest_rate_model_jump", + ) +} + +fn bench_update_interest_rate_model_exponential(config: &RunConfig) -> BenchmarkResult { + bench_update_interest_rate_model( + config, + InterestRateModelKind::Exponential, + "lending::interest_rate_model_exponential", + ) +} + fn bench_deposit_multiple_assets_storage(config: &RunConfig) -> BenchmarkResult { let op = "lending::deposit_multi_asset_storage"; let env = fresh_env(); diff --git a/stellar-lend/contracts/hello-world/src/interest_rate.rs b/stellar-lend/contracts/hello-world/src/interest_rate.rs index 6e0d9a2f..280b2b51 100644 --- a/stellar-lend/contracts/hello-world/src/interest_rate.rs +++ b/stellar-lend/contracts/hello-world/src/interest_rate.rs @@ -45,6 +45,16 @@ pub enum InterestRateError { AlreadyInitialized = 6, } +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum InterestRateModelKind { + Linear = 0, + Kink = 1, + Jump = 2, + Exponential = 3, +} + /// Storage keys for interest rate data #[contracttype] #[derive(Clone)] @@ -84,6 +94,8 @@ pub struct LendingIndex { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct InterestRateConfig { + /// Active interest rate model implementation + pub model: InterestRateModelKind, /// Base interest rate (in basis points, e.g., 100 = 1% per year) /// This is the minimum rate when utilization is 0% pub base_rate_bps: i128, @@ -117,6 +129,7 @@ const SECONDS_PER_YEAR: u64 = 365 * 86400; // 31,536,000 seconds /// Default interest rate configuration fn get_default_config() -> InterestRateConfig { InterestRateConfig { + model: InterestRateModelKind::Kink, base_rate_bps: 100, // 1% base rate kink_utilization_bps: 8000, // 80% kink multiplier_bps: 2000, // 20% multiplier below kink @@ -129,6 +142,118 @@ fn get_default_config() -> InterestRateConfig { } } +fn checked_mul_div(lhs: i128, rhs: i128, divisor: i128) -> Result { + if divisor == 0 { + return Err(InterestRateError::DivisionByZero); + } + lhs.checked_mul(rhs) + .ok_or(InterestRateError::Overflow)? + .checked_div(divisor) + .ok_or(InterestRateError::DivisionByZero) +} + +fn linear_rate(utilization: i128, config: &InterestRateConfig) -> Result { + let increase = checked_mul_div(utilization, config.multiplier_bps, BASIS_POINTS_SCALE)?; + config + .base_rate_bps + .checked_add(increase) + .ok_or(InterestRateError::Overflow) +} + +fn kink_rate(utilization: i128, config: &InterestRateConfig) -> Result { + if utilization <= config.kink_utilization_bps { + if config.kink_utilization_bps == 0 { + return Ok(config.base_rate_bps); + } + let increase = checked_mul_div( + utilization, + config.multiplier_bps, + config.kink_utilization_bps, + )?; + return config + .base_rate_bps + .checked_add(increase) + .ok_or(InterestRateError::Overflow); + } + + let rate_at_kink = config + .base_rate_bps + .checked_add(config.multiplier_bps) + .ok_or(InterestRateError::Overflow)?; + let utilization_above_kink = utilization + .checked_sub(config.kink_utilization_bps) + .ok_or(InterestRateError::Overflow)?; + let max_utilization_above_kink = BASIS_POINTS_SCALE + .checked_sub(config.kink_utilization_bps) + .ok_or(InterestRateError::Overflow)?; + let additional_rate = checked_mul_div( + utilization_above_kink, + config.jump_multiplier_bps, + max_utilization_above_kink, + )?; + + rate_at_kink + .checked_add(additional_rate) + .ok_or(InterestRateError::Overflow) +} + +fn jump_rate(utilization: i128, config: &InterestRateConfig) -> Result { + let mut rate = linear_rate(utilization, config)?; + if utilization > config.kink_utilization_bps { + let utilization_above_kink = utilization + .checked_sub(config.kink_utilization_bps) + .ok_or(InterestRateError::Overflow)?; + let max_utilization_above_kink = BASIS_POINTS_SCALE + .checked_sub(config.kink_utilization_bps) + .ok_or(InterestRateError::Overflow)?; + let jump = checked_mul_div( + utilization_above_kink, + config.jump_multiplier_bps, + max_utilization_above_kink, + )?; + rate = rate.checked_add(jump).ok_or(InterestRateError::Overflow)?; + } + Ok(rate) +} + +fn exponential_rate( + utilization: i128, + config: &InterestRateConfig, +) -> Result { + let utilization_squared = checked_mul_div(utilization, utilization, BASIS_POINTS_SCALE)?; + let utilization_cubed = checked_mul_div(utilization_squared, utilization, BASIS_POINTS_SCALE)?; + let quadratic = checked_mul_div( + utilization_squared, + config.multiplier_bps, + BASIS_POINTS_SCALE, + )?; + let cubic = checked_mul_div( + utilization_cubed, + config.jump_multiplier_bps, + BASIS_POINTS_SCALE, + )?; + + config + .base_rate_bps + .checked_add(quadratic) + .ok_or(InterestRateError::Overflow)? + .checked_add(cubic) + .ok_or(InterestRateError::Overflow) +} + +pub fn calculate_model_borrow_rate( + model: InterestRateModelKind, + utilization: i128, + config: &InterestRateConfig, +) -> Result { + match model { + InterestRateModelKind::Linear => linear_rate(utilization, config), + InterestRateModelKind::Kink => kink_rate(utilization, config), + InterestRateModelKind::Jump => jump_rate(utilization, config), + InterestRateModelKind::Exponential => exponential_rate(utilization, config), + } +} + /// Get interest rate configuration pub fn get_interest_rate_config(env: &Env) -> Option { let config_key = InterestRateDataKey::InterestRateConfig; @@ -196,49 +321,7 @@ pub fn calculate_borrow_rate(env: &Env) -> Result { let config = get_interest_rate_config(env).ok_or(InterestRateError::InvalidParameter)?; let utilization = calculate_utilization(env)?; - let mut rate = config.base_rate_bps; - - if utilization <= config.kink_utilization_bps { - // Below kink: linear increase - if config.kink_utilization_bps > 0 { - let rate_increase = utilization - .checked_mul(config.multiplier_bps) - .ok_or(InterestRateError::Overflow)? - .checked_div(config.kink_utilization_bps) - .ok_or(InterestRateError::DivisionByZero)?; - rate = rate - .checked_add(rate_increase) - .ok_or(InterestRateError::Overflow)?; - } - } else { - // Above kink: steeper increase - let rate_at_kink = config - .base_rate_bps - .checked_add(config.multiplier_bps) - .ok_or(InterestRateError::Overflow)?; - - let utilization_above_kink = utilization - .checked_sub(config.kink_utilization_bps) - .ok_or(InterestRateError::Overflow)?; - - let max_utilization_above_kink = BASIS_POINTS_SCALE - .checked_sub(config.kink_utilization_bps) - .ok_or(InterestRateError::Overflow)?; - - if max_utilization_above_kink > 0 { - let additional_rate = utilization_above_kink - .checked_mul(config.jump_multiplier_bps) - .ok_or(InterestRateError::Overflow)? - .checked_div(max_utilization_above_kink) - .ok_or(InterestRateError::DivisionByZero)?; - - rate = rate_at_kink - .checked_add(additional_rate) - .ok_or(InterestRateError::Overflow)?; - } else { - rate = rate_at_kink; - } - } + let mut rate = calculate_model_borrow_rate(config.model, utilization, &config)?; // Apply emergency adjustment rate = rate @@ -406,6 +489,22 @@ pub fn update_interest_rate_config( Ok(()) } +pub fn switch_interest_rate_model( + env: &Env, + caller: Address, + model: InterestRateModelKind, +) -> Result<(), InterestRateError> { + crate::admin::require_admin(env, &caller).map_err(|_| InterestRateError::Unauthorized)?; + + let config_key = InterestRateDataKey::InterestRateConfig; + let mut config = get_interest_rate_config(env).ok_or(InterestRateError::InvalidParameter)?; + config.model = model; + config.last_update = env.ledger().timestamp(); + env.storage().persistent().set(&config_key, &config); + + Ok(()) +} + /// Set emergency rate adjustment /// /// # Arguments @@ -558,3 +657,64 @@ pub fn compute_index_interest( .ok_or(InterestRateError::DivisionByZero)?; Ok(interest) } + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(model: InterestRateModelKind) -> InterestRateConfig { + InterestRateConfig { + model, + base_rate_bps: 100, + kink_utilization_bps: 8000, + multiplier_bps: 2000, + jump_multiplier_bps: 10_000, + rate_floor_bps: 0, + rate_ceiling_bps: 10_000, + spread_bps: 200, + emergency_adjustment_bps: 0, + last_update: 0, + } + } + + #[test] + fn model_formulas_cover_all_implementations() { + let util = 9000; + assert_eq!( + calculate_model_borrow_rate( + InterestRateModelKind::Linear, + util, + &cfg(InterestRateModelKind::Linear) + ) + .unwrap(), + 1900 + ); + assert_eq!( + calculate_model_borrow_rate( + InterestRateModelKind::Kink, + util, + &cfg(InterestRateModelKind::Kink) + ) + .unwrap(), + 7100 + ); + assert_eq!( + calculate_model_borrow_rate( + InterestRateModelKind::Jump, + util, + &cfg(InterestRateModelKind::Jump) + ) + .unwrap(), + 6900 + ); + assert_eq!( + calculate_model_borrow_rate( + InterestRateModelKind::Exponential, + util, + &cfg(InterestRateModelKind::Exponential) + ) + .unwrap(), + 9010 + ); + } +} diff --git a/stellar-lend/contracts/hello-world/src/lib.rs b/stellar-lend/contracts/hello-world/src/lib.rs index b8dba465..8fe3e208 100644 --- a/stellar-lend/contracts/hello-world/src/lib.rs +++ b/stellar-lend/contracts/hello-world/src/lib.rs @@ -1158,6 +1158,13 @@ impl HelloContract { interest_rate::get_current_utilization(&env).unwrap_or(0) } + /// Current interest rate model implementation. + pub fn get_interest_rate_model(env: Env) -> interest_rate::InterestRateModelKind { + interest_rate::get_interest_rate_config(&env) + .map(|cfg| cfg.model) + .unwrap_or(interest_rate::InterestRateModelKind::Kink) + } + /// Admin-only: update interest rate model parameters. #[allow(clippy::too_many_arguments)] pub fn update_interest_rate_config( @@ -1185,6 +1192,15 @@ impl HelloContract { .map_err(Into::into) } + /// Admin-only: switch between linear, kink, jump, and exponential rate models. + pub fn switch_interest_rate_model( + env: Env, + caller: Address, + model: interest_rate::InterestRateModelKind, + ) -> Result<(), LendingError> { + interest_rate::switch_interest_rate_model(&env, caller, model).map_err(Into::into) + } + /// Current global borrow index (scaled by 1e12; starts at 1e12 = "1.0"). pub fn get_borrow_index(env: Env) -> i128 { interest_rate::get_borrow_index(&env) diff --git a/stellar-lend/contracts/lending/interest_rate_models.md b/stellar-lend/contracts/lending/interest_rate_models.md new file mode 100644 index 00000000..efe34675 --- /dev/null +++ b/stellar-lend/contracts/lending/interest_rate_models.md @@ -0,0 +1,33 @@ +# Interest Rate Models + +The lending contract now stores the active interest rate model in `InterestRateConfig.model`. +Governance can switch the model with `update_interest_rate_model` while preserving the existing +configuration parameters. + +## Models + +| Model | Behavior | +| --- | --- | +| `Linear` | `base + utilization * slope` | +| `Kink` | Piecewise linear. Uses `slope` below `kink_utilization_bps` and `jump_slope` above it. | +| `Jump` | Linear slope across all utilization plus an additional jump slope above the kink. | +| `Exponential` | Quadratic/cubic integer approximation for markets that need sharper high-utilization pricing. | + +All calculations use basis points and checked integer arithmetic. The final borrow rate is clamped +between `rate_floor_bps` and `rate_ceiling_bps`. The supply rate is derived from the borrow rate +minus `spread_bps`, then clamped to the floor. + +## Migration Behavior + +Variable-rate positions are migrated lazily because they read the current model on each accrual. +Stable-rate positions keep their stored `stable_rate_bps` snapshot, so switching models does not +rewrite existing stable debt. New stable borrows use the active model at the time of borrow. + +## Benchmarking + +The benchmark suite includes model-switch measurements for all four pre-built models: + +- `lending::interest_rate_model_linear` +- `lending::interest_rate_model_kink` +- `lending::interest_rate_model_jump` +- `lending::interest_rate_model_exponential` diff --git a/stellar-lend/contracts/lending/src/borrow.rs b/stellar-lend/contracts/lending/src/borrow.rs index 7bf0b628..b6f86cd2 100644 --- a/stellar-lend/contracts/lending/src/borrow.rs +++ b/stellar-lend/contracts/lending/src/borrow.rs @@ -204,6 +204,7 @@ pub fn set_variable_borrow_rate_bps( } let update = crate::interest_rate::InterestRateConfigUpdate { + model: None, base_rate_bps: Some(rate_bps), kink_utilization_bps: None, slope_bps: None, diff --git a/stellar-lend/contracts/lending/src/interest_rate.rs b/stellar-lend/contracts/lending/src/interest_rate.rs index 862865d0..434c079e 100644 --- a/stellar-lend/contracts/lending/src/interest_rate.rs +++ b/stellar-lend/contracts/lending/src/interest_rate.rs @@ -1,6 +1,6 @@ use soroban_sdk::{contracterror, contracttype, Address, Env}; -use crate::borrow::{get_admin, get_debt_ceiling, get_total_debt, BorrowError, BorrowDataKey}; +use crate::borrow::{get_admin, get_debt_ceiling, get_total_debt, BorrowDataKey, BorrowError}; const BPS_SCALE: i128 = 10_000; @@ -14,9 +14,44 @@ pub enum InterestRateError { DivisionByZero = 4, } +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum InterestRateModelKind { + Linear = 0, + Kink = 1, + Jump = 2, + Exponential = 3, +} + +impl InterestRateModelKind { + pub fn from_code(code: u32) -> Result { + match code { + 0 => Ok(Self::Linear), + 1 => Ok(Self::Kink), + 2 => Ok(Self::Jump), + 3 => Ok(Self::Exponential), + _ => Err(InterestRateError::InvalidParameter), + } + } +} + +pub trait InterestRateModel { + fn calculate( + utilization_bps: i128, + cfg: &InterestRateConfig, + ) -> Result; +} + +pub struct LinearRateModel; +pub struct KinkRateModel; +pub struct JumpRateModel; +pub struct ExponentialRateModel; + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct InterestRateConfig { + pub model: InterestRateModelKind, pub base_rate_bps: i128, pub kink_utilization_bps: i128, pub slope_bps: i128, @@ -30,6 +65,7 @@ pub struct InterestRateConfig { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct InterestRateConfigUpdate { + pub model: Option, pub base_rate_bps: Option, pub kink_utilization_bps: Option, pub slope_bps: Option, @@ -41,6 +77,7 @@ pub struct InterestRateConfigUpdate { fn default_config(env: &Env) -> InterestRateConfig { InterestRateConfig { + model: InterestRateModelKind::Kink, base_rate_bps: 100, kink_utilization_bps: 8000, slope_bps: 2000, @@ -52,6 +89,125 @@ fn default_config(env: &Env) -> InterestRateConfig { } } +fn checked_mul_div(lhs: i128, rhs: i128, divisor: i128) -> Result { + if divisor == 0 { + return Err(InterestRateError::DivisionByZero); + } + lhs.checked_mul(rhs) + .ok_or(InterestRateError::Overflow)? + .checked_div(divisor) + .ok_or(InterestRateError::DivisionByZero) +} + +fn rate_at_kink(cfg: &InterestRateConfig) -> Result { + cfg.base_rate_bps + .checked_add(cfg.slope_bps) + .ok_or(InterestRateError::Overflow) +} + +impl InterestRateModel for LinearRateModel { + fn calculate( + utilization_bps: i128, + cfg: &InterestRateConfig, + ) -> Result { + let inc = checked_mul_div(utilization_bps, cfg.slope_bps, BPS_SCALE)?; + cfg.base_rate_bps + .checked_add(inc) + .ok_or(InterestRateError::Overflow) + } +} + +impl InterestRateModel for KinkRateModel { + fn calculate( + utilization_bps: i128, + cfg: &InterestRateConfig, + ) -> Result { + if utilization_bps <= cfg.kink_utilization_bps { + if cfg.kink_utilization_bps == 0 { + return Ok(cfg.base_rate_bps); + } + let inc = checked_mul_div(utilization_bps, cfg.slope_bps, cfg.kink_utilization_bps)?; + return cfg + .base_rate_bps + .checked_add(inc) + .ok_or(InterestRateError::Overflow); + } + + let util_above = utilization_bps + .checked_sub(cfg.kink_utilization_bps) + .ok_or(InterestRateError::Overflow)?; + let max_above = BPS_SCALE + .checked_sub(cfg.kink_utilization_bps) + .ok_or(InterestRateError::Overflow)?; + let addl = checked_mul_div(util_above, cfg.jump_slope_bps, max_above)?; + + rate_at_kink(cfg)? + .checked_add(addl) + .ok_or(InterestRateError::Overflow) + } +} + +impl InterestRateModel for JumpRateModel { + fn calculate( + utilization_bps: i128, + cfg: &InterestRateConfig, + ) -> Result { + let linear = checked_mul_div(utilization_bps, cfg.slope_bps, BPS_SCALE)?; + let mut rate = cfg + .base_rate_bps + .checked_add(linear) + .ok_or(InterestRateError::Overflow)?; + + if utilization_bps > cfg.kink_utilization_bps { + let util_above = utilization_bps + .checked_sub(cfg.kink_utilization_bps) + .ok_or(InterestRateError::Overflow)?; + let max_above = BPS_SCALE + .checked_sub(cfg.kink_utilization_bps) + .ok_or(InterestRateError::Overflow)?; + let jump = checked_mul_div(util_above, cfg.jump_slope_bps, max_above)?; + rate = rate.checked_add(jump).ok_or(InterestRateError::Overflow)?; + } + + Ok(rate) + } +} + +impl InterestRateModel for ExponentialRateModel { + fn calculate( + utilization_bps: i128, + cfg: &InterestRateConfig, + ) -> Result { + let util_squared = checked_mul_div(utilization_bps, utilization_bps, BPS_SCALE)?; + let util_cubed = checked_mul_div(util_squared, utilization_bps, BPS_SCALE)?; + let quadratic = checked_mul_div(util_squared, cfg.slope_bps, BPS_SCALE)?; + let cubic = checked_mul_div(util_cubed, cfg.jump_slope_bps, BPS_SCALE)?; + + cfg.base_rate_bps + .checked_add(quadratic) + .ok_or(InterestRateError::Overflow)? + .checked_add(cubic) + .ok_or(InterestRateError::Overflow) + } +} + +pub fn calculate_model_rate_bps( + model: InterestRateModelKind, + utilization_bps: i128, + cfg: &InterestRateConfig, +) -> Result { + match model { + InterestRateModelKind::Linear => LinearRateModel::calculate(utilization_bps, cfg), + InterestRateModelKind::Kink => KinkRateModel::calculate(utilization_bps, cfg), + InterestRateModelKind::Jump => JumpRateModel::calculate(utilization_bps, cfg), + InterestRateModelKind::Exponential => ExponentialRateModel::calculate(utilization_bps, cfg), + } +} + +fn clamp_rate(rate: i128, cfg: &InterestRateConfig) -> i128 { + rate.max(cfg.rate_floor_bps).min(cfg.rate_ceiling_bps) +} + pub fn get_config(env: &Env) -> InterestRateConfig { env.storage() .persistent() @@ -97,56 +253,22 @@ pub fn utilization_bps(env: &Env) -> Result { pub fn borrow_rate_bps(env: &Env) -> Result { let cfg = get_config(env); let util = utilization_bps(env)?; - - let mut rate = cfg.base_rate_bps; - - if util <= cfg.kink_utilization_bps { - if cfg.kink_utilization_bps > 0 { - let inc = util - .checked_mul(cfg.slope_bps) - .ok_or(InterestRateError::Overflow)? - .checked_div(cfg.kink_utilization_bps) - .ok_or(InterestRateError::DivisionByZero)?; - rate = rate.checked_add(inc).ok_or(InterestRateError::Overflow)?; - } - } else { - let rate_at_kink = cfg - .base_rate_bps - .checked_add(cfg.slope_bps) - .ok_or(InterestRateError::Overflow)?; - - let util_above = util - .checked_sub(cfg.kink_utilization_bps) - .ok_or(InterestRateError::Overflow)?; - - let max_above = BPS_SCALE - .checked_sub(cfg.kink_utilization_bps) - .ok_or(InterestRateError::Overflow)?; - - if max_above > 0 { - let addl = util_above - .checked_mul(cfg.jump_slope_bps) - .ok_or(InterestRateError::Overflow)? - .checked_div(max_above) - .ok_or(InterestRateError::DivisionByZero)?; - - rate = rate_at_kink - .checked_add(addl) - .ok_or(InterestRateError::Overflow)?; - } else { - rate = rate_at_kink; - } - } - - Ok(rate.max(cfg.rate_floor_bps).min(cfg.rate_ceiling_bps)) + Ok(clamp_rate( + calculate_model_rate_bps(cfg.model, util, &cfg)?, + &cfg, + )) } pub fn supply_rate_bps(env: &Env) -> Result { let cfg = get_config(env); let borrow = borrow_rate_bps(env)?; - let supply = borrow - .checked_sub(cfg.spread_bps) - .ok_or(InterestRateError::Overflow)?; + let supply = if borrow <= cfg.spread_bps { + 0 + } else { + borrow + .checked_sub(cfg.spread_bps) + .ok_or(InterestRateError::Overflow)? + }; Ok(supply.max(cfg.rate_floor_bps)) } @@ -167,6 +289,10 @@ pub fn update_config( let prev = get_config(env); let mut next = prev.clone(); + if let Some(model) = update.model { + next.model = InterestRateModelKind::from_code(model)?; + } + if let Some(v) = update.base_rate_bps { if v < 0 || v > BPS_SCALE { return Err(InterestRateError::InvalidParameter); @@ -226,9 +352,77 @@ pub fn update_config( .persistent() .set(&BorrowDataKey::BorrowInterestRate, &next); + crate::events::InterestRateModelUpdatedEvent { + caller: caller.clone(), + previous: prev.clone(), + updated: next.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); + Ok((prev, next)) } +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(model: InterestRateModelKind) -> InterestRateConfig { + InterestRateConfig { + model, + base_rate_bps: 100, + kink_utilization_bps: 8000, + slope_bps: 2000, + jump_slope_bps: 10_000, + rate_floor_bps: 0, + rate_ceiling_bps: 10_000, + spread_bps: 200, + last_update: 0, + } + } + + #[test] + fn model_rate_formulas_are_distinct_and_bounded() { + let util = 9000; + assert_eq!( + calculate_model_rate_bps( + InterestRateModelKind::Linear, + util, + &cfg(InterestRateModelKind::Linear) + ) + .unwrap(), + 1900 + ); + assert_eq!( + calculate_model_rate_bps( + InterestRateModelKind::Kink, + util, + &cfg(InterestRateModelKind::Kink) + ) + .unwrap(), + 7100 + ); + assert_eq!( + calculate_model_rate_bps( + InterestRateModelKind::Jump, + util, + &cfg(InterestRateModelKind::Jump) + ) + .unwrap(), + 6900 + ); + assert_eq!( + calculate_model_rate_bps( + InterestRateModelKind::Exponential, + util, + &cfg(InterestRateModelKind::Exponential) + ) + .unwrap(), + 9010 + ); + } +} + impl From for BorrowError { fn from(value: InterestRateError) -> Self { match value { diff --git a/stellar-lend/contracts/lending/src/interest_rate_test.rs b/stellar-lend/contracts/lending/src/interest_rate_test.rs index b12dbc96..e923c43f 100644 --- a/stellar-lend/contracts/lending/src/interest_rate_test.rs +++ b/stellar-lend/contracts/lending/src/interest_rate_test.rs @@ -73,6 +73,7 @@ fn test_rate_model_update_emits_event() { client.update_interest_rate_model( &admin, &InterestRateConfigUpdate { + model: None, base_rate_bps: Some(200), kink_utilization_bps: None, slope_bps: None, @@ -85,9 +86,76 @@ fn test_rate_model_update_emits_event() { let events = env.events().all(); let last = events.last().unwrap(); - let topic0 = last.topics.get(0).unwrap(); - let sym: Symbol = Symbol::try_from_val(&env, topic0).unwrap(); + let topic0 = last.1.get(0).unwrap(); + let sym: Symbol = Symbol::try_from_val(&env, &topic0).unwrap(); assert_eq!(sym, Symbol::new(&env, "interest_rate_model_updated")); assert_eq!(client.get_borrow_rate_bps(), 200); } + +#[test] +fn test_can_switch_between_prebuilt_rate_models() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, user, asset) = setup(&env, 100_000); + let collateral_asset = Address::generate(&env); + client.borrow(&user, &asset, &90_000, &collateral_asset, &135_000); + + client.update_interest_rate_model( + &admin, + &InterestRateConfigUpdate { + model: Some(InterestRateModelKind::Linear as u32), + base_rate_bps: None, + kink_utilization_bps: None, + slope_bps: None, + jump_slope_bps: None, + rate_floor_bps: None, + rate_ceiling_bps: None, + spread_bps: None, + }, + ); + assert_eq!( + client.get_interest_rate_model(), + InterestRateModelKind::Linear + ); + assert_eq!(client.get_borrow_rate_bps(), 1900); + + client.update_interest_rate_model( + &admin, + &InterestRateConfigUpdate { + model: Some(InterestRateModelKind::Jump as u32), + base_rate_bps: None, + kink_utilization_bps: None, + slope_bps: None, + jump_slope_bps: None, + rate_floor_bps: None, + rate_ceiling_bps: None, + spread_bps: None, + }, + ); + assert_eq!( + client.get_interest_rate_model(), + InterestRateModelKind::Jump + ); + assert_eq!(client.get_borrow_rate_bps(), 6900); + + client.update_interest_rate_model( + &admin, + &InterestRateConfigUpdate { + model: Some(InterestRateModelKind::Exponential as u32), + base_rate_bps: None, + kink_utilization_bps: None, + slope_bps: None, + jump_slope_bps: None, + rate_floor_bps: None, + rate_ceiling_bps: None, + spread_bps: None, + }, + ); + assert_eq!( + client.get_interest_rate_model(), + InterestRateModelKind::Exponential + ); + assert_eq!(client.get_borrow_rate_bps(), 9010); +} diff --git a/stellar-lend/contracts/lending/src/lib.rs b/stellar-lend/contracts/lending/src/lib.rs index c69ea29a..233a11b8 100644 --- a/stellar-lend/contracts/lending/src/lib.rs +++ b/stellar-lend/contracts/lending/src/lib.rs @@ -5,7 +5,9 @@ mod borrow; mod deposit; mod events; mod flash_loan; +mod interest_rate; mod pause; +mod risk_monitor; mod token_receiver; mod withdraw; @@ -25,7 +27,11 @@ use flash_loan::{ flash_loan as flash_loan_logic, set_flash_loan_fee_bps as set_flash_loan_fee_logic, FlashLoanError, }; -use pause::{is_paused, set_pause as set_pause_logic, PauseType}; +pub use interest_rate::{ + InterestRateConfig, InterestRateConfigUpdate, InterestRateError, InterestRateModelKind, +}; +pub use pause::PauseType; +use pause::{is_paused, set_pause as set_pause_logic}; use token_receiver::receive as receive_logic; mod views; @@ -48,10 +54,9 @@ use insurance::{ collect_premium as insurance_collect_premium, evaluate_claim as insurance_evaluate_claim, fund_pool as insurance_fund_pool, get_analytics as insurance_get_analytics, get_claim_by_id as insurance_get_claim, get_coverage_limit as insurance_get_coverage_limit, - get_premium_rate as insurance_get_premium_rate, - initialize as insurance_initialize, - set_coverage_limit as insurance_set_coverage_limit, - submit_claim as insurance_submit_claim, InsuranceAnalytics, InsuranceClaim, InsuranceError, + get_premium_rate as insurance_get_premium_rate, initialize as insurance_initialize, + set_coverage_limit as insurance_set_coverage_limit, submit_claim as insurance_submit_claim, + InsuranceAnalytics, InsuranceClaim, InsuranceError, }; #[cfg(test)] @@ -65,6 +70,8 @@ mod flash_loan_test; #[cfg(test)] mod insurance_test; #[cfg(test)] +mod interest_rate_test; +#[cfg(test)] mod math_safety_test; #[cfg(test)] mod pause_test; @@ -274,6 +281,40 @@ impl LendingContract { get_borrow_admin(&env) } + /// Get the active interest rate model configuration. + pub fn get_interest_rate_config(env: Env) -> InterestRateConfig { + interest_rate::get_config(&env) + } + + /// Get the active interest rate model kind. + pub fn get_interest_rate_model(env: Env) -> InterestRateModelKind { + interest_rate::get_config(&env).model + } + + /// Get protocol utilization in basis points. + pub fn get_utilization_bps(env: Env) -> Result { + interest_rate::utilization_bps(&env) + } + + /// Get the current borrow rate in basis points. + pub fn get_borrow_rate_bps(env: Env) -> Result { + interest_rate::borrow_rate_bps(&env) + } + + /// Get the current supply rate in basis points. + pub fn get_supply_rate_bps(env: Env) -> Result { + interest_rate::supply_rate_bps(&env) + } + + /// Admin-only: update interest rate model type or parameters. + pub fn update_interest_rate_model( + env: Env, + admin: Address, + update: InterestRateConfigUpdate, + ) -> Result<(), InterestRateError> { + interest_rate::update_config(&env, &admin, update).map(|_| ()) + } + /// Execute a flash loan pub fn flash_loan( env: Env,