diff --git a/contracts/price_oracle/src/lib.rs b/contracts/price_oracle/src/lib.rs index 5392266b..59dc184f 100644 --- a/contracts/price_oracle/src/lib.rs +++ b/contracts/price_oracle/src/lib.rs @@ -10,8 +10,10 @@ //! This contract is a push-based oracle registry. It assumes: //! - Oracle addresses added by the admin are trusted to publish honest prices //! - Consumers enforce freshness through `get_price_valid` and `max_staleness_seconds` -//! - A single whitelisted oracle update can replace the latest value for an asset -//! - There is no medianization, TWAP, quorum, or cross-source reconciliation on-chain +//! - `get_price` and `get_price_valid` expose the latest whitelisted update for +//! low-value consumers. +//! - High-value readers use a bounded recent-sample median path that requires +//! multiple fresh samples before returning a price. //! //! Integrators should only use this contract when those trust assumptions are acceptable //! for their asset and risk model. See: @@ -23,6 +25,9 @@ use soroban_sdk::{ }; pub const CURRENT_VERSION: u32 = 1; +const PRICE_SAMPLE_CAP: u32 = 16; +const HIGH_VALUE_MIN_SAMPLES: u32 = 3; +const HIGH_VALUE_THRESHOLD_USD_8DEC: i128 = 10_000_000_000; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -70,6 +75,8 @@ pub enum DataKey { OracleWhitelist(Address), /// Price per asset: asset_address -> PriceData Price(Address), + /// Bounded recent price samples per asset for high-value median reads + PriceSamples(Address), /// Oracle configuration (v1+) OracleConfig, /// Contract version @@ -143,6 +150,103 @@ fn set_max_staleness_internal(e: &Env, seconds: u64) { } } +fn read_price_samples(e: &Env, asset: &Address) -> Vec { + e.storage() + .instance() + .get::<_, Vec>(&DataKey::PriceSamples(asset.clone())) + .unwrap_or(Vec::new(e)) +} + +fn push_price_sample(e: &Env, asset: &Address, data: &PriceData) { + let mut samples = read_price_samples(e, asset); + samples.push_back(data.clone()); + while samples.len() > PRICE_SAMPLE_CAP { + samples.remove(0); + } + e.storage() + .instance() + .set(&DataKey::PriceSamples(asset.clone()), &samples); +} + +fn sort_prices(prices: &Vec) -> Vec { + let mut sorted = prices.clone(); + let len = sorted.len(); + let mut i = 0; + while i < len { + let mut j = i + 1; + while j < len { + let left = sorted.get(i).unwrap(); + let right = sorted.get(j).unwrap(); + if right < left { + sorted.set(i, right); + sorted.set(j, left); + } + j += 1; + } + i += 1; + } + sorted +} + +fn median_price(prices: &Vec) -> i128 { + let sorted = sort_prices(prices); + let len = sorted.len(); + if len == 0 { + return 0; + } + + let mid = len / 2; + if len % 2 == 1 { + sorted.get(mid).unwrap() + } else { + let left = sorted.get(mid - 1).unwrap(); + let right = sorted.get(mid).unwrap(); + SafeMath::div(SafeMath::add(left, right), 2) + } +} + +fn compute_fresh_median( + e: &Env, + asset: &Address, + max_staleness_seconds: u64, + min_samples: u32, +) -> Result { + let now = e.ledger().timestamp(); + let samples = read_price_samples(e, asset); + let mut fresh_prices = Vec::new(e); + let mut latest_updated_at = 0u64; + let mut decimals = 0u32; + + for sample in samples.iter() { + if sample.price <= 0 { + continue; + } + if now < sample.updated_at || now - sample.updated_at > max_staleness_seconds { + continue; + } + if fresh_prices.is_empty() { + decimals = sample.decimals; + } + if sample.decimals != decimals { + continue; + } + if sample.updated_at > latest_updated_at { + latest_updated_at = sample.updated_at; + } + fresh_prices.push_back(sample.price); + } + + if fresh_prices.len() < min_samples { + return Err(OracleError::StalePrice); + } + + Ok(PriceData { + price: median_price(&fresh_prices), + updated_at: latest_updated_at, + decimals, + }) +} + fn require_admin_result(e: &Env, caller: &Address) -> Result<(), OracleError> { caller.require_auth(); let admin = e @@ -260,6 +364,7 @@ impl PriceOracleContract { e.storage() .instance() .set(&DataKey::Price(asset.clone()), &data); + push_price_sample(&e, &asset, &data); e.events().publish( (symbol_short!("PriceSet"), asset), (price, updated_at, decimals), @@ -560,7 +665,7 @@ impl PriceOracleContract { /// /// Provides enhanced validation for operations involving significant value: /// - Stricter staleness requirements (60 seconds for high-value ops) - /// - Price deviation checks against historical averages + /// - Median aggregation across recent fresh samples for high-value operations /// - Additional validation for critical financial operations /// /// # Parameters @@ -574,13 +679,14 @@ impl PriceOracleContract { /// /// # Security Notes /// - High-value operations require fresher price data - /// - Historical deviation checks prevent manipulation attacks + /// - Fresh-sample median checks prevent a single latest update from dominating + /// high-value reads. Normal-value reads preserve the latest-price path. /// - Use for settlements, large transfers, or critical operations pub fn get_price_high_value( e: Env, asset: Address, operation_value_usd: i128, - max_deviation_percent: u32, + _max_deviation_percent: u32, ) -> Result { // Dynamic staleness based on operation value let staleness = if operation_value_usd > 100_000_000_000 { // > $1,000 USD in 8 decimals @@ -591,7 +697,17 @@ impl PriceOracleContract { 900 // 15 minutes for normal value }; - let data = Self::get_price_valid(e, asset.clone(), Some(staleness))?; + let data = if operation_value_usd > HIGH_VALUE_THRESHOLD_USD_8DEC { + let configured_staleness = read_config(&e).max_staleness_seconds; + let sample_staleness = if configured_staleness < staleness { + configured_staleness + } else { + staleness + }; + compute_fresh_median(&e, &asset, sample_staleness, HIGH_VALUE_MIN_SAMPLES)? + } else { + Self::get_price_valid(e.clone(), asset.clone(), Some(staleness))? + }; // Additional high-value validations if data.price <= 0 { diff --git a/contracts/price_oracle/src/tests.rs b/contracts/price_oracle/src/tests.rs index 8cb32b88..aa49e154 100644 --- a/contracts/price_oracle/src/tests.rs +++ b/contracts/price_oracle/src/tests.rs @@ -74,6 +74,19 @@ fn upload_wasm(e: &Env) -> BytesN<32> { e.deployer().upload_contract_wasm(wasm) } +fn write_price_sample( + e: &Env, + client: &PriceOracleContractClient, + oracle: &Address, + asset: &Address, + price: i128, +) { + client.set_price(oracle, asset, &price, &8); + e.ledger().with_mut(|li| { + li.timestamp += 1; + }); +} + #[test] fn test_initialize() { let e = Env::default(); @@ -929,7 +942,9 @@ fn test_get_price_high_value_high_value() { PriceOracleContract::add_oracle(e.clone(), admin.clone(), oracle.clone()).unwrap(); }); - client.set_price(&oracle, &asset, &1000_00000000, &8); + write_price_sample(&e, &client, &oracle, &asset, 998_00000000); + write_price_sample(&e, &client, &oracle, &asset, 1000_00000000); + write_price_sample(&e, &client, &oracle, &asset, 1002_00000000); // High value operation ($2000 USD) should use 5-minute staleness let data = client.get_price_high_value( @@ -940,6 +955,144 @@ fn test_get_price_high_value_high_value() { assert_eq!(data.price, 1000_00000000); } +#[test] +fn test_get_price_for_high_value_requires_min_fresh_samples() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let oracle = Address::generate(&e); + let asset = Address::generate(&e); + let contract_id = e.register_contract(None, PriceOracleContract); + let client = PriceOracleContractClient::new(&e, &contract_id); + + e.as_contract(&contract_id, || { + PriceOracleContract::initialize(e.clone(), admin.clone()).unwrap(); + PriceOracleContract::add_oracle(e.clone(), admin.clone(), oracle.clone()).unwrap(); + }); + + write_price_sample(&e, &client, &oracle, &asset, 1000_00000000); + write_price_sample(&e, &client, &oracle, &asset, 1001_00000000); + + assert_eq!( + client.try_get_price_high_value( + &asset, + &200_000_000_000, + &10 + ), + Err(Ok(OracleError::StalePrice)) + ); +} + +#[test] +fn test_get_price_high_value_uses_odd_median_and_drops_outlier() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let oracle = Address::generate(&e); + let asset = Address::generate(&e); + let contract_id = e.register_contract(None, PriceOracleContract); + let client = PriceOracleContractClient::new(&e, &contract_id); + + e.as_contract(&contract_id, || { + PriceOracleContract::initialize(e.clone(), admin.clone()).unwrap(); + PriceOracleContract::add_oracle(e.clone(), admin.clone(), oracle.clone()).unwrap(); + }); + + write_price_sample(&e, &client, &oracle, &asset, 100_00000000); + write_price_sample(&e, &client, &oracle, &asset, 101_00000000); + write_price_sample(&e, &client, &oracle, &asset, 10_000_00000000); + + let data = client.get_price_high_value(&asset, &200_000_000_000, &10); + assert_eq!(data.price, 101_00000000); +} + +#[test] +fn test_get_price_high_value_uses_even_median_average() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let oracle = Address::generate(&e); + let asset = Address::generate(&e); + let contract_id = e.register_contract(None, PriceOracleContract); + let client = PriceOracleContractClient::new(&e, &contract_id); + + e.as_contract(&contract_id, || { + PriceOracleContract::initialize(e.clone(), admin.clone()).unwrap(); + PriceOracleContract::add_oracle(e.clone(), admin.clone(), oracle.clone()).unwrap(); + }); + + write_price_sample(&e, &client, &oracle, &asset, 100_00000000); + write_price_sample(&e, &client, &oracle, &asset, 101_00000000); + write_price_sample(&e, &client, &oracle, &asset, 102_00000000); + write_price_sample(&e, &client, &oracle, &asset, 10_000_00000000); + + let data = client.get_price_high_value(&asset, &200_000_000_000, &10); + assert_eq!(data.price, 101_50000000); +} + +#[test] +fn test_get_price_high_value_ignores_stale_samples() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let oracle = Address::generate(&e); + let asset = Address::generate(&e); + let contract_id = e.register_contract(None, PriceOracleContract); + let client = PriceOracleContractClient::new(&e, &contract_id); + + e.as_contract(&contract_id, || { + PriceOracleContract::initialize(e.clone(), admin.clone()).unwrap(); + PriceOracleContract::add_oracle(e.clone(), admin.clone(), oracle.clone()).unwrap(); + }); + + write_price_sample(&e, &client, &oracle, &asset, 100_00000000); + write_price_sample(&e, &client, &oracle, &asset, 101_00000000); + e.ledger().with_mut(|li| { + li.timestamp += 301; + }); + write_price_sample(&e, &client, &oracle, &asset, 102_00000000); + + assert_eq!( + client.try_get_price_high_value( + &asset, + &200_000_000_000, + &10 + ), + Err(Ok(OracleError::StalePrice)) + ); +} + +#[test] +fn test_get_price_for_high_value_even_median_overflow_rejects() { + let e = Env::default(); + let admin = Address::generate(&e); + let asset = Address::generate(&e); + let contract_id = e.register_contract(None, PriceOracleContract); + let client = PriceOracleContractClient::new(&e, &contract_id); + + e.as_contract(&contract_id, || { + PriceOracleContract::initialize(e.clone(), admin.clone()).unwrap(); + let mut samples = Vec::new(&e); + samples.push_back(PriceData { price: 1, updated_at: 1, decimals: 8 }); + samples.push_back(PriceData { price: i128::MAX, updated_at: 2, decimals: 8 }); + samples.push_back(PriceData { price: i128::MAX, updated_at: 3, decimals: 8 }); + samples.push_back(PriceData { price: i128::MAX, updated_at: 4, decimals: 8 }); + e.storage() + .instance() + .set(&DataKey::PriceSamples(asset.clone()), &samples); + }); + e.ledger().with_mut(|li| { + li.timestamp = 4; + }); + + let result = client.try_get_price_high_value( + &asset, + &200_000_000_000, + &10 + ); + assert!(result.is_err()); +} + #[test] #[should_panic(expected = "Error(Contract, #6)")] // StalePrice fn test_get_price_high_value_very_high_value_stale() { @@ -1041,7 +1194,9 @@ fn test_oracle_consumer_integration_scenario() { // Set realistic prices client.set_price(&oracle, &usdc_asset, &1_00000000, &8); // $1 USDC - client.set_price(&oracle, ð_asset, &3000_00000000, &8); // $3000 ETH + write_price_sample(&e, &client, &oracle, ð_asset, 2998_00000000); + write_price_sample(&e, &client, &oracle, ð_asset, 3000_00000000); + write_price_sample(&e, &client, &oracle, ð_asset, 3002_00000000); // Simulate commitment_core operation - high value commitment let commitment_value = 500_000_000_000; // $5000 USD diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index c7b53bb3..bb056932 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -72,8 +72,8 @@ ### Price oracle manipulation resistance assumptions - **Threat:** A compromised or malicious whitelisted oracle publishes a manipulated price, or a delayed price remains usable long enough to distort downstream settlement or accounting. -- **Mitigations:** Admin-managed oracle whitelist, `require_auth` on oracle/admin paths, non-negative price validation, and `get_price_valid` freshness checks that reject stale and future-dated prices. -- **Assumptions:** `price_oracle` is a trusted-publisher registry, not an on-chain aggregation engine. It does not implement TWAP, medianization, quorum, circuit breakers, or cross-source reconciliation. +- **Mitigations:** Admin-managed oracle whitelist, `require_auth` on oracle/admin paths, non-negative price validation, `get_price_valid` freshness checks that reject stale and future-dated prices, and a bounded recent-sample median path for high-value reads. +- **Assumptions:** `price_oracle` remains a trusted-publisher registry for `get_price` and `get_price_valid`; those low-level APIs intentionally return the latest accepted write. High-value consumers should use `get_price_high_value`, which requires at least three fresh positive samples and returns the median to reduce single-update manipulation risk. This is not a quorum over unique oracle identities and does not replace off-chain source governance or circuit breakers. - **Integrator responsibility:** Consumers must call `get_price_valid` with an appropriate staleness bound for the asset and use case; `get_price` is a raw read helper and does not enforce freshness. - **Freshness boundary matrix:** `get_price_valid` treats `current_time - updated_at < max_staleness_seconds` as fresh, accepts the exact boundary (`age == max_staleness_seconds`), rejects one second past the boundary (`age == max_staleness_seconds + 1`) with `StalePrice`, and rejects future-dated prices where `updated_at > current_time` with `StalePrice`. - **Legacy config compatibility:** When the structured `OracleConfig` key is absent, `read_config` falls back to the legacy `MaxStalenessSeconds` key and applies the same freshness-boundary matrix. Migration tests should continue to verify this fallback until all deployed legacy instances have been upgraded. diff --git a/docs/price_oracle/admin_controls.md b/docs/price_oracle/admin_controls.md index 62c16313..9746927b 100644 --- a/docs/price_oracle/admin_controls.md +++ b/docs/price_oracle/admin_controls.md @@ -16,6 +16,7 @@ The `price_oracle` contract is a trusted-publisher registry. Only addresses whit | `add_oracle(caller, oracle_address)` | Add a trusted price publisher | Admin require_auth | Whitelisted oracle can overwrite the latest price for any asset it updates | | `remove_oracle(caller, oracle_address)` | Remove a trusted price publisher | Admin require_auth | Prevents further updates from that address | | `set_admin(caller, new_admin)` | Transfer oracle admin authority | Admin require_auth | Transfers control over whitelist and configuration | +| `get_price_high_value(asset, operation_value_usd, max_deviation_percent)` | Read with high-value validation | Public read | Uses latest-price validation for normal values and requires a bounded fresh-sample median for high-value operations | ## Oracle Rotation @@ -28,7 +29,8 @@ Oracle rotation is performed by removing an old oracle address and adding a new ## Security Notes - Only the admin can modify the whitelist. Attempts by non-admins will fail with `Unauthorized`. - Whitelisted oracles are trusted to publish honest prices. Compromised oracles can overwrite the latest price for any asset. -- Downstream contracts should always use `get_price_valid` and set appropriate staleness windows. +- Downstream contracts should always use `get_price_valid` and set appropriate staleness windows for low-value reads. +- High-value downstream contracts should use `get_price_high_value`. That path requires at least three fresh positive samples for operation values above the high-value threshold and returns the median sample price, which limits the impact of a single outlier update. ## See Also - [CONTRACT_FUNCTIONS.md](../CONTRACT_FUNCTIONS.md#price_oracle)