Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions contracts/lending_pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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<PriceData, PoolError> {
oracle::require_fresh_price(&env, &asset)
}
}

#[cfg(test)]
Expand Down
80 changes: 80 additions & 0 deletions contracts/lending_pool/src/oracle.rs
Original file line number Diff line number Diff line change
@@ -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<PriceData, PoolError> {
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)
}
97 changes: 96 additions & 1 deletion contracts/lending_pool/src/test.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading