From 9b195aaba8427096cfb5f87287afc8c6aa4baa29 Mon Sep 17 00:00:00 2001 From: david87131 Date: Wed, 29 Jul 2026 17:47:43 +0100 Subject: [PATCH 1/2] feat(contracts): add oracle staleness gate to lending_pool Add require_fresh_price to contracts/lending_pool: an admin-pushed price cache stamped with the recording ledger, a configurable max-age (default 60 ledgers), and a PoolError::OracleStale revert when a cached price is older than that threshold. Includes an admin-gated set_oracle_price / set_oracle_max_age pair and unit tests covering fresh, stale, boundary, missing-price, and auth-gated cases. This is a scoped Phase 1 slice of #1379: lending_pool has no live oracle integration today (rate logic lives in loan_manager, which only exposes a raw rate with no staleness metadata), so this establishes the reusable freshness-check primitive called out in the issue's file list without attempting the full cross-layer circuit breaker. Addresses #1379 --- contracts/lending_pool/src/lib.rs | 45 ++++++++++++ contracts/lending_pool/src/oracle.rs | 80 +++++++++++++++++++++ contracts/lending_pool/src/test.rs | 100 ++++++++++++++++++++++++++- 3 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 contracts/lending_pool/src/oracle.rs diff --git a/contracts/lending_pool/src/lib.rs b/contracts/lending_pool/src/lib.rs index cd4ce7de..19bb51ab 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 { @@ -21,6 +24,8 @@ pub enum PoolError { InvalidMaxPoolSize = 9, NoProposedAdmin = 10, CooldownTooLong = 11, + /// #1379 — cached oracle price is older than the configured max age. + OracleStale = 12, } /// Storage keys. @@ -57,6 +62,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] @@ -790,6 +800,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 79b6d717..feb35282 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; @@ -1618,6 +1618,102 @@ 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))); +} + #[test] fn test_deposit_transfers_from_depositor_to_pool() { let env = Env::default(); @@ -1643,4 +1739,4 @@ fn test_deposit_transfers_from_depositor_to_pool() { // Assert shares were minted to depositor assert!(shares > 0); assert_eq!(client.share_balance_of(&depositor), shares); -} \ No newline at end of file +} From ae241edffe105ad34e4962f1f0c98692b4f48dc5 Mon Sep 17 00:00:00 2001 From: david87131 Date: Wed, 29 Jul 2026 17:58:08 +0100 Subject: [PATCH 2/2] fix(contracts): remove stray blank line in lending_pool test.rs cargo fmt --check was failing on lending_pool/src/test.rs due to a double blank line before the new oracle staleness test section. Addresses #1379 --- contracts/lending_pool/src/test.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/lending_pool/src/test.rs b/contracts/lending_pool/src/test.rs index feb35282..cc145f72 100644 --- a/contracts/lending_pool/src/test.rs +++ b/contracts/lending_pool/src/test.rs @@ -1618,7 +1618,6 @@ 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]