From 8cfa31f1565c888a0ed6d6f21c31c907a37e9cc9 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 29 Sep 2026 13:44:27 +0100 Subject: [PATCH] Fix oracle APIs for issues #636-#639 --- contracts/oracle/src/lib.rs | 174 +++++++++++++++++++++++++++--------- 1 file changed, 132 insertions(+), 42 deletions(-) diff --git a/contracts/oracle/src/lib.rs b/contracts/oracle/src/lib.rs index 69ec4ff5..46815949 100644 --- a/contracts/oracle/src/lib.rs +++ b/contracts/oracle/src/lib.rs @@ -69,6 +69,7 @@ pub struct RoleKey { pub enum DataKey { Admin, Config, + QuoteCurrency, Price, Role(RoleKey), AdminCount, @@ -149,6 +150,20 @@ pub struct OracleConfig { pub min_submitters: u32, } +/// Optional field-wise changes to oracle configuration. `None` preserves the +/// currently stored value, avoiding a client-side read-modify-write race. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleConfigUpdate { + pub decimals: Option, + pub asset_peg: Option, + pub max_staleness: Option, + pub max_price: Option, + pub min_submit_interval: Option, + pub min_submitters: Option, + pub quote_currency: Option, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct PriceData { @@ -283,6 +298,17 @@ impl TwapOracle { role_members(&env, role) } + /// Returns addresses with tracked price submissions, including stale + /// submissions. This is the exact roster targeted by `purge_submitter`; + /// `role_members(Role::PriceFeeder)` instead lists authorized feeders, + /// including those who have never submitted a price. + pub fn submitters(env: Env) -> Vec
{ + env.storage() + .persistent() + .get(&DataKey::Submitters) + .unwrap_or(Vec::new(&env)) + } + /// Grants `role` to `account`. Only an `Admin` may call this. pub fn grant_role( env: Env, @@ -319,7 +345,9 @@ impl TwapOracle { } /// Explicitly purges a feeder's submission data and removes them from the - /// submitters set. Only an `Admin` may call this. + /// submitters set. Use `submitters()` to enumerate the tracked roster; + /// `role_members(Role::PriceFeeder)` lists authorized feeders instead. + /// Only an `Admin` may call this. pub fn purge_submitter(env: Env, caller: Address, feeder: Address) -> Result<(), Error> { require_role_or_admin(&env, &caller, Role::Admin)?; bump_instance(&env); @@ -346,7 +374,7 @@ impl TwapOracle { // ── Reads ──────────────────────────────────────────────────────────── - /// Reconfigures oracle parameters and pricing settings. Admin-gated. + /// Replaces all oracle parameters and pricing settings. Admin-gated. /// /// # Authorization /// @@ -397,54 +425,68 @@ impl TwapOracle { /// exceeds [`MAX_SUBMITTERS`]. pub fn configure_oracle(env: Env, caller: Address, config: OracleConfig) -> Result<(), Error> { require_role_or_admin(&env, &caller, Role::Admin)?; + store_oracle_config(&env, &caller, config) + } - if config.decimals > 19 { - return Err(Error::InvalidDecimals); - } + /// Returns the complete stored oracle configuration. + pub fn oracle_config(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Config) + .ok_or(Error::OracleNotConfigured) + } - if config.max_staleness == 0 { - return Err(Error::InvalidMaxStaleness); - } + /// Applies only the supplied fields; all omitted fields retain their + /// current values. Configuration validation and price-cache invalidation + /// are identical to `configure_oracle`. + pub fn update_oracle_config( + env: Env, + caller: Address, + update: OracleConfigUpdate, + ) -> Result<(), Error> { + require_role_or_admin(&env, &caller, Role::Admin)?; + let mut config: OracleConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(Error::OracleNotConfigured)?; - // The quorum must be at least one (a zero-feeder quorum would let the - // oracle report prices nobody backs) and can never exceed the capped - // submitter set aggregation iterates over (issue #661). - if config.min_submitters == 0 || config.min_submitters > MAX_SUBMITTERS { - return Err(Error::InvalidMinSubmitters); + if let Some(value) = update.decimals { + config.decimals = value; } - - bump_instance(&env); - - // Check if decimals or asset_peg changed relative to existing config. - // If so, clear all stored price data to prevent magnitude misinterpretation. - let existing: Option = env.storage().instance().get(&DataKey::Config); - if let Some(old) = existing { - if old.decimals != config.decimals || old.asset_peg != config.asset_peg { - // Clear the legacy single-value price slot. - env.storage().instance().remove(&DataKey::Price); - - // Clear every per-feeder submission and the submitter list itself. - // Submissions live in persistent() (see DataKey docs) so clears - // must target persistent storage and respect the cap. - let submitters: Vec
= env - .storage() - .persistent() - .get(&DataKey::Submitters) - .unwrap_or(Vec::new(&env)); - for feeder in submitters.iter() { - env.storage() - .persistent() - .remove(&DataKey::Submission(feeder)); - } - env.storage().persistent().remove(&DataKey::Submitters); - } + if let Some(value) = update.asset_peg { + config.asset_peg = value; + } + if let Some(value) = update.max_staleness { + config.max_staleness = value; + } + if let Some(value) = update.max_price { + config.max_price = value; + } + if let Some(value) = update.min_submit_interval { + config.min_submit_interval = value; + } + if let Some(value) = update.min_submitters { + config.min_submitters = value; } - env.storage().instance().set(&DataKey::Config, &config); - events::oracle_configured(&env, &caller, config); + let quote_currency = update.quote_currency; + store_oracle_config(&env, &caller, config)?; + if let Some(currency) = quote_currency { + env.storage() + .instance() + .set(&DataKey::QuoteCurrency, ¤cy); + events::quote_currency_configured(&env, &caller, currency); + } Ok(()) } + /// Returns the denomination configured for fiat payout values, or `None` + /// until an admin supplies it through `update_oracle_config`. + pub fn quote_currency(env: Env) -> Option { + env.storage().instance().get(&DataKey::QuoteCurrency) + } + /// Submit a price observation. Gated strictly on `PriceFeeder` — `Admin` /// is not sufficient, so a single admin key cannot inject a price point /// into the feeder set and shift the aggregated median. The admin can @@ -616,7 +658,7 @@ impl TwapOracle { // from fewer fresh feeders than `min_submitters` is not reliable // enough to return. `PriceStatus` exposes both counts so callers can // observe how far below quorum the set is. - if fresh_prices.len() as u32 < config.min_submitters { + if (fresh_prices.len() as u32) < config.min_submitters { return Err(Error::InsufficientQuorum); } @@ -656,6 +698,9 @@ impl TwapOracle { /// Errors: /// - `OracleNotConfigured` if `configure_oracle` has not been called. /// - `NoPriceAvailable` if no price has ever been submitted. + /// + /// Use this single view when both age and staleness are needed; it bundles + /// `newest_age`, `oldest_fresh_age`, `stale`, and quorum counts together. pub fn price_status(env: Env) -> Result { load_price_status(&env) } @@ -807,6 +852,45 @@ impl TwapOracle { } } +fn store_oracle_config(env: &Env, caller: &Address, config: OracleConfig) -> Result<(), Error> { + if config.decimals > 19 { + return Err(Error::InvalidDecimals); + } + + if config.max_staleness == 0 { + return Err(Error::InvalidMaxStaleness); + } + + if config.min_submitters == 0 || config.min_submitters > MAX_SUBMITTERS { + return Err(Error::InvalidMinSubmitters); + } + + bump_instance(env); + + let existing: Option = env.storage().instance().get(&DataKey::Config); + if let Some(old) = existing { + if old.decimals != config.decimals || old.asset_peg != config.asset_peg { + env.storage().instance().remove(&DataKey::Price); + + let submitters: Vec
= env + .storage() + .persistent() + .get(&DataKey::Submitters) + .unwrap_or(Vec::new(env)); + for feeder in submitters.iter() { + env.storage() + .persistent() + .remove(&DataKey::Submission(feeder)); + } + env.storage().persistent().remove(&DataKey::Submitters); + } + } + + env.storage().instance().set(&DataKey::Config, &config); + events::oracle_configured(env, caller, config); + Ok(()) +} + // ── Internal RBAC helpers (delegate to drip_common::rbac) ───────────────── // // These thin wrappers translate the oracle's DataKey / Role types into the @@ -912,6 +996,7 @@ fn add_submitter(env: &Env, account: &Address) -> Result<(), Error> { for existing in submitters.iter() { if existing == *account { + ttl::bump_persistent(env, &DataKey::Submitters); return Ok(()); } } @@ -1189,6 +1274,11 @@ mod events { .publish((symbol_short!("ocfg"), caller.clone()), config); } + pub fn quote_currency_configured(env: &Env, caller: &Address, currency: soroban_sdk::Symbol) { + env.events() + .publish((symbol_short!("qcurr"), caller.clone()), currency); + } + /// Emitted when a price submission is rejected so off-chain monitors /// can alert without parsing error codes. pub fn price_rejected(env: &Env, caller: &Address, price: u64, reason: soroban_sdk::Symbol) {