diff --git a/stellar-lend/Cargo.lock b/stellar-lend/Cargo.lock index eb04b2d0..8ac077d3 100644 --- a/stellar-lend/Cargo.lock +++ b/stellar-lend/Cargo.lock @@ -1037,7 +1037,7 @@ dependencies = [ "stellar-contract-utils", "stellar-macros", "stellarlend-amm", - "stellarlend-shared-deadline", + "stellarlend-oracle-client", ] [[package]] @@ -2753,6 +2753,13 @@ dependencies = [ "stellarlend-common", ] +[[package]] +name = "stellarlend-oracle-client" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "stellarlend-benchmarks" version = "0.1.0" diff --git a/stellar-lend/Cargo.toml b/stellar-lend/Cargo.toml index 54552c09..3bd5038e 100644 --- a/stellar-lend/Cargo.toml +++ b/stellar-lend/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "benchmarks", "client", + "packages/oracle-client", "contracts/amm", "contracts/bridge", "contracts/common", diff --git a/stellar-lend/contracts/hello-world/Cargo.toml b/stellar-lend/contracts/hello-world/Cargo.toml index 88d1c8f0..486d9e07 100644 --- a/stellar-lend/contracts/hello-world/Cargo.toml +++ b/stellar-lend/contracts/hello-world/Cargo.toml @@ -12,7 +12,7 @@ doctest = false soroban-sdk = { workspace = true } soroban-token-sdk = { workspace = true } stellarlend-amm = { path = "../amm" } -stellarlend-shared-deadline = { path = "../shared-deadline" } +stellarlend-oracle-client = { path = "../../packages/oracle-client" } stellar-contract-utils = { version = "0.6.0" } stellar-macros = { version = "0.6.0" } diff --git a/stellar-lend/contracts/hello-world/src/cross_asset.rs b/stellar-lend/contracts/hello-world/src/cross_asset.rs index 452cd810..b6f89883 100644 --- a/stellar-lend/contracts/hello-world/src/cross_asset.rs +++ b/stellar-lend/contracts/hello-world/src/cross_asset.rs @@ -155,10 +155,10 @@ pub enum CrossAssetError { const ADMIN: Symbol = symbol_short!("admin"); /// Storage key for the map of asset configurations: Map -const ASSET_CONFIGS: Symbol = symbol_short!("configs"); +pub(crate) const ASSET_CONFIGS: Symbol = symbol_short!("configs"); /// Storage key for the map of user positions: Map -const USER_POSITIONS: Symbol = symbol_short!("positions"); +pub(crate) const USER_POSITIONS: Symbol = symbol_short!("positions"); /// Storage key for the map of total supplies per asset: Map const TOTAL_SUPPLIES: Symbol = symbol_short!("supplies"); @@ -167,7 +167,7 @@ const TOTAL_SUPPLIES: Symbol = symbol_short!("supplies"); const TOTAL_BORROWS: Symbol = symbol_short!("borrows"); /// Storage key for the global list of registered assets: Vec -const ASSET_LIST: Symbol = symbol_short!("assets"); +pub(crate) const ASSET_LIST: Symbol = symbol_short!("assets"); /// Initialize the cross-asset lending module. /// @@ -457,84 +457,7 @@ pub fn get_user_position_summary( env: &Env, user: &Address, ) -> Result { - let asset_list: Vec = env - .storage() - .persistent() - .get(&ASSET_LIST) - .unwrap_or(Vec::new(env)); - - let configs: Map = env - .storage() - .persistent() - .get(&ASSET_CONFIGS) - .unwrap_or(Map::new(env)); - - let mut total_collateral_value: i128 = 0; - let mut weighted_collateral_value: i128 = 0; - let mut total_debt_value: i128 = 0; - let mut weighted_debt_value: i128 = 0; - - for i in 0..asset_list.len() { - let asset_key = asset_list.get(i).unwrap(); - - if let Some(config) = configs.get(asset_key.clone()) { - let asset_option = asset_key.to_option(); - let position = get_user_asset_position(env, user, asset_option); - - if position.collateral == 0 && position.debt_principal == 0 { - continue; - } - - let current_time = env.ledger().timestamp(); - if current_time > config.price_updated_at - && current_time - config.price_updated_at > 3600 - { - return Err(CrossAssetError::PriceStale); - } - - let collateral_value = (position.collateral * config.price) / 10_000_000; - total_collateral_value += collateral_value; - - if config.can_collateralize { - weighted_collateral_value += - (collateral_value * config.liquidation_threshold) / 10_000; - } - - let total_debt = position.debt_principal + position.accrued_interest; - let debt_value = (total_debt * config.price) / 10_000_000; - total_debt_value += debt_value; - - weighted_debt_value += debt_value; - } - } - - // Calculate health factor (weighted_collateral / weighted_debt * 10000) - // Health factor of 1.0 = 10000, below 1.0 can be liquidated - let health_factor = if weighted_debt_value > 0 { - (weighted_collateral_value * 10_000) / weighted_debt_value - } else { - i128::MAX // No debt = infinite health - }; - - // Position is liquidatable if health factor < 1.0 (10000) - let is_liquidatable = health_factor < 10_000 && weighted_debt_value > 0; - - // Calculate remaining borrow capacity - let borrow_capacity = if weighted_collateral_value > weighted_debt_value { - weighted_collateral_value - weighted_debt_value - } else { - 0 - }; - - Ok(UserPositionSummary { - total_collateral_value, - weighted_collateral_value, - total_debt_value, - weighted_debt_value, - health_factor, - is_liquidatable, - borrow_capacity, - }) + crate::health::get_batched_user_position_summary(env, user) } /// Deposit collateral for a specific asset. diff --git a/stellar-lend/contracts/hello-world/src/health.rs b/stellar-lend/contracts/hello-world/src/health.rs new file mode 100644 index 00000000..903f0802 --- /dev/null +++ b/stellar-lend/contracts/hello-world/src/health.rs @@ -0,0 +1,136 @@ +use soroban_sdk::{contracttype, Address, Env, Map, Vec}; + +use crate::cross_asset::{ + AssetConfig, AssetKey, AssetPosition, CrossAssetError, UserAssetKey, UserPositionSummary, + ASSET_CONFIGS, ASSET_LIST, USER_POSITIONS, +}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BatchedPositionData { + pub asset: AssetKey, + pub config: AssetConfig, + pub position: AssetPosition, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HealthBatch { + pub positions: Vec, + pub storage_reads: u32, + pub price_reads: u32, +} + +pub fn batch_read_health_data(env: &Env, user: &Address) -> HealthBatch { + let asset_list: Vec = env + .storage() + .persistent() + .get(&ASSET_LIST) + .unwrap_or(Vec::new(env)); + let configs: Map = env + .storage() + .persistent() + .get(&ASSET_CONFIGS) + .unwrap_or(Map::new(env)); + let user_positions: Map = env + .storage() + .persistent() + .get(&USER_POSITIONS) + .unwrap_or(Map::new(env)); + + let mut positions = Vec::new(env); + let mut storage_reads = 3u32; + let mut price_reads = 0u32; + + for i in 0..asset_list.len() { + let asset = asset_list.get(i).unwrap(); + if let Some(config) = configs.get(asset.clone()) { + let position = user_positions + .get(UserAssetKey::new(user.clone(), asset.clone().to_option())) + .unwrap_or(AssetPosition { + collateral: 0, + debt_principal: 0, + accrued_interest: 0, + last_updated: env.ledger().timestamp(), + }); + + if position.collateral != 0 || position.debt_principal != 0 { + price_reads += 1; + positions.push_back(BatchedPositionData { + asset, + config, + position, + }); + } + } + } + + HealthBatch { + positions, + storage_reads, + price_reads, + } +} + +pub fn calculate_health_from_batch( + env: &Env, + batch: &HealthBatch, +) -> Result { + let mut total_collateral_value: i128 = 0; + let mut weighted_collateral_value: i128 = 0; + let mut total_debt_value: i128 = 0; + let mut weighted_debt_value: i128 = 0; + + for i in 0..batch.positions.len() { + let item = batch.positions.get(i).unwrap(); + let current_time = env.ledger().timestamp(); + if current_time > item.config.price_updated_at + && current_time - item.config.price_updated_at > 3600 + { + return Err(CrossAssetError::PriceStale); + } + + let collateral_value = (item.position.collateral * item.config.price) / 10_000_000; + total_collateral_value += collateral_value; + + if item.config.can_collateralize { + weighted_collateral_value += + (collateral_value * item.config.liquidation_threshold) / 10_000; + } + + let total_debt = item.position.debt_principal + item.position.accrued_interest; + let debt_value = (total_debt * item.config.price) / 10_000_000; + total_debt_value += debt_value; + weighted_debt_value += debt_value; + } + + let health_factor = if weighted_debt_value > 0 { + (weighted_collateral_value * 10_000) / weighted_debt_value + } else { + i128::MAX + }; + let is_liquidatable = health_factor < 10_000 && weighted_debt_value > 0; + let borrow_capacity = if weighted_collateral_value > weighted_debt_value { + weighted_collateral_value - weighted_debt_value + } else { + 0 + }; + + Ok(UserPositionSummary { + total_collateral_value, + weighted_collateral_value, + total_debt_value, + weighted_debt_value, + health_factor, + is_liquidatable, + borrow_capacity, + }) +} + +pub fn get_batched_user_position_summary( + env: &Env, + user: &Address, +) -> Result { + let batch = batch_read_health_data(env, user); + calculate_health_from_batch(env, &batch) +} diff --git a/stellar-lend/contracts/hello-world/src/interest/mod.rs b/stellar-lend/contracts/hello-world/src/interest/mod.rs new file mode 100644 index 00000000..39dbc860 --- /dev/null +++ b/stellar-lend/contracts/hello-world/src/interest/mod.rs @@ -0,0 +1,18 @@ +use soroban_sdk::Env; + +use crate::interest_rate::InterestRateError; +use crate::traits::InterestModel; + +pub struct DefaultInterestModel; + +impl InterestModel for DefaultInterestModel { + type Error = InterestRateError; + + fn utilization(&self, env: &Env) -> Result { + crate::interest_rate::calculate_utilization(env) + } + + fn borrow_rate(&self, env: &Env) -> Result { + crate::interest_rate::calculate_borrow_rate(env) + } +} diff --git a/stellar-lend/contracts/hello-world/src/lib.rs b/stellar-lend/contracts/hello-world/src/lib.rs index e1b52640..0f8e85f9 100644 --- a/stellar-lend/contracts/hello-world/src/lib.rs +++ b/stellar-lend/contracts/hello-world/src/lib.rs @@ -18,9 +18,12 @@ pub mod errors; pub mod events; pub mod flash_loan; pub mod governance; +pub mod health; pub mod intents; +pub mod interest; pub mod interest_rate; pub mod liquidate; +pub mod liquidation; pub mod liquidation_queue; pub mod mev_protection; pub mod multi_collateral; @@ -32,12 +35,14 @@ pub mod recovery; pub mod reentrancy; pub mod repay; pub mod reserve; +pub mod reserve_factor; pub mod risk_management; pub mod risk_params; pub mod safe_math; pub mod storage; pub mod timelock; pub mod treasury; +pub mod traits; pub mod test_utils; pub mod tests; pub mod types; @@ -434,6 +439,65 @@ impl HelloContract { reserve::set_reserve_factor(&env, caller, asset, reserve_factor_bps).map_err(Into::into) } + pub fn set_reserve_factor_curve( + env: Env, + caller: Address, + asset: Option
, + curve: reserve_factor::ReserveFactorCurve, + ) -> Result<(), LendingError> { + reserve_factor::set_reserve_factor_curve(&env, caller, asset, curve).map_err(Into::into) + } + + pub fn set_reserve_factor_bounds( + env: Env, + caller: Address, + asset: Option
, + min_bps: i128, + max_bps: i128, + ) -> Result<(), LendingError> { + reserve_factor::set_reserve_factor_bounds(&env, caller, asset, min_bps, max_bps) + .map_err(Into::into) + } + + pub fn get_reserve_factor_curve( + env: Env, + asset: Option
, + ) -> reserve_factor::ReserveFactorCurve { + reserve_factor::get_reserve_factor_curve(&env, asset) + } + + pub fn preview_reserve_factor( + env: Env, + asset: Option
, + utilization_bps: Option, + ) -> Result { + reserve_factor::preview_reserve_factor(&env, asset, utilization_bps).map_err(Into::into) + } + + pub fn get_health_factor_batch( + env: Env, + user: Address, + ) -> health::HealthBatch { + health::batch_read_health_data(&env, &user) + } + + pub fn get_batched_health_summary( + env: Env, + user: Address, + ) -> Result { + health::get_batched_user_position_summary(&env, &user).map_err(Into::into) + } + + pub fn registered_pool_modules(env: Env) -> (String, String, String, String) { + let modules = traits::registered_modules(); + ( + String::from_str(&env, modules.0), + String::from_str(&env, modules.1), + String::from_str(&env, modules.2), + String::from_str(&env, modules.3), + ) + } + /// Set treasury address (admin only) pub fn set_treasury_address( env: Env, diff --git a/stellar-lend/contracts/hello-world/src/liquidation/mod.rs b/stellar-lend/contracts/hello-world/src/liquidation/mod.rs new file mode 100644 index 00000000..78f8ce03 --- /dev/null +++ b/stellar-lend/contracts/hello-world/src/liquidation/mod.rs @@ -0,0 +1,19 @@ +use soroban_sdk::Env; + +use crate::liquidate::LiquidationError; +use crate::traits::LiquidationStrategy; + +pub struct DefaultLiquidationStrategy; + +impl LiquidationStrategy for DefaultLiquidationStrategy { + type Error = LiquidationError; + + fn dynamic_penalty( + &self, + env: &Env, + collateral_value: i128, + total_debt: i128, + ) -> Result { + crate::liquidate::calculate_dynamic_penalty(env, collateral_value, total_debt) + } +} diff --git a/stellar-lend/contracts/hello-world/src/oracle.rs b/stellar-lend/contracts/hello-world/src/oracle.rs index b71059c8..8c12faed 100644 --- a/stellar-lend/contracts/hello-world/src/oracle.rs +++ b/stellar-lend/contracts/hello-world/src/oracle.rs @@ -27,6 +27,7 @@ use crate::admin::get_admin; use crate::deposit::DepositDataKey; use crate::events::{emit_price_updated, PriceUpdatedEvent}; use soroban_sdk::{contracterror, contracttype, Address, Env, IntoVal, Map, Symbol, Val, Vec}; +use stellarlend_oracle_client::{validate_price as validate_client_price, OraclePrice}; /// Errors that can occur during oracle operations #[contracterror] @@ -1194,6 +1195,15 @@ pub fn get_price(env: &Env, asset: &Address) -> Result { maybe_trip_breaker(env, asset, twap)?; // Cache and remember as last safe price. + let config = get_oracle_config(env); + let client_price = OraclePrice { + price: twap, + updated_at: env.ledger().timestamp(), + source: env.current_contract_address(), + }; + validate_client_price(env, &client_price, config.max_staleness_seconds) + .map_err(|_| OracleError::InvalidPrice)?; + cache_price(env, asset, twap); record_safe_price(env, asset, twap); diff --git a/stellar-lend/contracts/hello-world/src/reserve.rs b/stellar-lend/contracts/hello-world/src/reserve.rs index f5915d9f..a76ad644 100644 --- a/stellar-lend/contracts/hello-world/src/reserve.rs +++ b/stellar-lend/contracts/hello-world/src/reserve.rs @@ -42,6 +42,7 @@ use soroban_sdk::{contracterror, contracttype, Address, Env, Symbol}; use crate::deposit::DepositDataKey; +use crate::reserve_factor; /// Maximum allowed reserve factor (50% = 5000 basis points) /// This ensures that at least 50% of interest always goes to lenders @@ -98,6 +99,9 @@ pub enum ReserveDataKey { /// Virtual LP token balance tracked per asset for AMM deployments. /// (Accounting only; actual LP token custody is managed by the AMM contract / ops layer.) ReserveAmmLpBalance(Option
), + /// Dynamic reserve factor curve per asset. + /// Value type: ReserveFactorCurve + ReserveFactorCurve(Option
), } /// Initialize reserve configuration for an asset @@ -205,6 +209,11 @@ pub fn set_reserve_factor( /// # Returns /// Reserve factor in basis points (0-5000) pub fn get_reserve_factor(env: &Env, asset: Option
) -> i128 { + reserve_factor::get_dynamic_reserve_factor(env, asset.clone()) + .unwrap_or_else(|_| get_static_reserve_factor(env, asset)) +} + +pub fn get_static_reserve_factor(env: &Env, asset: Option
) -> i128 { let factor_key = ReserveDataKey::ReserveFactor(asset); env.storage() .persistent() diff --git a/stellar-lend/contracts/hello-world/src/reserve_factor.rs b/stellar-lend/contracts/hello-world/src/reserve_factor.rs new file mode 100644 index 00000000..f6be1bad --- /dev/null +++ b/stellar-lend/contracts/hello-world/src/reserve_factor.rs @@ -0,0 +1,232 @@ +use soroban_sdk::{contracttype, Address, Env, Symbol}; + +use crate::admin::require_admin; +use crate::interest_rate; +use crate::reserve::{ReserveDataKey, ReserveError, BASIS_POINTS_SCALE, DEFAULT_RESERVE_FACTOR_BPS, MAX_RESERVE_FACTOR_BPS}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReserveFactorCurve { + pub base_bps: i128, + pub slope_bps: i128, + pub kink_utilization_bps: i128, + pub jump_slope_bps: i128, + pub min_bps: i128, + pub max_bps: i128, + pub enabled: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReserveFactorPreview { + pub utilization_bps: i128, + pub reserve_factor_bps: i128, + pub static_reserve_factor_bps: i128, + pub revenue_delta_bps: i128, +} + +pub fn default_curve() -> ReserveFactorCurve { + ReserveFactorCurve { + base_bps: DEFAULT_RESERVE_FACTOR_BPS / 2, + slope_bps: 700, + kink_utilization_bps: 8_000, + jump_slope_bps: 1_800, + min_bps: 250, + max_bps: MAX_RESERVE_FACTOR_BPS, + enabled: true, + } +} + +pub fn validate_curve(curve: &ReserveFactorCurve) -> Result<(), ReserveError> { + if curve.base_bps < 0 + || curve.slope_bps < 0 + || curve.jump_slope_bps < 0 + || curve.min_bps < 0 + || curve.max_bps > MAX_RESERVE_FACTOR_BPS + || curve.min_bps > curve.max_bps + || curve.kink_utilization_bps <= 0 + || curve.kink_utilization_bps >= BASIS_POINTS_SCALE + { + return Err(ReserveError::InvalidReserveFactor); + } + + Ok(()) +} + +pub fn set_reserve_factor_curve( + env: &Env, + caller: Address, + asset: Option
, + curve: ReserveFactorCurve, +) -> Result<(), ReserveError> { + caller.require_auth(); + require_admin(env, &caller).map_err(|_| ReserveError::Unauthorized)?; + validate_curve(&curve)?; + + env.storage() + .persistent() + .set(&ReserveDataKey::ReserveFactorCurve(asset.clone()), &curve); + + let topics = (Symbol::new(env, "reserve_curve_set"), caller); + env.events().publish(topics, (asset, curve)); + + Ok(()) +} + +pub fn set_reserve_factor_bounds( + env: &Env, + caller: Address, + asset: Option
, + min_bps: i128, + max_bps: i128, +) -> Result<(), ReserveError> { + caller.require_auth(); + require_admin(env, &caller).map_err(|_| ReserveError::Unauthorized)?; + + let mut curve = get_reserve_factor_curve(env, asset.clone()); + curve.min_bps = min_bps; + curve.max_bps = max_bps; + validate_curve(&curve)?; + + env.storage() + .persistent() + .set(&ReserveDataKey::ReserveFactorCurve(asset.clone()), &curve); + + let topics = (Symbol::new(env, "reserve_bounds_set"), caller); + env.events().publish(topics, (asset, min_bps, max_bps)); + + Ok(()) +} + +pub fn get_reserve_factor_curve(env: &Env, asset: Option
) -> ReserveFactorCurve { + env.storage() + .persistent() + .get(&ReserveDataKey::ReserveFactorCurve(asset)) + .unwrap_or_else(default_curve) +} + +pub fn calculate_dynamic_reserve_factor( + utilization_bps: i128, + curve: &ReserveFactorCurve, +) -> Result { + validate_curve(curve)?; + if !curve.enabled { + return Ok(curve.base_bps.clamp(curve.min_bps, curve.max_bps)); + } + + let utilization = utilization_bps.clamp(0, BASIS_POINTS_SCALE); + let mut factor = curve.base_bps; + + if utilization <= curve.kink_utilization_bps { + factor = factor + .checked_add( + utilization + .checked_mul(curve.slope_bps) + .ok_or(ReserveError::Overflow)? + .checked_div(curve.kink_utilization_bps) + .ok_or(ReserveError::Overflow)?, + ) + .ok_or(ReserveError::Overflow)?; + } else { + let above_kink = utilization + .checked_sub(curve.kink_utilization_bps) + .ok_or(ReserveError::Overflow)?; + let denominator = BASIS_POINTS_SCALE + .checked_sub(curve.kink_utilization_bps) + .ok_or(ReserveError::Overflow)?; + + factor = factor + .checked_add(curve.slope_bps) + .ok_or(ReserveError::Overflow)? + .checked_add( + above_kink + .checked_mul(curve.jump_slope_bps) + .ok_or(ReserveError::Overflow)? + .checked_div(denominator) + .ok_or(ReserveError::Overflow)?, + ) + .ok_or(ReserveError::Overflow)?; + } + + Ok(factor.clamp(curve.min_bps, curve.max_bps)) +} + +pub fn get_dynamic_reserve_factor( + env: &Env, + asset: Option
, +) -> Result { + let utilization = interest_rate::calculate_utilization(env).map_err(|_| ReserveError::Overflow)?; + let curve = get_reserve_factor_curve(env, asset); + calculate_dynamic_reserve_factor(utilization, &curve) +} + +pub fn preview_reserve_factor( + env: &Env, + asset: Option
, + utilization_bps: Option, +) -> Result { + let utilization = utilization_bps + .unwrap_or(interest_rate::calculate_utilization(env).map_err(|_| ReserveError::Overflow)?) + .clamp(0, BASIS_POINTS_SCALE); + let curve = get_reserve_factor_curve(env, asset.clone()); + let dynamic = calculate_dynamic_reserve_factor(utilization, &curve)?; + let static_factor = crate::reserve::get_static_reserve_factor(env, asset); + + Ok(ReserveFactorPreview { + utilization_bps: utilization, + reserve_factor_bps: dynamic, + static_reserve_factor_bps: static_factor, + revenue_delta_bps: dynamic - static_factor, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn curve() -> ReserveFactorCurve { + ReserveFactorCurve { + base_bps: 500, + slope_bps: 700, + kink_utilization_bps: 8_000, + jump_slope_bps: 1_800, + min_bps: 250, + max_bps: 3_000, + enabled: true, + } + } + + #[test] + fn curve_scales_before_kink() { + let factor = calculate_dynamic_reserve_factor(4_000, &curve()).unwrap(); + assert_eq!(factor, 850); + } + + #[test] + fn curve_is_continuous_at_kink() { + let factor = calculate_dynamic_reserve_factor(8_000, &curve()).unwrap(); + assert_eq!(factor, 1_200); + } + + #[test] + fn curve_applies_jump_slope_after_kink() { + let factor = calculate_dynamic_reserve_factor(9_000, &curve()).unwrap(); + assert_eq!(factor, 2_100); + } + + #[test] + fn curve_respects_governance_bounds() { + let mut bounded = curve(); + bounded.max_bps = 1_500; + assert_eq!( + calculate_dynamic_reserve_factor(10_000, &bounded).unwrap(), + 1_500 + ); + + bounded.min_bps = 900; + assert_eq!( + calculate_dynamic_reserve_factor(0, &bounded).unwrap(), + 900 + ); + } +} diff --git a/stellar-lend/contracts/hello-world/src/traits/mod.rs b/stellar-lend/contracts/hello-world/src/traits/mod.rs new file mode 100644 index 00000000..67eb0967 --- /dev/null +++ b/stellar-lend/contracts/hello-world/src/traits/mod.rs @@ -0,0 +1,41 @@ +use soroban_sdk::{Address, Env}; + +pub trait InterestModel { + type Error; + + fn utilization(&self, env: &Env) -> Result; + fn borrow_rate(&self, env: &Env) -> Result; +} + +pub trait LiquidationStrategy { + type Error; + + fn dynamic_penalty( + &self, + env: &Env, + collateral_value: i128, + total_debt: i128, + ) -> Result; +} + +pub trait FeeCalculator { + type Error; + + fn reserve_factor(&self, env: &Env, asset: Option
) -> Result; +} + +pub trait RiskParameters { + type Error; + type Params; + + fn params(&self, env: &Env) -> Result; +} + +pub fn registered_modules() -> (&'static str, &'static str, &'static str, &'static str) { + ( + "default-interest-model/v1", + "default-liquidation-strategy/v1", + "dynamic-reserve-factor/v1", + "default-risk-parameters/v1", + ) +} diff --git a/stellar-lend/packages/oracle-client/Cargo.toml b/stellar-lend/packages/oracle-client/Cargo.toml new file mode 100644 index 00000000..dba6c1c0 --- /dev/null +++ b/stellar-lend/packages/oracle-client/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "stellarlend-oracle-client" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +soroban-sdk = { workspace = true } diff --git a/stellar-lend/packages/oracle-client/src/fallback.rs b/stellar-lend/packages/oracle-client/src/fallback.rs new file mode 100644 index 00000000..a7d1ecb5 --- /dev/null +++ b/stellar-lend/packages/oracle-client/src/fallback.rs @@ -0,0 +1,45 @@ +use soroban_sdk::{Address, Env}; + +use crate::{ + validate_price, OracleClientError, OracleClientConfig, OraclePrice, PriceCache, PriceOracle, +}; + +pub fn resolve_with_fallback( + env: &Env, + asset: &Address, + primary: &P, + secondary: Option<&S>, + cache: &C, + config: &OracleClientConfig, +) -> Result +where + P: PriceOracle, + S: PriceOracle, + C: PriceCache, +{ + if let Ok(price) = primary.price(env, asset) { + validate_price(env, &price, config.max_staleness_seconds)?; + cache.cache_price(env, asset, &price); + return Ok(price); + } + + if let Some(secondary) = secondary { + if let Ok(price) = secondary.price(env, asset) { + validate_price(env, &price, config.max_staleness_seconds)?; + cache.cache_price(env, asset, &price); + return Ok(price); + } + } + + if let Some(cached) = cache.cached_price(env, asset) { + let now = env.ledger().timestamp(); + if now >= cached.cached_at + && now - cached.cached_at <= config.cache_ttl_seconds + && validate_price(env, &cached.price, config.max_staleness_seconds).is_ok() + { + return Ok(cached.price); + } + } + + Err(OracleClientError::NoPriceAvailable) +} diff --git a/stellar-lend/packages/oracle-client/src/lib.rs b/stellar-lend/packages/oracle-client/src/lib.rs new file mode 100644 index 00000000..8836d31e --- /dev/null +++ b/stellar-lend/packages/oracle-client/src/lib.rs @@ -0,0 +1,137 @@ +#![no_std] + +use soroban_sdk::{contracttype, Address, Env, Vec}; + +pub mod fallback; + +pub const PRICE_ORACLE_INTERFACE_VERSION: u32 = 1; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OraclePrice { + pub price: i128, + pub updated_at: u64, + pub source: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleClientConfig { + pub max_staleness_seconds: u64, + pub cache_ttl_seconds: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CachedOraclePrice { + pub price: OraclePrice, + pub cached_at: u64, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum OracleClientError { + UnsupportedVersion = 1, + InvalidPrice = 2, + StalePrice = 3, + PrimaryUnavailable = 4, + SecondaryUnavailable = 5, + CacheMiss = 6, + CacheStale = 7, + NoPriceAvailable = 8, +} + +pub trait PriceOracle { + fn interface_version(&self) -> u32; + fn price(&self, env: &Env, asset: &Address) -> Result; +} + +pub fn validate_version(version: u32) -> Result<(), OracleClientError> { + if version == PRICE_ORACLE_INTERFACE_VERSION { + Ok(()) + } else { + Err(OracleClientError::UnsupportedVersion) + } +} + +pub fn validate_price( + env: &Env, + price: &OraclePrice, + max_staleness_seconds: u64, +) -> Result<(), OracleClientError> { + if price.price <= 0 { + return Err(OracleClientError::InvalidPrice); + } + + let now = env.ledger().timestamp(); + if price.updated_at > now || now.saturating_sub(price.updated_at) > max_staleness_seconds { + return Err(OracleClientError::StalePrice); + } + + Ok(()) +} + +pub trait PriceCache { + fn cached_price(&self, env: &Env, asset: &Address) -> Option; + fn cache_price(&self, env: &Env, asset: &Address, price: &OraclePrice); +} + +pub struct FallbackOracleClient { + pub primary: P, + pub secondary: Option, + pub cache: C, + pub config: OracleClientConfig, +} + +impl FallbackOracleClient +where + P: PriceOracle, + S: PriceOracle, + C: PriceCache, +{ + pub fn get_price(&self, env: &Env, asset: &Address) -> Result { + validate_version(self.primary.interface_version())?; + if let Ok(price) = self.primary.price(env, asset) { + validate_price(env, &price, self.config.max_staleness_seconds)?; + self.cache.cache_price(env, asset, &price); + return Ok(price); + } + + if let Some(secondary) = &self.secondary { + validate_version(secondary.interface_version())?; + if let Ok(price) = secondary.price(env, asset) { + validate_price(env, &price, self.config.max_staleness_seconds)?; + self.cache.cache_price(env, asset, &price); + return Ok(price); + } + } + + if let Some(cached) = self.cache.cached_price(env, asset) { + let now = env.ledger().timestamp(); + if now >= cached.cached_at + && now - cached.cached_at <= self.config.cache_ttl_seconds + && validate_price(env, &cached.price, self.config.max_staleness_seconds).is_ok() + { + return Ok(cached.price); + } + } + + Err(OracleClientError::NoPriceAvailable) + } +} + +pub fn first_valid_price( + env: &Env, + prices: &Vec, + max_staleness_seconds: u64, +) -> Result { + for i in 0..prices.len() { + let price = prices.get(i).ok_or(OracleClientError::NoPriceAvailable)?; + if validate_price(env, &price, max_staleness_seconds).is_ok() { + return Ok(price); + } + } + + Err(OracleClientError::NoPriceAvailable) +}