From ef1418b72cf7096ef7ded60f70bd304114f88b48 Mon Sep 17 00:00:00 2001 From: debanshuhalder Date: Tue, 20 Oct 2026 14:47:06 +0530 Subject: [PATCH] fix(lending): require fresh oracle prices for collateral --- contracts/lending/src/lib.rs | 73 +++++++++++++++++++---------------- contracts/lending/src/test.rs | 62 ++++++++++++++++++++++++----- 2 files changed, 93 insertions(+), 42 deletions(-) diff --git a/contracts/lending/src/lib.rs b/contracts/lending/src/lib.rs index 25208fc7..2eb3ceac 100644 --- a/contracts/lending/src/lib.rs +++ b/contracts/lending/src/lib.rs @@ -261,6 +261,8 @@ pub enum DataKey { PriceOracle, /// Aggregated oracle price samples for a collateral asset. OraclePriceSamples(Address), + /// Ledger timestamp at which the asset's current oracle price was posted. + OraclePriceUpdatedAt(Address), /// TWAP fallback price for a collateral asset. OracleTwapPrice(Address), /// Borrower Loyalty Rewards contract address @@ -297,6 +299,8 @@ const RATE_SWITCH_FEE_BPS: u32 = 50; /// Cooldown between rate switches: 24 hours in seconds. const RATE_SWITCH_COOLDOWN_SECS: u64 = 86_400; +/// Oracle collateral prices older than this cannot be used for borrowing. +const MAX_COLLATERAL_PRICE_AGE_SECS: u64 = 3_600; /// Default collateral factor for assets without explicit config: 75% LTV. const DEFAULT_COLLATERAL_FACTOR_BPS: u32 = 7500; @@ -905,6 +909,10 @@ impl LendingContract { env.storage() .instance() .set(&DataKey::OraclePriceSamples(asset.clone()), &prices); + env.storage().instance().set( + &DataKey::OraclePriceUpdatedAt(asset), + &env.ledger().timestamp(), + ); } /// Store a TWAP fallback price for an asset. @@ -927,6 +935,10 @@ impl LendingContract { env.storage() .instance() .set(&DataKey::OracleTwapPrice(asset.clone()), &price); + env.storage().instance().set( + &DataKey::OraclePriceUpdatedAt(asset), + &env.ledger().timestamp(), + ); } // ── Asset collateral configuration (multisig-gated) ─────────────────────── @@ -939,9 +951,8 @@ impl LendingContract { /// can be borrowed, in basis-points (e.g. 8000 = 80% LTV). /// Clamped to [MIN_COLLATERAL_FACTOR_BPS, MAX_COLLATERAL_FACTOR_BPS]. /// - /// `has_price_oracle` — if true, the contract expects a price feed from - /// the authorized oracle; if false, the asset is valued at face value - /// (i.e. 1 unit = 1 USD) for borrowing power calculations. + /// `has_price_oracle` — the asset must have an active oracle feed before + /// it can be used in collateral valuation. /// /// `volatility_bps` — estimated annualized volatility in bps, used in /// dynamic liquidation threshold calculations. @@ -1952,8 +1963,7 @@ impl LendingContract { /// Borrowing power = sum over entries of: /// (amount * price * collateral_factor_bps) / (PRICE_PRECISION * 10_000) /// - /// Where `price` is the oracle price if configured, or PRICE_PRECISION (1.0) - /// if no oracle is configured. + /// Where `price` is a fresh, registered oracle quote. fn compute_borrowing_power_from_entries(env: &Env, entries: &Vec) -> i128 { let mut total_power: i128 = 0; for entry in entries.iter() { @@ -1985,36 +1995,33 @@ impl LendingContract { /// For example, if 1 XLM = $0.10 USD, the price would be: /// (1 XLM in stroops = 10_000_000) * PRICE_PRECISION / 1 = 10_000_000 * 10_000_000 / 1 /// - /// But for collateral valuation, we use a simpler scheme: - /// - If the asset has an oracle, price is posted by the oracle - /// - If not, price = PRICE_PRECISION (i.e. face value = 1.0 base unit per asset unit) - /// - /// For now, without a live oracle, all assets are valued at face value (1:1). - /// This still allows testing the multi-collateral framework with different - /// collateral factors. + /// Only a recent quote for an asset with a configured oracle can be used. + /// Missing or stale prices fail closed instead of assuming face value. fn get_asset_price(env: &Env, asset: &Address, config: &AssetCollateralConfig) -> i128 { - if config.has_price_oracle { - let samples: Vec = env - .storage() - .instance() - .get(&DataKey::OraclePriceSamples(asset.clone())) - .unwrap_or(Vec::new(env)); - - if !samples.is_empty() { - return Self::median_price(env, &samples); - } - - if let Some(twap_price) = env - .storage() - .instance() - .get::(&DataKey::OracleTwapPrice(asset.clone())) - { - return twap_price; - } + if !config.has_price_oracle || !env.storage().instance().has(&DataKey::PriceOracle) { + panic!("Collateral asset has no active price oracle"); } - - // No oracle or no live price data — fall back to face value. - PRICE_PRECISION + let updated_at: u64 = env + .storage() + .instance() + .get(&DataKey::OraclePriceUpdatedAt(asset.clone())) + .expect("No oracle price registered for collateral asset"); + if env.ledger().timestamp().saturating_sub(updated_at) > MAX_COLLATERAL_PRICE_AGE_SECS { + panic!("Collateral oracle price is stale"); + } + let samples: Vec = env + .storage() + .instance() + .get(&DataKey::OraclePriceSamples(asset.clone())) + .unwrap_or(Vec::new(env)); + if !samples.is_empty() { + return Self::median_price(env, &samples); + } + env.storage() + .instance() + .get::(&DataKey::OracleTwapPrice(asset.clone())) + .filter(|price| *price > 0) + .expect("No oracle price registered for collateral asset") } fn median_price(env: &Env, prices: &Vec) -> i128 { diff --git a/contracts/lending/src/test.rs b/contracts/lending/src/test.rs index f95eb78e..dd8857ae 100644 --- a/contracts/lending/src/test.rs +++ b/contracts/lending/src/test.rs @@ -29,6 +29,14 @@ fn setup() -> (Env, Address, Address, Address, Address) { let collateral_asset = Address::generate(&env); client.whitelist_asset(&admin, &collateral_asset); + let price_oracle = admin.clone(); + client.set_price_oracle(&admin, &price_oracle); + client.set_asset_collateral_config( + &admin, + &collateral_asset, + &AssetCollateralConfig { collateral_factor_bps: 7500, has_price_oracle: true, volatility_bps: 0 }, + ); + client.set_asset_oracle_prices(&price_oracle, &collateral_asset, &vec![&env, PRICE_PRECISION]); link_reputation(&env, &client, &admin, &borrower); @@ -235,7 +243,7 @@ fn test_no_overflow_at_maximum_valid_inputs() { &collateral_asset, &AssetCollateralConfig { collateral_factor_bps: 9500, - has_price_oracle: false, + has_price_oracle: true, volatility_bps: 0, }, ); @@ -1672,6 +1680,16 @@ fn setup_multi_collateral() -> (Env, Address, Address, Address, Address, Address let asset_b = env.register_stellar_asset_contract_v2(asset_b_admin).address(); client.whitelist_asset(&admin, &asset_a); client.whitelist_asset(&admin, &asset_b); + let price_oracle = admin.clone(); + client.set_price_oracle(&admin, &price_oracle); + for asset in [&asset_a, &asset_b] { + client.set_asset_collateral_config( + &admin, + asset, + &AssetCollateralConfig { collateral_factor_bps: 7500, has_price_oracle: true, volatility_bps: 0 }, + ); + client.set_asset_oracle_prices(&price_oracle, asset, &vec![&env, PRICE_PRECISION]); + } // Give each test borrower ample funds so deposits exercise real transfers. let funding = 1_000_000_000_000_000i128; @@ -1914,7 +1932,7 @@ fn test_get_borrowing_power_single_asset() { &asset_a, &AssetCollateralConfig { collateral_factor_bps: 8000, // 80% LTV - has_price_oracle: false, + has_price_oracle: true, volatility_bps: 500, // 5% vol }, ); @@ -1941,7 +1959,7 @@ fn test_get_borrowing_power_multiple_assets() { &asset_b, &AssetCollateralConfig { collateral_factor_bps: 5000, // 50% LTV - has_price_oracle: false, + has_price_oracle: true, volatility_bps: 2000, // 20% vol }, ); @@ -1977,7 +1995,7 @@ fn test_set_asset_collateral_config_non_whitelisted_rejected() { &random_asset, &AssetCollateralConfig { collateral_factor_bps: 8000, - has_price_oracle: false, + has_price_oracle: true, volatility_bps: 500, }, ); @@ -1995,7 +2013,7 @@ fn test_set_asset_collateral_config_clamps_to_bounds() { &asset_a, &AssetCollateralConfig { collateral_factor_bps: 9600, // Above MAX - has_price_oracle: false, + has_price_oracle: true, volatility_bps: 500, }, ); @@ -2009,7 +2027,7 @@ fn test_set_asset_collateral_config_clamps_to_bounds() { &asset_a, &AssetCollateralConfig { collateral_factor_bps: 500, // Below MIN - has_price_oracle: false, + has_price_oracle: true, volatility_bps: 500, }, ); @@ -2095,6 +2113,32 @@ fn test_price_oracle_twap_fallback_is_used() { assert_eq!(value, 2_000_0000000); } +/// Collateral without an enabled oracle quote must never be assigned face value. +#[test] +#[should_panic(expected = "Collateral asset has no active price oracle")] +fn test_collateral_without_oracle_price_is_rejected() { + let (env, contract_id, admin, borrower, asset_a, _asset_b) = setup_multi_collateral(); + let client = LendingContractClient::new(&env, &contract_id); + client.set_asset_collateral_config( + &admin, + &asset_a, + &AssetCollateralConfig { collateral_factor_bps: 7500, has_price_oracle: false, volatility_bps: 0 }, + ); + client.deposit_collateral(&borrower, &asset_a, &1_000_0000000); + client.get_total_collateral_value(&borrower); +} + +/// A quote stops being usable after the configured freshness window. +#[test] +#[should_panic(expected = "Collateral oracle price is stale")] +fn test_stale_collateral_oracle_price_is_rejected() { + let (env, contract_id, _admin, borrower, asset_a, _asset_b) = setup_multi_collateral(); + let client = LendingContractClient::new(&env, &contract_id); + client.deposit_collateral(&borrower, &asset_a, &1_000_0000000); + env.ledger().set_timestamp(MAX_COLLATERAL_PRICE_AGE_SECS + 1); + client.get_total_collateral_value(&borrower); +} + /// Only multisig admin can set the price oracle. #[test] #[should_panic(expected = "Unauthorised")] @@ -2120,7 +2164,7 @@ fn test_set_asset_collateral_config_non_admin_rejected() { &asset_a, &AssetCollateralConfig { collateral_factor_bps: 8000, - has_price_oracle: false, + has_price_oracle: true, volatility_bps: 500, }, ); @@ -2138,7 +2182,7 @@ fn test_create_loan_request_checks_borrowing_power() { &asset_a, &AssetCollateralConfig { collateral_factor_bps: 1000, // 10% LTV - has_price_oracle: false, + has_price_oracle: true, volatility_bps: 500, }, ); @@ -2183,7 +2227,7 @@ fn test_create_loan_request_with_multi_asset_collateral() { &asset_b, &AssetCollateralConfig { collateral_factor_bps: 5000, // 50% LTV - has_price_oracle: false, + has_price_oracle: true, volatility_bps: 1000, }, );