diff --git a/contracts/lending_pool/src/lib.rs b/contracts/lending_pool/src/lib.rs index a955f5df..4caa3d72 100644 --- a/contracts/lending_pool/src/lib.rs +++ b/contracts/lending_pool/src/lib.rs @@ -8,6 +8,9 @@ use soroban_sdk::{ mod events; use events::*; +mod oracle; +pub use oracle::PriceData; + #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum PoolError { @@ -29,6 +32,8 @@ pub enum PoolError { /// The computed share/asset amount for an operation rounded down to /// zero, so no value would actually move. ZeroShares = 14, + /// #1379 — cached oracle price is older than the configured max age. + OracleStale = 15, } /// Storage keys. @@ -73,6 +78,11 @@ pub enum DataKey { TotalYieldDistributed(Address), ProposedAdmin, Version, + /// #1379 — asset → last cached oracle price + the ledger it was recorded at. + OraclePrice(Address), + /// #1379 — max age (in ledgers) a cached oracle price may have before + /// `require_fresh_price` rejects it as stale. + OracleMaxAge, } #[contracttype] @@ -994,6 +1004,41 @@ impl LendingPool { pub fn pool_balance(env: Env, token: Address) -> i128 { Self::read_pool_balance(&env, &token) } + + // ── Oracle staleness gate (#1379, Phase 1) ────────────────────────────── + // + // `lending_pool` has no live oracle integration today, so these entry + // points expose an admin-pushed price cache guarded by a freshness + // check. This is a foundational safety primitive, not the full + // circuit-breaker described in #1379 — see PR description for scope. + + /// Admin-only: record a new price for `asset`, stamped with the current + /// ledger. Stands in for a live oracle feed until one is wired up. + pub fn set_oracle_price(env: Env, asset: Address, rate: i128) { + let admin = Self::admin(&env); + oracle::set_oracle_price(&env, &admin, &asset, rate); + Self::bump_instance_ttl(&env); + } + + /// Admin-only: configure how many ledgers a cached oracle price may age + /// before it is treated as stale. + pub fn set_oracle_max_age(env: Env, max_age: u32) { + let admin = Self::admin(&env); + oracle::set_oracle_max_age(&env, &admin, max_age); + Self::bump_instance_ttl(&env); + } + + pub fn get_oracle_max_age(env: Env) -> u32 { + oracle::oracle_max_age(&env) + } + + /// Read the cached price for `asset`, reverting with + /// `PoolError::OracleStale` if it is older than the configured max age. + /// Rate-sensitive callers (e.g. a future `accrue_interest`/`borrow` + /// entrypoint) should call this before using the returned rate. + pub fn require_fresh_price(env: Env, asset: Address) -> Result { + oracle::require_fresh_price(&env, &asset) + } } #[cfg(test)] diff --git a/contracts/lending_pool/src/oracle.rs b/contracts/lending_pool/src/oracle.rs new file mode 100644 index 00000000..acfb9a01 --- /dev/null +++ b/contracts/lending_pool/src/oracle.rs @@ -0,0 +1,80 @@ +// Oracle price-freshness gate (issue #1379, Phase 1 — contract-side only). +// +// `lending_pool` does not currently call out to a live on-chain rate oracle +// (rate logic lives in `loan_manager`, which only receives a raw `u32` rate +// with no staleness metadata at all). This module adds the reusable +// staleness-check primitive called out in the issue's file list +// (`contracts/lending_pool/src/oracle.rs`): an admin-pushed price cache with +// an `updated_ledger` watermark, and a `require_fresh_price` gate that +// reverts once a cached price is older than a configurable max age. +// +// This is intentionally narrow: it does not implement the pause/resume +// circuit-breaker state machine, event emission for staleness, or wiring +// into `loan_manager`'s borrow/liquidate paths — see the PR description for +// what is deferred to follow-up phases. + +use crate::{DataKey, PoolError}; +use soroban_sdk::{contracttype, Address, Env}; + +/// Default maximum age (in ledgers) a cached oracle price may have before +/// `require_fresh_price` rejects it. ~5 minutes at a 5s ledger close time. +pub const DEFAULT_ORACLE_MAX_AGE_LEDGERS: u32 = 60; + +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PriceData { + pub rate: i128, + pub updated_ledger: u32, +} + +/// Admin-only: push a new price for `asset`, stamped with the current ledger +/// sequence. Stands in for a live oracle feed until this contract is wired +/// to one in a later phase. +pub fn set_oracle_price(env: &Env, admin: &Address, asset: &Address, rate: i128) { + admin.require_auth(); + let price = PriceData { + rate, + updated_ledger: env.ledger().sequence(), + }; + env.storage() + .instance() + .set(&DataKey::OraclePrice(asset.clone()), &price); +} + +/// Admin-only: configure the maximum age (in ledgers) a cached price may +/// have before it is considered stale. +pub fn set_oracle_max_age(env: &Env, admin: &Address, max_age: u32) { + admin.require_auth(); + env.storage() + .instance() + .set(&DataKey::OracleMaxAge, &max_age); +} + +/// Read the configured max age, falling back to `DEFAULT_ORACLE_MAX_AGE_LEDGERS`. +pub fn oracle_max_age(env: &Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::OracleMaxAge) + .unwrap_or(DEFAULT_ORACLE_MAX_AGE_LEDGERS) +} + +/// Read the cached price for `asset` and revert with `PoolError::OracleStale` +/// if it is older than the configured max age, or `PoolError::NotInitialized` +/// if no price has ever been recorded for this asset. +pub fn require_fresh_price(env: &Env, asset: &Address) -> Result { + let price: PriceData = env + .storage() + .instance() + .get(&DataKey::OraclePrice(asset.clone())) + .ok_or(PoolError::NotInitialized)?; + + let current_ledger = env.ledger().sequence(); + let age = current_ledger.saturating_sub(price.updated_ledger); + let max_age = oracle_max_age(env); + + if age > max_age { + return Err(PoolError::OracleStale); + } + + Ok(price) +} diff --git a/contracts/lending_pool/src/test.rs b/contracts/lending_pool/src/test.rs index 68085d90..098500e1 100644 --- a/contracts/lending_pool/src/test.rs +++ b/contracts/lending_pool/src/test.rs @@ -1,4 +1,4 @@ -use crate::{events, LendingPool, LendingPoolClient}; +use crate::{events, LendingPool, LendingPoolClient, PoolError}; use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _}; use soroban_sdk::token::Client as TokenClient; use soroban_sdk::token::StellarAssetClient; @@ -1668,6 +1668,101 @@ fn test_adjust_outstanding_zero_delta_is_a_no_op() { assert_eq!(pool_client.get_total_outstanding(&token), 1_000); } +// ── Oracle staleness gate (#1379, Phase 1) ────────────────────────────────── + +#[test] +fn test_fresh_oracle_price_passes_staleness_check() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let asset = Address::generate(&env); + let pool_id = env.register(LendingPool, ()); + let pool_client = LendingPoolClient::new(&env, &pool_id); + pool_client.initialize(&admin); + + pool_client.set_oracle_price(&asset, &1_500); + + let price = pool_client.require_fresh_price(&asset); + assert_eq!(price.rate, 1_500); + assert_eq!(price.updated_ledger, env.ledger().sequence()); +} + +#[test] +fn test_stale_oracle_price_reverts_with_oracle_stale() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let asset = Address::generate(&env); + let pool_id = env.register(LendingPool, ()); + let pool_client = LendingPoolClient::new(&env, &pool_id); + pool_client.initialize(&admin); + + pool_client.set_oracle_max_age(&5); + pool_client.set_oracle_price(&asset, &1_500); + + env.ledger().with_mut(|l| { + l.sequence_number += 6; + }); + + let result = pool_client.try_require_fresh_price(&asset); + assert_eq!(result, Err(Ok(PoolError::OracleStale))); +} + +#[test] +fn test_oracle_price_within_max_age_boundary_passes() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let asset = Address::generate(&env); + let pool_id = env.register(LendingPool, ()); + let pool_client = LendingPoolClient::new(&env, &pool_id); + pool_client.initialize(&admin); + + pool_client.set_oracle_max_age(&5); + pool_client.set_oracle_price(&asset, &1_500); + + env.ledger().with_mut(|l| { + l.sequence_number += 5; + }); + + let price = pool_client.require_fresh_price(&asset); + assert_eq!(price.rate, 1_500); +} + +#[test] +fn test_set_oracle_price_requires_admin_auth() { + let env = Env::default(); + let admin = Address::generate(&env); + let asset = Address::generate(&env); + let pool_id = env.register(LendingPool, ()); + let pool_client = LendingPoolClient::new(&env, &pool_id); + + env.mock_all_auths(); + pool_client.initialize(&admin); + + env.mock_auths(&[]); + let result = pool_client.try_set_oracle_price(&asset, &1_500); + assert!(result.is_err()); +} + +#[test] +fn test_require_fresh_price_with_no_recorded_price_errors() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let asset = Address::generate(&env); + let pool_id = env.register(LendingPool, ()); + let pool_client = LendingPoolClient::new(&env, &pool_id); + pool_client.initialize(&admin); + + let result = pool_client.try_require_fresh_price(&asset); + assert_eq!(result, Err(Ok(PoolError::NotInitialized))); +} + // ── #1380: slippage bounds & virtual-share/asset offset ─────────────────────── // // These tests reproduce the single-ledger share-price manipulation described