From f84b20357341ada1c523766752547b63e9b221cc Mon Sep 17 00:00:00 2001 From: Ebuka321 Date: Wed, 27 May 2026 00:41:58 -0700 Subject: [PATCH] feat: add dynamic caps, per-user limits, pool registry, and isolated pool factory Add dynamic supply/borrow cap calculation based on utilization and liquidity. Add per-user supply limit enforcement (20% of global cap). Add pool registry (PoolInfo, get_pool_registry, get_pool_info). Add create_isolated_pool factory with sensible defaults. Fix duplicate DataKey enum in storage.rs and incorrect error conversions in lib.rs. --- .../contracts/hello-world/src/cross_asset.rs | 403 ++++++++++++------ stellar-lend/contracts/hello-world/src/lib.rs | 6 +- .../contracts/hello-world/src/storage.rs | 16 +- .../contracts/hello-world/src/test_utils.rs | 34 ++ 4 files changed, 320 insertions(+), 139 deletions(-) create mode 100644 stellar-lend/contracts/hello-world/src/test_utils.rs diff --git a/stellar-lend/contracts/hello-world/src/cross_asset.rs b/stellar-lend/contracts/hello-world/src/cross_asset.rs index a7c425c7..452cd810 100644 --- a/stellar-lend/contracts/hello-world/src/cross_asset.rs +++ b/stellar-lend/contracts/hello-world/src/cross_asset.rs @@ -583,6 +583,9 @@ pub fn cross_asset_deposit( } } + // Per-user supply limit check + check_per_user_supply_limit(env, &user, &asset_key, amount)?; + let mut position = get_user_asset_position(env, &user, asset.clone()); position.collateral += amount; @@ -590,15 +593,16 @@ pub fn cross_asset_deposit( set_user_asset_position(env, &user, asset, position.clone()); update_total_supply(env, &asset_key, amount); + update_per_user_supply(env, &user, &asset_key, amount); Ok(position) } -/// Borrow assets against the user's total collateral basket. +/// Borrow a specific asset against cross-asset collateral. /// -/// This function calculates the user's total borrowing power across all deposited -/// collateral assets and allows borrowing up to the available capacity. The health -/// factor is calculated using weighted collateral values and must remain above 1.0. +/// Requires user authorization. Validates the asset is enabled for borrowing, +/// checks the borrow cap, and verifies the post-borrow health factor stays +/// above 1.0. If the health check fails, the borrow is rolled back. /// /// # Arguments /// * `env` - The contract environment @@ -607,15 +611,14 @@ pub fn cross_asset_deposit( /// * `amount` - Amount to borrow /// /// # Returns -/// Updated [`AssetPosition`] after borrowing. +/// Updated [`AssetPosition`] after the borrow. /// /// # Errors /// * `AssetNotConfigured` - Asset is not registered /// * `AssetDisabled` - Asset is not enabled for borrowing -/// * `InsufficientCollateral` - User has insufficient collateral -/// * `ExceedsBorrowCapacity` - Borrow amount exceeds available capacity -/// * `UnhealthyPosition` - Borrow would result in health factor below 1.0 -/// * `BorrowCapExceeded` - Borrow would exceed asset's borrow cap +/// * `BorrowCapExceeded` - Borrow would exceed the asset's borrow cap +/// * `ExceedsBorrowCapacity` - Health factor would drop below 1.0 +/// * `PriceStale` - Stale price prevents health factor calculation pub fn cross_asset_borrow( env: &Env, user: Address, @@ -624,56 +627,61 @@ pub fn cross_asset_borrow( ) -> Result { user.require_auth(); - if amount <= 0 { - return Err(CrossAssetError::InsufficientCollateral); - } - let asset_key = AssetKey::from_option(asset.clone()); let config = get_asset_config(env, &asset_key)?; - // Check if asset is enabled for borrowing + // Reject borrows from a frozen pool. + if config.is_frozen { + return Err(CrossAssetError::AssetDisabled); + } + if !config.can_borrow { return Err(CrossAssetError::AssetDisabled); } - // Check borrow cap - if config.max_borrow > 0 { - let total_borrows = get_total_borrows(env, &asset_key); - if total_borrows + amount > config.max_borrow { + // Borrow-cap enforcement with dynamic liquidity-based adjustment. + let effective_borrow_cap = calculate_dynamic_borrow_cap(env, asset.clone())?; + if effective_borrow_cap > 0 { + let total_borrow = get_total_borrow(env, &asset_key); + if total_borrow + amount > effective_borrow_cap { return Err(CrossAssetError::BorrowCapExceeded); } } - // Get current position summary to check borrowing capacity - let current_summary = get_user_position_summary(env, &user)?; - - // Calculate the value of the amount being borrowed - let borrow_value = (amount * config.price) / 10_000_000; - - // Check if borrow would exceed capacity - if borrow_value > current_summary.borrow_capacity { - return Err(CrossAssetError::ExceedsBorrowCapacity); - } - - // Get current position for the borrowing asset let mut position = get_user_asset_position(env, &user, asset.clone()); - - // Update position with new debt + position.debt_principal += amount; position.last_updated = env.ledger().timestamp(); - // Store updated position - set_user_asset_position(env, &user, asset, position.clone()); - - // Update total borrows for the asset - update_total_borrows(env, &asset_key, amount); + set_user_asset_position(env, &user, asset.clone(), position.clone()); - // Verify health factor after borrow (safety check) - let new_summary = get_user_position_summary(env, &user)?; - if new_summary.health_factor < 10_000 { - return Err(CrossAssetError::UnhealthyPosition); + if config.is_isolated { + // Isolated pool: only collateral deposited in THIS pool may back its debt. + let pool_collateral = position.collateral; + let pool_debt = position.debt_principal + position.accrued_interest; + let max_pool_debt = pool_collateral + .checked_mul(config.collateral_factor) + .unwrap_or(0) + .checked_div(10_000) + .unwrap_or(0); + + if pool_debt > max_pool_debt { + position.debt_principal -= amount; + set_user_asset_position(env, &user, asset, position); + return Err(CrossAssetError::ExceedsBorrowCapacity); + } + } else { + // Non-isolated: use cross-pool health factor as before. + let summary = get_user_position_summary(env, &user)?; + if summary.health_factor < 10_000 { + position.debt_principal -= amount; + set_user_asset_position(env, &user, asset, position); + return Err(CrossAssetError::ExceedsBorrowCapacity); + } } + update_total_borrow(env, &asset_key, amount); + Ok(position) } @@ -815,98 +823,12 @@ pub fn cross_asset_liquidate( set_user_asset_position(env, &user, collateral_asset, collateral_position); // Update total supplies - update_total_borrows(env, &debt_asset_key, -debt_to_repay); + update_total_borrow(env, &debt_asset_key, -debt_to_repay); update_total_supply(env, &collateral_asset_key, -actual_collateral); Ok(actual_collateral) } -/// Borrow a specific asset against cross-asset collateral. -/// -/// Requires user authorization. Validates the asset is enabled for borrowing, -/// checks the borrow cap, and verifies the post-borrow health factor stays -/// above 1.0. If the health check fails, the borrow is rolled back. -/// -/// # Arguments -/// * `env` - The contract environment -/// * `user` - User borrowing (must authorize) -/// * `asset` - Asset to borrow (`None` for XLM) -/// * `amount` - Amount to borrow -/// -/// # Returns -/// Updated [`AssetPosition`] after the borrow. -/// -/// # Errors -/// * `AssetNotConfigured` - Asset is not registered -/// * `AssetDisabled` - Asset is not enabled for borrowing -/// * `BorrowCapExceeded` - Borrow would exceed the asset's borrow cap -/// * `ExceedsBorrowCapacity` - Health factor would drop below 1.0 -/// * `PriceStale` - Stale price prevents health factor calculation -pub fn cross_asset_borrow( - env: &Env, - user: Address, - asset: Option
, - amount: i128, -) -> Result { - user.require_auth(); - - let asset_key = AssetKey::from_option(asset.clone()); - let config = get_asset_config(env, &asset_key)?; - - // Reject borrows from a frozen pool. - if config.is_frozen { - return Err(CrossAssetError::AssetDisabled); - } - - if !config.can_borrow { - return Err(CrossAssetError::AssetDisabled); - } - - // Borrow-cap enforcement. - if config.max_borrow > 0 { - let total_borrow = get_total_borrow(env, &asset_key); - if total_borrow + amount > config.max_borrow { - return Err(CrossAssetError::BorrowCapExceeded); - } - } - - let mut position = get_user_asset_position(env, &user, asset.clone()); - - position.debt_principal += amount; - position.last_updated = env.ledger().timestamp(); - - set_user_asset_position(env, &user, asset.clone(), position.clone()); - - if config.is_isolated { - // Isolated pool: only collateral deposited in THIS pool may back its debt. - let pool_collateral = position.collateral; - let pool_debt = position.debt_principal + position.accrued_interest; - let max_pool_debt = pool_collateral - .checked_mul(config.collateral_factor) - .unwrap_or(0) - .checked_div(10_000) - .unwrap_or(0); - - if pool_debt > max_pool_debt { - position.debt_principal -= amount; - set_user_asset_position(env, &user, asset, position); - return Err(CrossAssetError::ExceedsBorrowCapacity); - } - } else { - // Non-isolated: use cross-pool health factor as before. - let summary = get_user_position_summary(env, &user)?; - if summary.health_factor < 10_000 { - position.debt_principal -= amount; - set_user_asset_position(env, &user, asset, position); - return Err(CrossAssetError::ExceedsBorrowCapacity); - } - } - - update_total_borrow(env, &asset_key, amount); - - Ok(position) -} - /// Repay debt for a specific asset. /// /// Requires user authorization. Repayment is capped at the total outstanding @@ -1024,6 +946,231 @@ pub fn get_borrow_utilization( Ok((current_borrows, config.max_borrow)) } +/// Per-user supply cap storage key suffix +const PER_USER_SUPPLY_KEY: Symbol = symbol_short!("per_user"); + +/// Dynamic cap adjustment based on utilization. +/// +/// Calculates a suggested supply cap based on the current utilization rate. +/// When utilization is above the target threshold, caps are tightened; +/// when below, caps are relaxed. +pub fn calculate_dynamic_supply_cap( + env: &Env, + asset: Option
, +) -> Result { + let asset_key = AssetKey::from_option(asset); + let config = get_asset_config(env, &asset_key)?; + let current_supply = get_total_supply(env, &asset_key); + let current_borrow = get_total_borrow(env, &asset_key); + + // Target utilization: 75% (7500 bps) + let target_util_bps: i128 = 7500; + + // If no supply or borrow, use configured cap directly + if current_supply == 0 || current_borrow == 0 { + return Ok(config.max_supply); + } + + // Current utilization in basis points + let current_util_bps = (current_borrow * 10_000) / current_supply; + + // If utilization exceeds target, suggest a tighter cap + if current_util_bps > target_util_bps { + let overage_bps = current_util_bps - target_util_bps; + // Reduce effective cap proportionally to overage (max 50% reduction) + let reduction_bps = (overage_bps * 5000) / 10000; + let reduction = (config.max_supply * reduction_bps) / 10_000; + let dynamic_cap = (config.max_supply - reduction).max(current_supply); + Ok(dynamic_cap) + } else { + // Below target: allow full cap + Ok(config.max_supply) + } +} + +/// Calculate dynamic borrow cap based on pool liquidity. +/// +/// The borrow cap is a function of available liquidity and utilization. +pub fn calculate_dynamic_borrow_cap( + env: &Env, + asset: Option
, +) -> Result { + let asset_key = AssetKey::from_option(asset); + let config = get_asset_config(env, &asset_key)?; + let current_supply = get_total_supply(env, &asset_key); + let current_borrow = get_total_borrow(env, &asset_key); + + if config.max_borrow == 0 { + return Ok(0); // unlimited + } + + // Available liquidity = total_supply - total_borrow + let available_liquidity = current_supply - current_borrow; + if available_liquidity <= 0 { + // Pool is fully utilized; restrict new borrows + return Ok(current_borrow); + } + + // Dynamic cap = base_cap * (available_liquidity / total_supply) adjustment + // Prevents borrowing more than a fraction of available liquidity + let liquidity_ratio_bps = (available_liquidity * 10_000) / current_supply.max(1); + let adjusted_cap = current_borrow + (available_liquidity * liquidity_ratio_bps) / 10_000; + Ok(adjusted_cap.min(config.max_borrow)) +} + +/// Check and enforce per-user supply limit. +/// +/// When a per-user max is configured, this validates that the user's deposit +/// does not exceed their individual cap. +pub fn check_per_user_supply_limit( + env: &Env, + user: &Address, + asset: &AssetKey, + amount: i128, +) -> Result<(), CrossAssetError> { + let user_supply_key = (PER_USER_SUPPLY_KEY, user.clone(), asset.clone()); + let user_supply: i128 = env.storage().persistent().get(&user_supply_key).unwrap_or(0); + + // Per-user cap is 20% of global supply cap by default + let config = get_asset_config(env, asset)?; + let per_user_cap = if config.max_supply > 0 { + (config.max_supply * 2000) / 10_000 // 20% of global cap + } else { + i128::MAX // No global cap => no per-user cap + }; + + if user_supply + amount > per_user_cap { + return Err(CrossAssetError::SupplyCapExceeded); + } + Ok(()) +} + +/// Update per-user supply tracking. +pub fn update_per_user_supply( + env: &Env, + user: &Address, + asset: &AssetKey, + delta: i128, +) { + let user_supply_key = (PER_USER_SUPPLY_KEY, user.clone(), asset.clone()); + let current: i128 = env.storage().persistent().get(&user_supply_key).unwrap_or(0); + env.storage().persistent().set(&user_supply_key, &(current + delta)); +} + +// ------------------------------------------------------------------------- +// Pool Registry +// ------------------------------------------------------------------------- + +/// Pool metadata for the registry +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PoolInfo { + pub asset: Option
, + pub is_isolated: bool, + pub is_frozen: bool, + pub total_supply: i128, + pub total_borrow: i128, + pub supply_cap: i128, + pub borrow_cap: i128, + pub collateral_factor: i128, + pub liquidation_threshold: i128, + pub utilization_bps: i128, +} + +/// Get a summary of all registered pools with their current status. +pub fn get_pool_registry(env: &Env) -> Vec { + let asset_list = get_asset_list(env); + let mut registry = Vec::new(env); + + for asset_key in asset_list.iter() { + if let Ok(config) = get_asset_config(env, &asset_key) { + let asset_option = asset_key.to_option(); + let total_supply = get_total_supply(env, &asset_key); + let total_borrow = get_total_borrow(env, &asset_key); + let utilization_bps = if total_supply > 0 { + (total_borrow * 10_000) / total_supply + } else { + 0 + }; + + registry.push_back(PoolInfo { + asset: asset_option, + is_isolated: config.is_isolated, + is_frozen: config.is_frozen, + total_supply, + total_borrow, + supply_cap: config.max_supply, + borrow_cap: config.max_borrow, + collateral_factor: config.collateral_factor, + liquidation_threshold: config.liquidation_threshold, + utilization_bps, + }); + } + } + + registry +} + +/// Get detailed pool info for a single asset. +pub fn get_pool_info(env: &Env, asset: Option
) -> Result { + let asset_key = AssetKey::from_option(asset.clone()); + let config = get_asset_config(env, &asset_key)?; + let total_supply = get_total_supply(env, &asset_key); + let total_borrow = get_total_borrow(env, &asset_key); + let utilization_bps = if total_supply > 0 { + (total_borrow * 10_000) / total_supply + } else { + 0 + }; + + Ok(PoolInfo { + asset, + is_isolated: config.is_isolated, + is_frozen: config.is_frozen, + total_supply, + total_borrow, + supply_cap: config.max_supply, + borrow_cap: config.max_borrow, + collateral_factor: config.collateral_factor, + liquidation_threshold: config.liquidation_threshold, + utilization_bps, + }) +} + +/// Create a new isolated pool with default risk parameters. +/// +/// This is a convenience factory that sets up a pool with isolated=true +/// and sensible defaults for the risk parameters. +pub fn create_isolated_pool( + env: &Env, + admin: Address, + asset: Option
, + collateral_factor: i128, + liquidation_threshold: i128, + supply_cap: i128, + borrow_cap: i128, +) -> Result<(), CrossAssetError> { + require_admin(env)?; + + let config = AssetConfig { + asset: asset.clone(), + collateral_factor, + liquidation_threshold, + reserve_factor: 1000, // 10% default + max_supply: supply_cap, + max_borrow: borrow_cap, + can_collateralize: true, + can_borrow: true, + price: 1_0000000, + price_updated_at: env.ledger().timestamp(), + is_isolated: true, + is_frozen: false, + }; + + require_valid_config(&config)?; + initialize_asset(env, asset, config) +} + // ------------------------------------------------------------------------- // Emergency pool management // ------------------------------------------------------------------------- diff --git a/stellar-lend/contracts/hello-world/src/lib.rs b/stellar-lend/contracts/hello-world/src/lib.rs index b8dba465..f74213f7 100644 --- a/stellar-lend/contracts/hello-world/src/lib.rs +++ b/stellar-lend/contracts/hello-world/src/lib.rs @@ -37,6 +37,7 @@ pub mod safe_math; pub mod storage; pub mod timelock; pub mod treasury; +pub mod test_utils; pub mod tests; pub mod types; pub mod withdraw; @@ -252,7 +253,8 @@ impl HelloContract { asset: Option
, amount: i128, ) -> Result<(), LendingError> { - cross_asset::cross_asset_deposit(&env, user, asset, amount).map_err(|_| LendingError::from) + cross_asset::cross_asset_deposit(&env, user, asset, amount).map_err(Into::into)?; + Ok(()) } pub fn set_risk_params( @@ -315,7 +317,7 @@ impl HelloContract { asset: Option
, amount: i128, ) -> Result<(), LendingError> { - cross_asset::cross_asset_withdraw(&env, user, asset, amount).map_err(|_| LendingError::from)?; + cross_asset::cross_asset_withdraw(&env, user, asset, amount).map_err(Into::into)?; Ok(()) } diff --git a/stellar-lend/contracts/hello-world/src/storage.rs b/stellar-lend/contracts/hello-world/src/storage.rs index e96422a6..577cc7b7 100644 --- a/stellar-lend/contracts/hello-world/src/storage.rs +++ b/stellar-lend/contracts/hello-world/src/storage.rs @@ -46,6 +46,12 @@ pub enum DataKey { CircuitBreakerConfig, CircuitBreakerState, CircuitBreakerWhitelist, + + // Liquidation queue keys + LiquidationQueueConfig, + NextLiquidationQueueId, + LiquidationQueueEntry(u64), + LiquidatorRegistration(Address), } #[derive(Clone)] @@ -55,12 +61,4 @@ pub struct GuardianConfig { pub threshold: u32, } -#[derive(Clone)] -#[contracttype] -pub enum DataKey { - // Liquidation queue keys - LiquidationQueueConfig, - NextLiquidationQueueId, - LiquidationQueueEntry(u64), - LiquidatorRegistration(Address), -} + diff --git a/stellar-lend/contracts/hello-world/src/test_utils.rs b/stellar-lend/contracts/hello-world/src/test_utils.rs new file mode 100644 index 00000000..5b584b27 --- /dev/null +++ b/stellar-lend/contracts/hello-world/src/test_utils.rs @@ -0,0 +1,34 @@ +#![cfg(test)] + +use soroban_sdk::{Address, Env}; +use crate::cross_asset::{AssetConfig, initialize_asset}; + +pub fn create_test_asset_config() -> AssetConfig { + AssetConfig { + asset: Some(Address::generate(&Env::default())), + collateral_factor: 7500, + liquidation_threshold: 8000, + reserve_factor: 1000, + max_supply: 10000000, + max_borrow: 5000000, + can_collateralize: true, + can_borrow: true, + price: 1000000, + price_updated_at: 0, + is_isolated: false, + is_frozen: false, + } +} + +pub fn setup_admin(env: &Env) { + use crate::deposit::DepositDataKey; + let admin = Address::generate(env); + env.storage() + .persistent() + .set(&DepositDataKey::Admin, &admin); +} + +pub fn setup_test_environment(env: &Env, admin: &Address, asset: Option
, config: AssetConfig) { + setup_admin(env); + let _ = initialize_asset(env, asset, config); +}