From 9dafc92381ebf07bd0198754d1273e85bc557e1a Mon Sep 17 00:00:00 2001 From: James Akolo Date: Mon, 27 Jul 2026 16:50:03 +0100 Subject: [PATCH 1/4] feat: add token-symbol registry to price_oracle (#501) Adds canonical Address-to-Symbol registry for on-chain token resolution, eliminating hardcoded symbol assumptions in consumers. Includes register_token_symbol, resolve_symbol, and convert_by_address entrypoints. Closes #501 --- contracts/price_oracle/src/lib.rs | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/contracts/price_oracle/src/lib.rs b/contracts/price_oracle/src/lib.rs index da4f508..83e6aba 100644 --- a/contracts/price_oracle/src/lib.rs +++ b/contracts/price_oracle/src/lib.rs @@ -16,6 +16,7 @@ pub struct PriceData { pub enum DataKey { Admin, Price(Symbol, Symbol), + TokenSymbol(Address), } #[contract] @@ -106,6 +107,43 @@ impl PriceOracleContract { Ok(converted) } + /// Register a token address to its currency symbol. + /// Admin only. Used for address-based conversion lookups. + pub fn register_token_symbol( + env: Env, + admin: Address, + token: Address, + symbol: Symbol, + ) -> Result<(), KoraError> { + admin.require_auth(); + Self::require_admin(&env, &admin)?; + env.storage() + .persistent() + .set(&DataKey::TokenSymbol(token), &symbol); + Ok(()) + } + + /// Resolve a token address to its registered currency symbol. + pub fn resolve_symbol(env: Env, token: Address) -> Result { + env.storage() + .persistent() + .get(&DataKey::TokenSymbol(token)) + .ok_or(KoraError::InvalidAddress) + } + + /// Convert an amount using token addresses instead of symbols. + /// Internally resolves both addresses to symbols and delegates to convert. + pub fn convert_by_address( + env: Env, + amount: i128, + from_token: Address, + to_token: Address, + ) -> Result { + let from_symbol = Self::resolve_symbol(env.clone(), from_token)?; + let to_symbol = Self::resolve_symbol(env.clone(), to_token)?; + Self::convert(env, amount, from_symbol, to_symbol) + } + fn require_admin(env: &Env, caller: &Address) -> Result<(), KoraError> { let admin: Address = env .storage() @@ -194,4 +232,38 @@ mod tests { let result = client.try_get_price(&base, "e); assert!(result.is_err()); } + + #[test] + fn test_register_and_resolve_token_symbol() { + let (env, admin, client) = setup(); + let token_addr = Address::generate(&env); + let symbol = Symbol::new(&env, "USDC"); + client.register_token_symbol(&admin, &token_addr, &symbol); + let resolved = client.resolve_symbol(&token_addr); + assert_eq!(resolved, symbol); + } + + #[test] + fn test_resolve_unregistered_token_fails() { + let (env, _admin, client) = setup(); + let token_addr = Address::generate(&env); + let result = client.try_resolve_symbol(&token_addr); + assert!(result.is_err()); + } + + #[test] + fn test_convert_by_address() { + let (env, admin, client) = setup(); + let eurc_token = Address::generate(&env); + let usdc_token = Address::generate(&env); + let eurc_symbol = Symbol::new(&env, "EURC"); + let usdc_symbol = Symbol::new(&env, "USDC"); + + client.register_token_symbol(&admin, &eurc_token, &eurc_symbol); + client.register_token_symbol(&admin, &usdc_token, &usdc_symbol); + client.set_price(&admin, &eurc_symbol, &usdc_symbol, &11_000_000i128); + + let result = client.convert_by_address(&10_000_000i128, &eurc_token, &usdc_token); + assert_eq!(result, 11_000_000i128); + } } From 2f2c0f3194580b7ff8900b1ff76f8fa43b11cce7 Mon Sep 17 00:00:00 2001 From: James Akolo Date: Mon, 27 Jul 2026 17:01:25 +0100 Subject: [PATCH 2/4] feat: add circuit-breaker with max-deviation check to set_price (#500) Implements configurable maximum price deviation (default 10% / 1000 bps) that rejects price updates deviating beyond the threshold. Includes set_price_override for legitimate large moves bypassing the check. Closes #500 --- contracts/price_oracle/src/lib.rs | 165 ++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/contracts/price_oracle/src/lib.rs b/contracts/price_oracle/src/lib.rs index 83e6aba..09bc2e8 100644 --- a/contracts/price_oracle/src/lib.rs +++ b/contracts/price_oracle/src/lib.rs @@ -4,6 +4,7 @@ use kora_shared::errors::KoraError; use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol}; const MAX_STALENESS_SECS: u64 = 3600; +const DEFAULT_MAX_PRICE_DEVIATION_BPS: u32 = 1000; // 10% deviation #[contracttype] #[derive(Clone, Debug)] @@ -17,6 +18,7 @@ pub enum DataKey { Admin, Price(Symbol, Symbol), TokenSymbol(Address), + MaxDeviation, } #[contract] @@ -29,11 +31,15 @@ impl PriceOracleContract { return Err(KoraError::AlreadyInitialized); } env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .persistent() + .set(&DataKey::MaxDeviation, &DEFAULT_MAX_PRICE_DEVIATION_BPS); Ok(()) } /// Set a price for a currency pair. Admin only. /// Price is expressed as `base` units per 1 unit of `quote`, scaled by 1e7 (stroops). + /// Rejects prices that deviate more than MAX_PRICE_DEVIATION_BPS from the current stored price. pub fn set_price( env: Env, admin: Address, @@ -48,6 +54,34 @@ impl PriceOracleContract { return Err(KoraError::InvalidAmount); } + Self::check_price_deviation(&env, &base, "e, price)?; + + let data = PriceData { + price, + timestamp: env.ledger().timestamp(), + }; + env.storage() + .persistent() + .set(&DataKey::Price(base, quote), &data); + Ok(()) + } + + /// Set a price with override, bypassing deviation checks. + /// Admin only. Use for legitimate large moves (e.g., de-peg events). + pub fn set_price_override( + env: Env, + admin: Address, + base: Symbol, + quote: Symbol, + price: i128, + ) -> Result<(), KoraError> { + admin.require_auth(); + Self::require_admin(&env, &admin)?; + + if price <= 0 { + return Err(KoraError::InvalidAmount); + } + let data = PriceData { price, timestamp: env.ledger().timestamp(), @@ -58,6 +92,21 @@ impl PriceOracleContract { Ok(()) } + /// Set the maximum allowed price deviation in basis points. + /// Admin only. Default is 1000 (10%). + pub fn set_max_deviation( + env: Env, + admin: Address, + deviation_bps: u32, + ) -> Result<(), KoraError> { + admin.require_auth(); + Self::require_admin(&env, &admin)?; + env.storage() + .persistent() + .set(&DataKey::MaxDeviation, &deviation_bps); + Ok(()) + } + /// Get the price for a pair. Returns the price and its timestamp. /// Fails if the price is stale (older than MAX_STALENESS_SECS) or missing. pub fn get_price( @@ -144,6 +193,54 @@ impl PriceOracleContract { Self::convert(env, amount, from_symbol, to_symbol) } + fn get_max_deviation(env: &Env) -> u32 { + env.storage() + .persistent() + .get(&DataKey::MaxDeviation) + .unwrap_or(DEFAULT_MAX_PRICE_DEVIATION_BPS) + } + + fn check_price_deviation( + env: &Env, + base: &Symbol, + quote: &Symbol, + new_price: i128, + ) -> Result<(), KoraError> { + let max_deviation_bps = Self::get_max_deviation(env); + + if let Ok(old_data) = env + .storage() + .persistent() + .get::<_, PriceData>(&DataKey::Price(base.clone(), quote.clone())) + .ok_or(KoraError::InvalidAmount) + { + let old_price = old_data.price; + let deviation_bps = if new_price > old_price { + let increase = new_price + .checked_sub(old_price) + .ok_or(KoraError::ArithmeticOverflow)?; + increase + .checked_mul(10000) + .and_then(|v| v.checked_div(old_price)) + .ok_or(KoraError::ArithmeticOverflow)? as u32 + } else { + let decrease = old_price + .checked_sub(new_price) + .ok_or(KoraError::ArithmeticOverflow)?; + decrease + .checked_mul(10000) + .and_then(|v| v.checked_div(old_price)) + .ok_or(KoraError::ArithmeticOverflow)? as u32 + }; + + if deviation_bps > max_deviation_bps { + return Err(KoraError::InvalidAmount); + } + } + + Ok(()) + } + fn require_admin(env: &Env, caller: &Address) -> Result<(), KoraError> { let admin: Address = env .storage() @@ -266,4 +363,72 @@ mod tests { let result = client.convert_by_address(&10_000_000i128, &eurc_token, &usdc_token); assert_eq!(result, 11_000_000i128); } + + #[test] + fn test_price_within_deviation_succeeds() { + let (env, admin, client) = setup(); + let base = Symbol::new(&env, "EURC"); + let quote = Symbol::new(&env, "USDC"); + + client.set_price(&admin, &base, "e, &10_000_000i128); + + // 10% deviation allowed (default), new price 10.5M is within 10% + let result = client.try_set_price(&admin, &base, "e, &10_500_000i128); + assert!(result.is_ok()); + } + + #[test] + fn test_price_exceeding_deviation_rejected() { + let (env, admin, client) = setup(); + let base = Symbol::new(&env, "EURC"); + let quote = Symbol::new(&env, "USDC"); + + client.set_price(&admin, &base, "e, &10_000_000i128); + + // 10% deviation allowed (default), new price 11.5M exceeds 10% + let result = client.try_set_price(&admin, &base, "e, &11_500_000i128); + assert!(result.is_err()); + } + + #[test] + fn test_price_override_bypasses_deviation() { + let (env, admin, client) = setup(); + let base = Symbol::new(&env, "EURC"); + let quote = Symbol::new(&env, "USDC"); + + client.set_price(&admin, &base, "e, &10_000_000i128); + + // Exceeds deviation but override bypasses check + let result = client.try_set_price_override(&admin, &base, "e, &20_000_000i128); + assert!(result.is_ok()); + let data = client.get_price(&base, "e); + assert_eq!(data.price, 20_000_000i128); + } + + #[test] + fn test_set_max_deviation() { + let (env, admin, client) = setup(); + let base = Symbol::new(&env, "EURC"); + let quote = Symbol::new(&env, "USDC"); + + client.set_price(&admin, &base, "e, &10_000_000i128); + + // Set deviation to 5% (500 bps) + client.set_max_deviation(&admin, &500u32); + + // 7% increase should now fail (was within 10% before) + let result = client.try_set_price(&admin, &base, "e, &10_700_000i128); + assert!(result.is_err()); + } + + #[test] + fn test_first_price_always_succeeds() { + let (env, admin, client) = setup(); + let base = Symbol::new(&env, "EURC"); + let quote = Symbol::new(&env, "USDC"); + + // No previous price, should succeed regardless of value + let result = client.try_set_price(&admin, &base, "e, &100_000_000i128); + assert!(result.is_ok()); + } } From fe6340c02e51ca268f9b2aab4f32d805a221e0c3 Mon Sep 17 00:00:00 2001 From: James Akolo Date: Mon, 27 Jul 2026 17:04:33 +0100 Subject: [PATCH 3/4] feat: implement multi-feeder model with median aggregation (#499) Adds authorized feeder registry separate from admin. Multiple feeders can independently submit prices for pairs, with get_price returning the median across all active feeders. Includes add_feeder/remove_feeder management, and tests demonstrating single malicious feeder cannot unilaterally control the aggregated price. Closes #499 --- contracts/price_oracle/src/lib.rs | 286 ++++++++++++++++++++++++------ 1 file changed, 229 insertions(+), 57 deletions(-) diff --git a/contracts/price_oracle/src/lib.rs b/contracts/price_oracle/src/lib.rs index 09bc2e8..cb566d8 100644 --- a/contracts/price_oracle/src/lib.rs +++ b/contracts/price_oracle/src/lib.rs @@ -19,6 +19,9 @@ pub enum DataKey { Price(Symbol, Symbol), TokenSymbol(Address), MaxDeviation, + Feeder(Address), + FeederPrice(Symbol, Symbol, Address), + PriceFeeders(Symbol, Symbol), } #[contract] @@ -37,18 +40,18 @@ impl PriceOracleContract { Ok(()) } - /// Set a price for a currency pair. Admin only. + /// Set a price for a currency pair. Authorized feeders only. /// Price is expressed as `base` units per 1 unit of `quote`, scaled by 1e7 (stroops). - /// Rejects prices that deviate more than MAX_PRICE_DEVIATION_BPS from the current stored price. + /// Rejects prices that deviate more than MAX_PRICE_DEVIATION_BPS from the current aggregated price. pub fn set_price( env: Env, - admin: Address, + feeder: Address, base: Symbol, quote: Symbol, price: i128, ) -> Result<(), KoraError> { - admin.require_auth(); - Self::require_admin(&env, &admin)?; + feeder.require_auth(); + Self::require_feeder(&env, &feeder)?; if price <= 0 { return Err(KoraError::InvalidAmount); @@ -62,21 +65,38 @@ impl PriceOracleContract { }; env.storage() .persistent() - .set(&DataKey::Price(base, quote), &data); + .set( + &DataKey::FeederPrice(base.clone(), quote.clone(), feeder.clone()), + &data, + ); + + let mut feeders: Vec
= env + .storage() + .persistent() + .get(&DataKey::PriceFeeders(base.clone(), quote.clone())) + .unwrap_or_else(|| Vec::new(&env)); + + if !feeders.iter().any(|f| f == &feeder) { + feeders.push_back(feeder); + env.storage() + .persistent() + .set(&DataKey::PriceFeeders(base, quote), &feeders); + } + Ok(()) } /// Set a price with override, bypassing deviation checks. - /// Admin only. Use for legitimate large moves (e.g., de-peg events). + /// Authorized feeders only. Use for legitimate large moves (e.g., de-peg events). pub fn set_price_override( env: Env, - admin: Address, + feeder: Address, base: Symbol, quote: Symbol, price: i128, ) -> Result<(), KoraError> { - admin.require_auth(); - Self::require_admin(&env, &admin)?; + feeder.require_auth(); + Self::require_feeder(&env, &feeder)?; if price <= 0 { return Err(KoraError::InvalidAmount); @@ -88,7 +108,40 @@ impl PriceOracleContract { }; env.storage() .persistent() - .set(&DataKey::Price(base, quote), &data); + .set( + &DataKey::FeederPrice(base.clone(), quote.clone(), feeder.clone()), + &data, + ); + + let mut feeders: Vec
= env + .storage() + .persistent() + .get(&DataKey::PriceFeeders(base.clone(), quote.clone())) + .unwrap_or_else(|| Vec::new(&env)); + + if !feeders.iter().any(|f| f == &feeder) { + feeders.push_back(feeder); + env.storage() + .persistent() + .set(&DataKey::PriceFeeders(base, quote), &feeders); + } + + Ok(()) + } + + /// Add an authorized feeder. Admin only. + pub fn add_feeder(env: Env, admin: Address, feeder: Address) -> Result<(), KoraError> { + admin.require_auth(); + Self::require_admin(&env, &admin)?; + env.storage().persistent().set(&DataKey::Feeder(feeder), &true); + Ok(()) + } + + /// Remove an authorized feeder. Admin only. + pub fn remove_feeder(env: Env, admin: Address, feeder: Address) -> Result<(), KoraError> { + admin.require_auth(); + Self::require_admin(&env, &admin)?; + env.storage().persistent().remove(&DataKey::Feeder(feeder)); Ok(()) } @@ -107,28 +160,57 @@ impl PriceOracleContract { Ok(()) } - /// Get the price for a pair. Returns the price and its timestamp. - /// Fails if the price is stale (older than MAX_STALENESS_SECS) or missing. + /// Get the aggregated price for a pair (median of all active feeders). + /// Returns the median price and its oldest timestamp. + /// Fails if no feeders have submitted or prices are stale. pub fn get_price( env: Env, base: Symbol, quote: Symbol, ) -> Result { - let data: PriceData = env + let feeders: Vec
= env .storage() .persistent() - .get(&DataKey::Price(base.clone(), quote.clone())) + .get(&DataKey::PriceFeeders(base.clone(), quote.clone())) .ok_or(KoraError::InvalidAmount)?; - let age = env - .ledger() - .timestamp() - .saturating_sub(data.timestamp); - if age > MAX_STALENESS_SECS { - return Err(KoraError::InvoiceExpired); + if feeders.is_empty() { + return Err(KoraError::InvalidAmount); + } + + let mut prices: Vec = Vec::new(&env); + let mut min_timestamp = u64::MAX; + + for feeder in feeders.iter() { + if let Ok(data) = env + .storage() + .persistent() + .get::<_, PriceData>(&DataKey::FeederPrice(base.clone(), quote.clone(), feeder.clone())) + .ok_or(KoraError::InvalidAmount) + { + let age = env + .ledger() + .timestamp() + .saturating_sub(data.timestamp); + if age > MAX_STALENESS_SECS { + continue; + } + prices.push_back(data.price); + if data.timestamp < min_timestamp { + min_timestamp = data.timestamp; + } + } } - Ok(data) + if prices.is_empty() { + return Err(KoraError::InvalidAmount); + } + + let median = Self::calculate_median(&prices); + Ok(PriceData { + price: median, + timestamp: min_timestamp, + }) } /// Convert an amount from one currency to another using the stored price. @@ -208,12 +290,7 @@ impl PriceOracleContract { ) -> Result<(), KoraError> { let max_deviation_bps = Self::get_max_deviation(env); - if let Ok(old_data) = env - .storage() - .persistent() - .get::<_, PriceData>(&DataKey::Price(base.clone(), quote.clone())) - .ok_or(KoraError::InvalidAmount) - { + if let Ok(old_data) = Self::get_price(env.clone(), base.clone(), quote.clone()) { let old_price = old_data.price; let deviation_bps = if new_price > old_price { let increase = new_price @@ -241,6 +318,30 @@ impl PriceOracleContract { Ok(()) } + fn calculate_median(prices: &Vec) -> i128 { + let len = prices.len(); + if len == 0 { + return 0; + } + + let mut sorted = prices.clone(); + for i in 0..len { + for j in i..len { + if sorted.get(j).unwrap() < sorted.get(i).unwrap() { + let temp = *sorted.get(j).unwrap(); + sorted.set(j, *sorted.get(i).unwrap()); + sorted.set(i, temp); + } + } + } + + if len % 2 == 1 { + *sorted.get(len / 2).unwrap() + } else { + (*sorted.get(len / 2 - 1).unwrap() + *sorted.get(len / 2).unwrap()) / 2 + } + } + fn require_admin(env: &Env, caller: &Address) -> Result<(), KoraError> { let admin: Address = env .storage() @@ -252,6 +353,18 @@ impl PriceOracleContract { } Ok(()) } + + fn require_feeder(env: &Env, feeder: &Address) -> Result<(), KoraError> { + let is_feeder: bool = env + .storage() + .persistent() + .get(&DataKey::Feeder(feeder.clone())) + .unwrap_or(false); + if !is_feeder { + return Err(KoraError::RoleNotAssigned); + } + Ok(()) + } } #[cfg(test)] @@ -259,29 +372,31 @@ mod tests { use super::*; use soroban_sdk::{testutils::Address as _, Env, Symbol}; - fn setup() -> (Env, Address, PriceOracleContractClient<'static>) { + fn setup() -> (Env, Address, Address, PriceOracleContractClient<'static>) { let env = Env::default(); env.mock_all_auths(); let contract_id = env.register_contract(None, PriceOracleContract); let client = PriceOracleContractClient::new(&env, &contract_id); let admin = Address::generate(&env); + let feeder = Address::generate(&env); client.initialize(&admin); - (env, admin, client) + client.add_feeder(&admin, &feeder); + (env, admin, feeder, client) } #[test] fn test_set_and_get_price() { - let (env, admin, client) = setup(); + let (env, _admin, feeder, client) = setup(); let base = Symbol::new(&env, "EURC"); let quote = Symbol::new(&env, "USDC"); - client.set_price(&admin, &base, "e, &11_000_000i128); + client.set_price(&feeder, &base, "e, &11_000_000i128); let data = client.get_price(&base, "e); assert_eq!(data.price, 11_000_000i128); } #[test] fn test_convert_same_currency() { - let (env, _admin, client) = setup(); + let (env, _admin, _feeder, client) = setup(); let sym = Symbol::new(&env, "USDC"); let result = client.convert(&1_000_000i128, &sym, &sym); assert_eq!(result, 1_000_000i128); @@ -289,18 +404,17 @@ mod tests { #[test] fn test_convert_different_currency() { - let (env, admin, client) = setup(); + let (env, _admin, feeder, client) = setup(); let eurc = Symbol::new(&env, "EURC"); let usdc = Symbol::new(&env, "USDC"); - // 1 EURC = 1.1 USDC (11_000_000 stroops per 10_000_000) - client.set_price(&admin, &eurc, &usdc, &11_000_000i128); + client.set_price(&feeder, &eurc, &usdc, &11_000_000i128); let result = client.convert(&10_000_000i128, &eurc, &usdc); assert_eq!(result, 11_000_000i128); } #[test] fn test_get_price_missing_fails() { - let (env, _admin, client) = setup(); + let (env, _admin, _feeder, client) = setup(); let base = Symbol::new(&env, "XLM"); let quote = Symbol::new(&env, "USDC"); let result = client.try_get_price(&base, "e); @@ -310,10 +424,10 @@ mod tests { #[test] fn test_stale_price_rejected() { use soroban_sdk::testutils::{Ledger, LedgerInfo}; - let (env, admin, client) = setup(); + let (env, _admin, feeder, client) = setup(); let base = Symbol::new(&env, "EURC"); let quote = Symbol::new(&env, "USDC"); - client.set_price(&admin, &base, "e, &11_000_000i128); + client.set_price(&feeder, &base, "e, &11_000_000i128); env.ledger().set(LedgerInfo { timestamp: env.ledger().timestamp() + MAX_STALENESS_SECS + 1, @@ -332,7 +446,7 @@ mod tests { #[test] fn test_register_and_resolve_token_symbol() { - let (env, admin, client) = setup(); + let (env, admin, _feeder, client) = setup(); let token_addr = Address::generate(&env); let symbol = Symbol::new(&env, "USDC"); client.register_token_symbol(&admin, &token_addr, &symbol); @@ -342,7 +456,7 @@ mod tests { #[test] fn test_resolve_unregistered_token_fails() { - let (env, _admin, client) = setup(); + let (env, _admin, _feeder, client) = setup(); let token_addr = Address::generate(&env); let result = client.try_resolve_symbol(&token_addr); assert!(result.is_err()); @@ -350,7 +464,7 @@ mod tests { #[test] fn test_convert_by_address() { - let (env, admin, client) = setup(); + let (env, admin, feeder, client) = setup(); let eurc_token = Address::generate(&env); let usdc_token = Address::generate(&env); let eurc_symbol = Symbol::new(&env, "EURC"); @@ -358,7 +472,7 @@ mod tests { client.register_token_symbol(&admin, &eurc_token, &eurc_symbol); client.register_token_symbol(&admin, &usdc_token, &usdc_symbol); - client.set_price(&admin, &eurc_symbol, &usdc_symbol, &11_000_000i128); + client.set_price(&feeder, &eurc_symbol, &usdc_symbol, &11_000_000i128); let result = client.convert_by_address(&10_000_000i128, &eurc_token, &usdc_token); assert_eq!(result, 11_000_000i128); @@ -366,40 +480,40 @@ mod tests { #[test] fn test_price_within_deviation_succeeds() { - let (env, admin, client) = setup(); + let (env, _admin, feeder, client) = setup(); let base = Symbol::new(&env, "EURC"); let quote = Symbol::new(&env, "USDC"); - client.set_price(&admin, &base, "e, &10_000_000i128); + client.set_price(&feeder, &base, "e, &10_000_000i128); // 10% deviation allowed (default), new price 10.5M is within 10% - let result = client.try_set_price(&admin, &base, "e, &10_500_000i128); + let result = client.try_set_price(&feeder, &base, "e, &10_500_000i128); assert!(result.is_ok()); } #[test] fn test_price_exceeding_deviation_rejected() { - let (env, admin, client) = setup(); + let (env, _admin, feeder, client) = setup(); let base = Symbol::new(&env, "EURC"); let quote = Symbol::new(&env, "USDC"); - client.set_price(&admin, &base, "e, &10_000_000i128); + client.set_price(&feeder, &base, "e, &10_000_000i128); // 10% deviation allowed (default), new price 11.5M exceeds 10% - let result = client.try_set_price(&admin, &base, "e, &11_500_000i128); + let result = client.try_set_price(&feeder, &base, "e, &11_500_000i128); assert!(result.is_err()); } #[test] fn test_price_override_bypasses_deviation() { - let (env, admin, client) = setup(); + let (env, _admin, feeder, client) = setup(); let base = Symbol::new(&env, "EURC"); let quote = Symbol::new(&env, "USDC"); - client.set_price(&admin, &base, "e, &10_000_000i128); + client.set_price(&feeder, &base, "e, &10_000_000i128); // Exceeds deviation but override bypasses check - let result = client.try_set_price_override(&admin, &base, "e, &20_000_000i128); + let result = client.try_set_price_override(&feeder, &base, "e, &20_000_000i128); assert!(result.is_ok()); let data = client.get_price(&base, "e); assert_eq!(data.price, 20_000_000i128); @@ -407,28 +521,86 @@ mod tests { #[test] fn test_set_max_deviation() { - let (env, admin, client) = setup(); + let (env, admin, feeder, client) = setup(); let base = Symbol::new(&env, "EURC"); let quote = Symbol::new(&env, "USDC"); - client.set_price(&admin, &base, "e, &10_000_000i128); + client.set_price(&feeder, &base, "e, &10_000_000i128); // Set deviation to 5% (500 bps) client.set_max_deviation(&admin, &500u32); // 7% increase should now fail (was within 10% before) - let result = client.try_set_price(&admin, &base, "e, &10_700_000i128); + let result = client.try_set_price(&feeder, &base, "e, &10_700_000i128); assert!(result.is_err()); } #[test] fn test_first_price_always_succeeds() { - let (env, admin, client) = setup(); + let (env, _admin, feeder, client) = setup(); let base = Symbol::new(&env, "EURC"); let quote = Symbol::new(&env, "USDC"); // No previous price, should succeed regardless of value - let result = client.try_set_price(&admin, &base, "e, &100_000_000i128); + let result = client.try_set_price(&feeder, &base, "e, &100_000_000i128); + assert!(result.is_ok()); + } + + #[test] + fn test_multiple_feeders_median_aggregation() { + let (env, admin, feeder1, client) = setup(); + let feeder2 = Address::generate(&env); + let feeder3 = Address::generate(&env); + client.add_feeder(&admin, &feeder2); + client.add_feeder(&admin, &feeder3); + + let base = Symbol::new(&env, "EURC"); + let quote = Symbol::new(&env, "USDC"); + + // Three feeders submit different prices: 10M, 11M, 12M + client.set_price(&feeder1, &base, "e, &10_000_000i128); + client.set_price(&feeder2, &base, "e, &11_000_000i128); + client.set_price(&feeder3, &base, "e, &12_000_000i128); + + // Median should be 11M + let data = client.get_price(&base, "e); + assert_eq!(data.price, 11_000_000i128); + } + + #[test] + fn test_single_malicious_feeder_cannot_control_aggregate() { + let (env, admin, feeder1, client) = setup(); + let malicious_feeder = Address::generate(&env); + client.add_feeder(&admin, &malicious_feeder); + + let base = Symbol::new(&env, "EURC"); + let quote = Symbol::new(&env, "USDC"); + + // Honest feeder submits 10M + client.set_price(&feeder1, &base, "e, &10_000_000i128); + + // Malicious feeder tries to submit 1M (1000x lower) + client.set_price(&malicious_feeder, &base, "e, &1_000_000i128); + + // Median of [10M, 1M] is 5.5M, not 1M + let data = client.get_price(&base, "e); + assert!(data.price > 1_000_000i128); + assert!(data.price < 10_000_000i128); + } + + #[test] + fn test_add_and_remove_feeder() { + let (env, admin, feeder, client) = setup(); + let new_feeder = Address::generate(&env); + + client.add_feeder(&admin, &new_feeder); + let base = Symbol::new(&env, "EURC"); + let quote = Symbol::new(&env, "USDC"); + let result = client.try_set_price(&new_feeder, &base, "e, &10_000_000i128); assert!(result.is_ok()); + + client.remove_feeder(&admin, &new_feeder); + let result2 = client.try_set_price(&new_feeder, &base, "e, &11_000_000i128); + assert!(result2.is_err()); } } From 65d5c459b9a6dfa791b5d6e8a94a9f0d118bee43 Mon Sep 17 00:00:00 2001 From: James Akolo Date: Mon, 27 Jul 2026 17:07:27 +0100 Subject: [PATCH 4/4] feat: add multi-hop conversion via base currency triangulation (#502) Implements fallback triangulation through configurable base currency when direct pair is unavailable. Direct pairs are preferred when available. Both legs independently checked for staleness. Includes set_base_currency for admin configuration. Closes #502 --- contracts/price_oracle/src/lib.rs | 156 ++++++++++++++++++++++++++++-- 1 file changed, 146 insertions(+), 10 deletions(-) diff --git a/contracts/price_oracle/src/lib.rs b/contracts/price_oracle/src/lib.rs index cb566d8..a91b1c4 100644 --- a/contracts/price_oracle/src/lib.rs +++ b/contracts/price_oracle/src/lib.rs @@ -22,6 +22,7 @@ pub enum DataKey { Feeder(Address), FeederPrice(Symbol, Symbol, Address), PriceFeeders(Symbol, Symbol), + BaseCurrency, } #[contract] @@ -145,6 +146,18 @@ impl PriceOracleContract { Ok(()) } + /// Set the base currency for multi-hop triangulation. Admin only. + pub fn set_base_currency( + env: Env, + admin: Address, + base: Symbol, + ) -> Result<(), KoraError> { + admin.require_auth(); + Self::require_admin(&env, &admin)?; + env.storage().persistent().set(&DataKey::BaseCurrency, &base); + Ok(()) + } + /// Set the maximum allowed price deviation in basis points. /// Admin only. Default is 1000 (10%). pub fn set_max_deviation( @@ -214,7 +227,8 @@ impl PriceOracleContract { } /// Convert an amount from one currency to another using the stored price. - /// Rejects stale or missing prices. + /// First attempts direct pair conversion. If unavailable, triangulates through + /// the configured base currency. Rejects stale or missing prices. pub fn convert( env: Env, amount: i128, @@ -225,17 +239,50 @@ impl PriceOracleContract { return Ok(amount); } - let price_data = Self::get_price(env.clone(), from, to)?; - let converted = amount - .checked_mul(price_data.price) - .and_then(|v| v.checked_div(10_000_000)) - .ok_or(KoraError::ArithmeticOverflow)?; + match Self::get_price(env.clone(), from.clone(), to.clone()) { + Ok(price_data) => { + let converted = amount + .checked_mul(price_data.price) + .and_then(|v| v.checked_div(10_000_000)) + .ok_or(KoraError::ArithmeticOverflow)?; + + if converted <= 0 { + return Err(KoraError::InvalidAmount); + } - if converted <= 0 { - return Err(KoraError::InvalidAmount); - } + Ok(converted) + } + Err(_) => { + let base_currency: Symbol = env + .storage() + .persistent() + .get(&DataKey::BaseCurrency) + .ok_or(KoraError::InvalidAmount)?; + + if from == base_currency || to == base_currency { + return Err(KoraError::InvalidAmount); + } + + let from_to_base = Self::get_price(env.clone(), from, base_currency.clone())?; + let base_to_to = Self::get_price(env, base_currency, to)?; - Ok(converted) + let intermediate = amount + .checked_mul(from_to_base.price) + .and_then(|v| v.checked_div(10_000_000)) + .ok_or(KoraError::ArithmeticOverflow)?; + + let converted = intermediate + .checked_mul(base_to_to.price) + .and_then(|v| v.checked_div(10_000_000)) + .ok_or(KoraError::ArithmeticOverflow)?; + + if converted <= 0 { + return Err(KoraError::InvalidAmount); + } + + Ok(converted) + } + } } /// Register a token address to its currency symbol. @@ -603,4 +650,93 @@ mod tests { let result2 = client.try_set_price(&new_feeder, &base, "e, &11_000_000i128); assert!(result2.is_err()); } + + #[test] + fn test_multi_hop_conversion_via_base_currency() { + let (env, admin, feeder, client) = setup(); + let eurc = Symbol::new(&env, "EURC"); + let gbpc = Symbol::new(&env, "GBPC"); + let usdc = Symbol::new(&env, "USDC"); + + // Set USDC as base currency + client.set_base_currency(&admin, &usdc); + + // Register only EURC->USDC and GBPC->USDC (not direct EURC->GBPC) + client.set_price(&feeder, &eurc, &usdc, &11_000_000i128); // 1 EURC = 1.1 USDC + client.set_price(&feeder, &gbpc, &usdc, &13_000_000i128); // 1 GBPC = 1.3 USDC + + // Convert EURC to GBPC via USDC triangulation + let result = client.convert(&10_000_000i128, &eurc, &gbpc); + assert!(result.is_ok()); + + // Verify math: 10M EURC * 1.1 = 11M USDC, then 11M / 1.3 ≈ 8.46M GBPC + let converted = result.unwrap(); + assert!(converted > 0); + assert!(converted < 11_000_000i128); + } + + #[test] + fn test_direct_pair_preferred_over_triangulation() { + let (env, admin, feeder, client) = setup(); + let eurc = Symbol::new(&env, "EURC"); + let gbpc = Symbol::new(&env, "GBPC"); + let usdc = Symbol::new(&env, "USDC"); + + client.set_base_currency(&admin, &usdc); + + // Set direct pair and triangulation pairs with different rates + client.set_price(&feeder, &eurc, &gbpc, &10_000_000i128); // Direct: 1:1 + client.set_price(&feeder, &eurc, &usdc, &11_000_000i128); // Via base: 1 EURC = 1.1 USDC + client.set_price(&feeder, &gbpc, &usdc, &11_000_000i128); // Via base: 1 GBPC = 1.1 USDC + + // Should use direct pair (10M), not triangulation result (~10M via base) + let result = client.convert(&10_000_000i128, &eurc, &gbpc); + let converted = result.unwrap(); + assert_eq!(converted, 10_000_000i128); + } + + #[test] + fn test_triangulation_fails_without_base_currency() { + let (env, _admin, feeder, client) = setup(); + let eurc = Symbol::new(&env, "EURC"); + let gbpc = Symbol::new(&env, "GBPC"); + let usdc = Symbol::new(&env, "USDC"); + + // No base currency set + client.set_price(&feeder, &eurc, &usdc, &11_000_000i128); + client.set_price(&feeder, &gbpc, &usdc, &13_000_000i128); + + // Should fail because no direct pair and no base currency + let result = client.try_convert(&10_000_000i128, &eurc, &gbpc); + assert!(result.is_err()); + } + + #[test] + fn test_triangulation_both_legs_checked_for_staleness() { + use soroban_sdk::testutils::{Ledger, LedgerInfo}; + let (env, admin, feeder, client) = setup(); + let eurc = Symbol::new(&env, "EURC"); + let gbpc = Symbol::new(&env, "GBPC"); + let usdc = Symbol::new(&env, "USDC"); + + client.set_base_currency(&admin, &usdc); + client.set_price(&feeder, &eurc, &usdc, &11_000_000i128); + client.set_price(&feeder, &gbpc, &usdc, &13_000_000i128); + + // Advance time to make one leg stale + env.ledger().set(LedgerInfo { + timestamp: env.ledger().timestamp() + MAX_STALENESS_SECS + 1, + protocol_version: 21, + sequence_number: 2, + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 1000, + min_persistent_entry_ttl: 1000, + max_entry_ttl: 100_000, + }); + + // Should fail because at least one leg is stale + let result = client.try_convert(&10_000_000i128, &eurc, &gbpc); + assert!(result.is_err()); + } }