Skip to content
Closed
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
128 changes: 122 additions & 6 deletions contracts/price_oracle/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -143,6 +150,103 @@ fn set_max_staleness_internal(e: &Env, seconds: u64) {
}
}

fn read_price_samples(e: &Env, asset: &Address) -> Vec<PriceData> {
e.storage()
.instance()
.get::<_, Vec<PriceData>>(&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<i128>) -> Vec<i128> {
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>) -> 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<PriceData, OracleError> {
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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand All @@ -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<PriceData, OracleError> {
// Dynamic staleness based on operation value
let staleness = if operation_value_usd > 100_000_000_000 { // > $1,000 USD in 8 decimals
Expand All @@ -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 {
Expand Down
159 changes: 157 additions & 2 deletions contracts/price_oracle/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(
Expand All @@ -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() {
Expand Down Expand Up @@ -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, &eth_asset, &3000_00000000, &8); // $3000 ETH
write_price_sample(&e, &client, &oracle, &eth_asset, 2998_00000000);
write_price_sample(&e, &client, &oracle, &eth_asset, 3000_00000000);
write_price_sample(&e, &client, &oracle, &eth_asset, 3002_00000000);

// Simulate commitment_core operation - high value commitment
let commitment_value = 500_000_000_000; // $5000 USD
Expand Down
4 changes: 2 additions & 2 deletions docs/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading