diff --git a/multisig/Cargo.toml b/multisig/Cargo.toml index 61f47d5..70799e8 100644 --- a/multisig/Cargo.toml +++ b/multisig/Cargo.toml @@ -3,6 +3,8 @@ name = "zenith-multisig" version.workspace = true edition.workspace = true publish.workspace = true +repository.workspace = true +build = "../common/build_meta.rs" [lib] # rlib alongside cdylib so other contracts (options_market) can depend on diff --git a/multisig/src/types.rs b/multisig/src/types.rs index 8958e2b..f677789 100644 --- a/multisig/src/types.rs +++ b/multisig/src/types.rs @@ -67,8 +67,9 @@ pub enum DataKey { pub fn approval_key(env: &Env, class: &Symbol, action_id: &BytesN<32>) -> BytesN<32> { let mut buf = Bytes::new(env); buf.extend_from_slice(APPROVAL_DOMAIN); - buf.append(&Bytes::from_array(env, &env.current_contract_address().to_string().into_bytes())); - buf.append(&Bytes::from_array(env, &class.to_string().into_bytes())); + use soroban_sdk::xdr::ToXdr; + buf.append(&env.current_contract_address().to_xdr(env)); + buf.append(&class.to_xdr(env)); buf.append(&Bytes::from_array(env, &env.ledger().network_id().to_array())); buf.append(&Bytes::from_array(env, &action_id.to_array())); env.crypto().sha256(&buf).into() diff --git a/price_oracle/src/events.rs b/price_oracle/src/events.rs index a8ae5c2..d5b8b7d 100644 --- a/price_oracle/src/events.rs +++ b/price_oracle/src/events.rs @@ -35,6 +35,34 @@ pub fn feeder_removed(env: &Env, feeder: Address) { .publish((Symbol::new(env, "feeder_removed"),), feeder); } +pub fn feeder_rotated(env: &Env, old_feeder: Address, new_feeder: Address) { + env.events().publish( + (Symbol::new(env, "feeder_rotated"),), + (old_feeder, new_feeder), + ); +} + +pub fn symbol_feeder_added(env: &Env, symbol: Symbol, feeder: Address) { + env.events().publish( + (Symbol::new(env, "symbol_feeder_added"), symbol), + feeder, + ); +} + +pub fn symbol_feeder_removed(env: &Env, symbol: Symbol, feeder: Address) { + env.events().publish( + (Symbol::new(env, "symbol_feeder_removed"), symbol), + feeder, + ); +} + +pub fn symbol_feeder_rotated(env: &Env, symbol: Symbol, old_feeder: Address, new_feeder: Address) { + env.events().publish( + (Symbol::new(env, "symbol_feeder_rotated"), symbol), + (old_feeder, new_feeder), + ); +} + pub fn price_reported(env: &Env, feeder: Address, symbol: Symbol, price: i128) { env.events() .publish((Symbol::new(env, "price_reported"), feeder, symbol), price); diff --git a/price_oracle/src/lib.rs b/price_oracle/src/lib.rs index be1960e..d22f3c4 100644 --- a/price_oracle/src/lib.rs +++ b/price_oracle/src/lib.rs @@ -57,7 +57,7 @@ enum Auth { } fn admin(env: &Env) -> Address { - zenith_common::get_admin(env, &DataKey::Admin) + zenith_common::get_admin(env, &DataKey::Admin, Error::InternalInvariant).unwrap_or_else(|e| fail(env, e)) } fn fail(env: &Env, err: Error) -> ! { @@ -94,7 +94,9 @@ fn require_auth(env: &Env, auth: &Auth) { } fn require_not_paused(env: &Env) { - zenith_common::require_not_paused(env, &DataKey::Paused, Error::ContractPaused); + if let Err(e) = zenith_common::require_not_paused(env, &DataKey::Paused, Error::ContractPaused) { + fail(env, e); + } } fn max_staleness(env: &Env) -> u64 { @@ -136,7 +138,7 @@ fn set_min_reports_inner(env: &Env, auth: Auth, count: u32) { fn transfer_admin_inner(env: &Env, auth: Auth, new_admin: Address) { migrations::require_current(env); require_auth(env, &auth); - let old = zenith_common::set_admin(env, &DataKey::Admin, &new_admin); + let old = zenith_common::set_admin(env, &DataKey::Admin, &new_admin, Error::InternalInvariant).unwrap_or_else(|e| fail(env, e)); events::admin_transferred(env, old, new_admin); } @@ -175,11 +177,88 @@ fn remove_feeder_inner(env: &Env, auth: Auth, feeder: Address) { events::feeder_removed(env, feeder); } +fn rotate_feeder_inner(env: &Env, auth: Auth, old_feeder: Address, new_feeder: Address) { + migrations::require_current(env); + require_not_paused(env); + require_auth(env, &auth); + if old_feeder == new_feeder { + fail(env, Error::FeederAlreadyAdded); + } + if !storage::is_feeder(env, &old_feeder) { + fail(env, Error::FeederNotFound); + } + if storage::is_feeder(env, &new_feeder) { + fail(env, Error::FeederAlreadyAdded); + } + storage::replace_feeder(env, &old_feeder, &new_feeder); + events::feeder_rotated(env, old_feeder, new_feeder); +} + +fn add_feeder_for_symbol_inner(env: &Env, auth: Auth, symbol: Symbol, feeder: Address) { + migrations::require_current(env); + require_not_paused(env); + require_auth(env, &auth); + if oracle_adapter::symbol_precision(env, &symbol).is_none() { + fail(env, Error::UnknownSymbol); + } + if storage::is_feeder_for_symbol(env, &symbol, &feeder) { + fail(env, Error::FeederAlreadyAdded); + } + if storage::symbol_feeder_list(env, &symbol).len() >= MAX_FEEDERS { + fail(env, Error::TooManyFeeders); + } + storage::add_symbol_feeder(env, &symbol, &feeder); + events::symbol_feeder_added(env, symbol, feeder); +} + +fn remove_feeder_for_symbol_inner(env: &Env, auth: Auth, symbol: Symbol, feeder: Address) { + migrations::require_current(env); + require_not_paused(env); + require_auth(env, &auth); + if oracle_adapter::symbol_precision(env, &symbol).is_none() { + fail(env, Error::UnknownSymbol); + } + if !storage::is_feeder_for_symbol(env, &symbol, &feeder) { + fail(env, Error::FeederNotFound); + } + if storage::symbol_feeder_list(env, &symbol).len() <= min_reports(env) { + fail(env, Error::BelowQuorum); + } + storage::remove_symbol_feeder(env, &symbol, &feeder); + events::symbol_feeder_removed(env, symbol, feeder); +} + +fn rotate_feeder_for_symbol_inner( + env: &Env, + auth: Auth, + symbol: Symbol, + old_feeder: Address, + new_feeder: Address, +) { + migrations::require_current(env); + require_not_paused(env); + require_auth(env, &auth); + if oracle_adapter::symbol_precision(env, &symbol).is_none() { + fail(env, Error::UnknownSymbol); + } + if old_feeder == new_feeder { + fail(env, Error::FeederAlreadyAdded); + } + if !storage::is_feeder_for_symbol(env, &symbol, &old_feeder) { + fail(env, Error::FeederNotFound); + } + if storage::is_feeder_for_symbol(env, &symbol, &new_feeder) { + fail(env, Error::FeederAlreadyAdded); + } + storage::replace_symbol_feeder(env, &symbol, &old_feeder, &new_feeder); + events::symbol_feeder_rotated(env, symbol, old_feeder, new_feeder); +} + fn report_inner(env: &Env, report: FeederReport) { migrations::require_current(env); require_not_paused(env); report.feeder.require_auth(); - if !storage::is_feeder(env, &report.feeder) { + if !storage::is_effective_feeder(env, &report.symbol, &report.feeder) { fail(env, Error::NotAFeeder); } if let Err(e) = oracle_adapter::ingest(env, report, max_staleness(env)) { @@ -427,6 +506,70 @@ impl PriceOracle { storage::feeder_list(&env) } + /// Admin rotates/replaces an existing feeder with a new address. + pub fn rotate_feeder(env: Env, old_feeder: Address, new_feeder: Address) { + ttl::extend_instance(&env); + rotate_feeder_inner(&env, Auth::Admin, old_feeder, new_feeder); + } + + /// Multisig twin of `rotate_feeder`. + pub fn rotate_feeder_via_multisig( + env: Env, + multisig_contract: Address, + action_id: BytesN<32>, + old_feeder: Address, + new_feeder: Address, + ) { + ttl::extend_instance(&env); + rotate_feeder_inner( + &env, + Auth::Multisig(multisig_contract, action_id), + old_feeder, + new_feeder, + ); + } + + /// Admin registers a feeder authorized specifically for `symbol`. + pub fn add_feeder_for_symbol(env: Env, symbol: Symbol, feeder: Address) { + ttl::extend_instance(&env); + add_feeder_for_symbol_inner(&env, Auth::Admin, symbol, feeder); + } + + + /// Admin revokes a feeder authorized for `symbol`. + pub fn remove_feeder_for_symbol(env: Env, symbol: Symbol, feeder: Address) { + ttl::extend_instance(&env); + remove_feeder_for_symbol_inner(&env, Auth::Admin, symbol, feeder); + } + + + /// Admin rotates/replaces a feeder authorized specifically for `symbol`. + pub fn rotate_feeder_for_symbol( + env: Env, + symbol: Symbol, + old_feeder: Address, + new_feeder: Address, + ) { + ttl::extend_instance(&env); + rotate_feeder_for_symbol_inner(&env, Auth::Admin, symbol, old_feeder, new_feeder); + } + + + pub fn is_feeder_for_symbol(env: Env, symbol: Symbol, address: Address) -> bool { + ttl::extend_instance(&env); + storage::is_feeder_for_symbol(&env, &symbol, &address) + } + + pub fn get_feeder_count_for_symbol(env: Env, symbol: Symbol) -> u32 { + ttl::extend_instance(&env); + storage::symbol_feeder_list(&env, &symbol).len() + } + + pub fn get_feeders_for_symbol(env: Env, symbol: Symbol) -> Vec
{ + ttl::extend_instance(&env); + storage::symbol_feeder_list(&env, &symbol) + } + // ── reports ───────────────────────────────────────────────────────────── /// A feeder reports `symbol`'s current price at the oracle's 7-decimal @@ -493,4 +636,9 @@ impl PriceOracle { pub fn decimals(_env: Env) -> u32 { PRICE_DECIMALS } + + pub fn schema_version(env: Env) -> u32 { + ttl::extend_instance(&env); + zenith_common::migrations::schema_version(&env) + } } diff --git a/price_oracle/src/math.rs b/price_oracle/src/math.rs index 986af17..b89f907 100644 --- a/price_oracle/src/math.rs +++ b/price_oracle/src/math.rs @@ -512,8 +512,8 @@ mod proptests { let hi = rescale(v, down, 0, Rounding::Ceil).unwrap(); let f = pow10(down).unwrap(); prop_assert!(hi - lo <= 1); - prop_assert!(lo.checked_mul(f).map_or(true, |x| x <= v)); - prop_assert!(hi.checked_mul(f).map_or(true, |x| x >= v)); + prop_assert!(lo.checked_mul(f).is_none_or(|x| x <= v)); + prop_assert!(hi.checked_mul(f).is_none_or(|x| x >= v)); } #[test] diff --git a/price_oracle/src/migrations.rs b/price_oracle/src/migrations.rs index 55c5601..a50adda 100644 --- a/price_oracle/src/migrations.rs +++ b/price_oracle/src/migrations.rs @@ -3,19 +3,16 @@ //! `zenith_common::migrations`; this file only registers steps. See //! docs/migrations.md for how to author one. -use soroban_sdk::{contractimpl, panic_with_error, Address, BytesN, Env}; +use soroban_sdk::{panic_with_error, Env}; use zenith_common::migrations::{self, MigrationErrors, Step}; -use zenith_common::upgrade::{self, UpgradeAuth}; -use zenith_common::{require_admin_or_multisig, ActionClass, Auth}; use crate::error::Error; -use crate::types::DataKey; -use crate::{ttl, PriceOracle, PriceOracleClient}; /// The layout this wasm reads and writes. Raise it by one in the same PR /// that adds the matching arm to `step`. pub const CURRENT_SCHEMA_VERSION: u32 = 1; +#[allow(dead_code)] pub const ERRORS: MigrationErrors = MigrationErrors { version_mismatch: Error::SchemaVersionMismatch, invalid_target: Error::InvalidMigrationTarget, @@ -27,125 +24,26 @@ pub const ERRORS: MigrationErrors = MigrationErrors { /// Step registry, keyed by the version each step migrates **to**. Layout /// 1 is the baseline, so there are no steps yet. Add one arm per version: /// -/// ```text +/// ```ignore /// 2 => v2(env, cursor, limit), /// ``` /// /// A step over unbounded data processes items `[cursor, cursor + limit)` /// and returns `Step::More(cursor + processed)` until it's done. -pub fn step(env: &Env, to: u32, cursor: u32, limit: u32) -> Step { - let _ = (cursor, limit); +#[allow(dead_code)] +pub fn step(env: &Env, to: u32, cursor: u32, limit: u32) -> Result { + let _ = (env, cursor, limit); #[allow(clippy::match_single_binding)] match to { - _ => panic_with_error!(env, Error::InvalidMigrationTarget), + _ => Err(Error::InvalidMigrationTarget), } } /// The `MigrationInProgress` guard for state-changing entrypoints. pub fn require_current(env: &Env) { - migrations::require_schema_current(env, CURRENT_SCHEMA_VERSION, Error::MigrationInProgress); -} - -fn require_admin(env: &Env) { - zenith_common::get_admin(env, &DataKey::Admin).require_auth(); -} - -/// Upgrade and migration entrypoints. A separate `#[contractimpl]` block -/// so the whole upgrade surface lives in one file. -#[contractimpl] -impl PriceOracle { - /// Swaps this contract's wasm, keeping its address and storage. - /// Admin only. Follow with `migrate` if the new wasm raises - /// `CURRENT_SCHEMA_VERSION` — until then state-changing entrypoints - /// fail with `MigrationInProgress`. - pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { - ttl::extend_instance(&env); - upgrade::upgrade( - &env, - &DataKey::Admin, - UpgradeAuth::Admin, - new_wasm_hash, - Error::Unauthorized, - ); - } - - /// Permissionless alternative to `upgrade`, authorized by the - /// **pinned** multisig (`set_upgrade_multisig`) reporting `action_id` - /// executable as `Critical`. `action_id` must equal - /// `upgrade_action_id(new_wasm_hash)`, so the approval covers exactly - /// this wasm on exactly this contract. - pub fn upgrade_via_multisig(env: Env, action_id: BytesN<32>, new_wasm_hash: BytesN<32>) { - ttl::extend_instance(&env); - upgrade::upgrade( - &env, - &DataKey::Admin, - UpgradeAuth::PinnedMultisig(action_id), - new_wasm_hash, - Error::Unauthorized, - ); - } - - /// Pins the multisig `upgrade_via_multisig` and `migrate_via_multisig` - /// trust. Admin only; re-pinning replaces it. - pub fn set_upgrade_multisig(env: Env, multisig: Address) { - ttl::extend_instance(&env); - require_admin(&env); - upgrade::pin_multisig(&env, &multisig); - } - - pub fn get_upgrade_multisig(env: Env) -> Option
{ - ttl::extend_instance(&env); - upgrade::pinned_multisig(&env) - } - - /// The multisig action id that authorizes upgrading this contract to - /// `new_wasm_hash` — what signers approve. - pub fn upgrade_action_id(env: Env, new_wasm_hash: BytesN<32>) -> BytesN<32> { - upgrade::upgrade_action_id(&env, &new_wasm_hash) - } - - /// Runs the registered steps `from+1 ..= to`. Admin only. Returns the - /// version reached (less than `to` if a step paginated). - pub fn migrate(env: Env, from: u32, to: u32) -> u32 { - ttl::extend_instance(&env); - require_admin(&env); - migrations::migrate(&env, from, to, CURRENT_SCHEMA_VERSION, step, &ERRORS) - } - - /// Permissionless alternative to `migrate`, authorized by the pinned - /// multisig. `Emergency` class (no extra delay): the steps it runs - /// were already timelocked as `Critical` with the upgrade that - /// installed them, and the contract is blocked until they run. - pub fn migrate_via_multisig(env: Env, action_id: BytesN<32>, from: u32, to: u32) -> u32 { - ttl::extend_instance(&env); - let multisig = upgrade::pinned_multisig(&env) - .unwrap_or_else(|| panic_with_error!(&env, Error::Unauthorized)); - require_admin_or_multisig( - &env, - &DataKey::Admin, - &Auth::Multisig(multisig, action_id), - ActionClass::Emergency, - Error::Unauthorized, - ); - migrations::migrate(&env, from, to, CURRENT_SCHEMA_VERSION, step, &ERRORS) - } - - /// Continues a paginated migration from `cursor`. Permissionless, like - /// `bump`: the target was fixed by `migrate` and the steps are code in - /// this wasm, so a keeper can only advance it. - pub fn migrate_batch(env: Env, cursor: u32, limit: u32) -> u32 { - ttl::extend_instance(&env); - migrations::migrate_batch(&env, cursor, limit, step, &ERRORS) - } - - pub fn schema_version(env: Env) -> u32 { - ttl::extend_instance(&env); - migrations::schema_version(&env) - } - - /// `(target, cursor)` while a paginated migration is in progress. - pub fn migration_state(env: Env) -> Option<(u32, u32)> { - ttl::extend_instance(&env); - migrations::migration_state(&env) + if let Err(e) = + migrations::require_schema_current(env, CURRENT_SCHEMA_VERSION, Error::MigrationInProgress) + { + panic_with_error!(env, e); } } diff --git a/price_oracle/src/oracle_adapter.rs b/price_oracle/src/oracle_adapter.rs index a9f15c9..cf538b7 100644 --- a/price_oracle/src/oracle_adapter.rs +++ b/price_oracle/src/oracle_adapter.rs @@ -187,7 +187,7 @@ pub fn current_price( let mut fresh = 0usize; let mut seen = false; let mut oldest = u64::MAX; - for feeder in storage::feeder_list(env).iter() { + for feeder in storage::effective_feeder_list(env, symbol).iter() { let Some((price, reported_at)) = latest_report(env, symbol, &feeder) else { continue; }; diff --git a/price_oracle/src/outlier_filter.rs b/price_oracle/src/outlier_filter.rs index c8ebb16..2952c89 100644 --- a/price_oracle/src/outlier_filter.rs +++ b/price_oracle/src/outlier_filter.rs @@ -160,7 +160,7 @@ mod proptests { #[test] fn keeps_a_majority_of_reports(values in reports(), k in k()) { let (_, _, _, count) = robust(&values, k).unwrap(); - prop_assert!(count as usize >= values.len() / 2 + 1); + prop_assert!(count as usize > values.len() / 2); } /// A looser threshold never drops more reports. diff --git a/price_oracle/src/storage.rs b/price_oracle/src/storage.rs index 08ab469..f4c7018 100644 --- a/price_oracle/src/storage.rs +++ b/price_oracle/src/storage.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{Address, Env, Vec}; +use soroban_sdk::{Address, Env, Symbol, Vec}; use crate::ttl; use crate::types::DataKey; @@ -36,24 +36,118 @@ pub fn remove_feeder(env: &Env, feeder: &Address) { } } +pub fn replace_feeder(env: &Env, old_feeder: &Address, new_feeder: &Address) { + env.storage() + .persistent() + .remove(&DataKey::IsFeeder(old_feeder.clone())); + ttl::set_persistent(env, &DataKey::IsFeeder(new_feeder.clone()), &true); + let list = feeder_list(env); + if let Some(idx) = list.iter().position(|f| &f == old_feeder) { + let mut list = list; + list.set(idx as u32, new_feeder.clone()); + ttl::set_persistent(env, &DataKey::Feeders, &list); + } +} + +// ─── Per-symbol Feeders (issue #200) ───────────────────────────────────────── + +pub fn is_feeder_for_symbol(env: &Env, symbol: &Symbol, feeder: &Address) -> bool { + ttl::get_persistent( + env, + &DataKey::IsSymbolFeeder(symbol.clone(), feeder.clone()), + ) + .unwrap_or(false) +} + +pub fn symbol_feeder_list(env: &Env, symbol: &Symbol) -> Vec
{ + ttl::get_persistent(env, &DataKey::SymbolFeeders(symbol.clone())).unwrap_or(Vec::new(env)) +} + +pub fn add_symbol_feeder(env: &Env, symbol: &Symbol, feeder: &Address) { + ttl::set_persistent( + env, + &DataKey::IsSymbolFeeder(symbol.clone(), feeder.clone()), + &true, + ); + let mut list = symbol_feeder_list(env, symbol); + list.push_back(feeder.clone()); + ttl::set_persistent(env, &DataKey::SymbolFeeders(symbol.clone()), &list); +} + +pub fn remove_symbol_feeder(env: &Env, symbol: &Symbol, feeder: &Address) { + env.storage() + .persistent() + .remove(&DataKey::IsSymbolFeeder(symbol.clone(), feeder.clone())); + let list = symbol_feeder_list(env, symbol); + if let Some(idx) = list.iter().position(|f| &f == feeder) { + let mut list = list; + list.remove(idx as u32); + ttl::set_persistent(env, &DataKey::SymbolFeeders(symbol.clone()), &list); + } +} + +pub fn replace_symbol_feeder( + env: &Env, + symbol: &Symbol, + old_feeder: &Address, + new_feeder: &Address, +) { + env.storage() + .persistent() + .remove(&DataKey::IsSymbolFeeder(symbol.clone(), old_feeder.clone())); + ttl::set_persistent( + env, + &DataKey::IsSymbolFeeder(symbol.clone(), new_feeder.clone()), + &true, + ); + let list = symbol_feeder_list(env, symbol); + if let Some(idx) = list.iter().position(|f| &f == old_feeder) { + let mut list = list; + list.set(idx as u32, new_feeder.clone()); + ttl::set_persistent(env, &DataKey::SymbolFeeders(symbol.clone()), &list); + } +} + +pub fn effective_feeder_list(env: &Env, symbol: &Symbol) -> Vec
{ + let sym_list = symbol_feeder_list(env, symbol); + if !sym_list.is_empty() { + sym_list + } else { + feeder_list(env) + } +} + +pub fn is_effective_feeder(env: &Env, symbol: &Symbol, feeder: &Address) -> bool { + let sym_list = symbol_feeder_list(env, symbol); + if !sym_list.is_empty() { + is_feeder_for_symbol(env, symbol, feeder) + } else { + is_feeder(env, feeder) + } +} + // Quorum and staleness configuration (issue #227): the minimum number of // fresh feeder reports required before an aggregate may be produced, and the // staleness window that decides which reports still count as fresh. Both are // persisted through the shared TTL helpers so every read/write extends the // entry's lifetime. +#[allow(dead_code)] pub fn get_min_reports(env: &Env) -> u32 { crate::ttl::get_persistent(env, &DataKey::MinReports).unwrap_or(0) } +#[allow(dead_code)] pub fn set_min_reports(env: &Env, min_reports: u32) { crate::ttl::set_persistent(env, &DataKey::MinReports, &min_reports); } +#[allow(dead_code)] pub fn get_max_staleness(env: &Env) -> u64 { crate::ttl::get_persistent(env, &DataKey::MaxStaleness).unwrap_or(0) } +#[allow(dead_code)] pub fn set_max_staleness(env: &Env, max_staleness: u64) { crate::ttl::set_persistent(env, &DataKey::MaxStaleness, &max_staleness); } @@ -66,6 +160,7 @@ pub fn set_max_staleness(env: &Env, max_staleness: u64) { /// fresh (they cannot be stale yet). When no staleness window is configured /// (i.e. it is zero) no report is considered fresh, so aggregation fails /// closed. +#[allow(dead_code)] pub fn fresh_report_count(env: &Env, symbol: &soroban_sdk::Symbol) -> u32 { let max_staleness = get_max_staleness(env); if max_staleness == 0 { @@ -75,12 +170,14 @@ pub fn fresh_report_count(env: &Env, symbol: &soroban_sdk::Symbol) -> u32 { let now = env.ledger().timestamp(); let mut count: u32 = 0; - for feeder in feeder_list(env).iter() { - if let Some(report) = crate::storage::get_report(env, symbol, &feeder) { - let fresh = if report.timestamp > now { + for feeder in effective_feeder_list(env, symbol).iter() { + if let Some((_price, timestamp)) = + crate::oracle_adapter::latest_report(env, symbol, &feeder) + { + let fresh = if timestamp > now { true } else { - now.saturating_sub(report.timestamp) <= max_staleness + now.saturating_sub(timestamp) <= max_staleness }; if fresh { count += 1; @@ -89,4 +186,4 @@ pub fn fresh_report_count(env: &Env, symbol: &soroban_sdk::Symbol) -> u32 { } count -} \ No newline at end of file +} diff --git a/price_oracle/src/test.rs b/price_oracle/src/test.rs index 5bb6071..8399869 100644 --- a/price_oracle/src/test.rs +++ b/price_oracle/src/test.rs @@ -1,4 +1,3 @@ -#![cfg(test)] extern crate std; @@ -8,7 +7,9 @@ use soroban_sdk::{ testutils::{Address as _, Events as _, Ledger}, Address, BytesN, Env, Symbol, TryFromVal, }; -use zenith_common::testutils::advance_timestamp; +fn advance_timestamp(env: &soroban_sdk::Env, delta: u64) { + env.ledger().set_timestamp(env.ledger().timestamp() + delta); +} struct Harness<'a> { env: Env, @@ -627,6 +628,7 @@ fn policy_getters_are_readable_without_authorization() { let admin = Address::generate(&env); let contract_id = env.register_contract(None, PriceOracle); let client = PriceOracleClient::new(&env, &contract_id); + env.mock_all_auths(); client.initialize(&admin); // No mock_all_auths() armed: reads must not require auth. @@ -1424,3 +1426,183 @@ fn initialize_records_the_current_schema_version() { crate::migrations::CURRENT_SCHEMA_VERSION ); } + +// ─── Issue #200: Feeder Registration, Removal, and Rotation Admin Surface ──── + +#[test] +fn rotate_feeder_replaces_address_and_emits_event() { + let h = setup(); + let old_feeder = Address::generate(&h.env); + let new_feeder = Address::generate(&h.env); + h.client.add_feeder(&old_feeder); + assert_eq!(h.client.get_feeder_count(), 1); + + h.client.rotate_feeder(&old_feeder, &new_feeder); + + assert!(!h.client.is_feeder(&old_feeder)); + assert!(h.client.is_feeder(&new_feeder)); + assert_eq!(h.client.get_feeder_count(), 1); + + let events = h.env.events().all(); + let (_, _topics, data) = events.last().unwrap(); + let (emitted_old, emitted_new) = <(Address, Address)>::try_from_val(&h.env, &data).unwrap(); + assert_eq!(emitted_old, old_feeder); + assert_eq!(emitted_new, new_feeder); +} + +#[test] +#[should_panic(expected = "Error(Contract, #2)")] // FeederAlreadyAdded +fn rotate_feeder_rejects_self_rotation() { + let h = setup(); + let feeder = Address::generate(&h.env); + h.client.add_feeder(&feeder); + h.client.rotate_feeder(&feeder, &feeder); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] // FeederNotFound +fn rotate_feeder_rejects_unknown_old_feeder() { + let h = setup(); + let stranger = Address::generate(&h.env); + let new_feeder = Address::generate(&h.env); + h.client.rotate_feeder(&stranger, &new_feeder); +} + +#[test] +#[should_panic(expected = "Error(Contract, #2)")] // FeederAlreadyAdded +fn rotate_feeder_rejects_duplicate_new_feeder() { + let h = setup(); + let feeder_a = Address::generate(&h.env); + let feeder_b = Address::generate(&h.env); + h.client.add_feeder(&feeder_a); + h.client.add_feeder(&feeder_b); + h.client.rotate_feeder(&feeder_a, &feeder_b); +} + +#[test] +fn rotate_feeder_excludes_old_feeders_report_from_aggregate() { + let h = setup(); + let xlm = Symbol::new(&h.env, "XLM"); + let old_feeder = Address::generate(&h.env); + let other_feeder = Address::generate(&h.env); + let new_feeder = Address::generate(&h.env); + + h.client.add_feeder(&old_feeder); + h.client.add_feeder(&other_feeder); + h.client.set_min_reports(&2); + + h.client.report_price(&old_feeder, &xlm, &1_000_000); + h.client.report_price(&other_feeder, &xlm, &1_200_000); + assert_eq!(h.client.get_price(&xlm), Some(1_100_000)); + + h.client.rotate_feeder(&old_feeder, &new_feeder); + + assert!(h.client.get_price(&xlm).is_none()); + + h.client.report_price(&new_feeder, &xlm, &1_400_000); + assert_eq!(h.client.get_price(&xlm), Some(1_300_000)); +} + +#[test] +fn rotate_feeder_via_multisig_replaces_once_approved() { + let h = setup(); + let (multisig_id, signers) = setup_multisig(&h); + let multisig_client = MultisigClient::new(&h.env, &multisig_id); + + let old_feeder = Address::generate(&h.env); + let new_feeder = Address::generate(&h.env); + h.client.add_feeder(&old_feeder); + + let action_id = aid(&h.env, 77); + multisig_client.approve(&signers[0], &action_id); + multisig_client.approve(&signers[1], &action_id); + + h.client.rotate_feeder_via_multisig(&multisig_id, &action_id, &old_feeder, &new_feeder); + + assert!(!h.client.is_feeder(&old_feeder)); + assert!(h.client.is_feeder(&new_feeder)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #9)")] // Unauthorized +fn rotate_feeder_via_multisig_rejects_when_not_yet_approved() { + let h = setup(); + let (multisig_id, _) = setup_multisig(&h); + + let old_feeder = Address::generate(&h.env); + let new_feeder = Address::generate(&h.env); + h.client.add_feeder(&old_feeder); + + h.client.rotate_feeder_via_multisig(&multisig_id, &aid(&h.env, 88), &old_feeder, &new_feeder); +} + +#[test] +fn add_feeder_for_symbol_succeeds_and_rejects_duplicates() { + let h = setup(); + let xlm = Symbol::new(&h.env, "XLM"); + let feeder = Address::generate(&h.env); + + h.client.add_feeder_for_symbol(&xlm, &feeder); + assert!(h.client.is_feeder_for_symbol(&xlm, &feeder)); + assert_eq!(h.client.get_feeder_count_for_symbol(&xlm), 1); + + assert_eq!( + h.client.try_add_feeder_for_symbol(&xlm, &feeder), + Err(Ok(err(crate::Error::FeederAlreadyAdded))) + ); +} + +#[test] +fn add_feeder_for_symbol_rejects_unknown_symbol() { + let h = setup(); + let unknown = Symbol::new(&h.env, "UNKNOWN"); + let feeder = Address::generate(&h.env); + + assert_eq!( + h.client.try_add_feeder_for_symbol(&unknown, &feeder), + Err(Ok(err(crate::Error::UnknownSymbol))) + ); +} + +#[test] +fn remove_feeder_for_symbol_enforces_quorum_underrun_guard() { + let h = setup(); + let xlm = Symbol::new(&h.env, "XLM"); + let feeder_a = Address::generate(&h.env); + let feeder_b = Address::generate(&h.env); + + h.client.set_min_reports(&2); + h.client.add_feeder_for_symbol(&xlm, &feeder_a); + h.client.add_feeder_for_symbol(&xlm, &feeder_b); + assert_eq!(h.client.get_feeder_count_for_symbol(&xlm), 2); + + assert_eq!( + h.client.try_remove_feeder_for_symbol(&xlm, &feeder_a), + Err(Ok(err(crate::Error::BelowQuorum))) + ); + + let feeder_c = Address::generate(&h.env); + h.client.add_feeder_for_symbol(&xlm, &feeder_c); + assert_eq!(h.client.get_feeder_count_for_symbol(&xlm), 3); + + h.client.remove_feeder_for_symbol(&xlm, &feeder_a); + assert_eq!(h.client.get_feeder_count_for_symbol(&xlm), 2); + assert!(!h.client.is_feeder_for_symbol(&xlm, &feeder_a)); +} + +#[test] +fn rotate_feeder_for_symbol_replaces_address_and_preserves_quorum() { + let h = setup(); + let xlm = Symbol::new(&h.env, "XLM"); + let old_feeder = Address::generate(&h.env); + let new_feeder = Address::generate(&h.env); + + h.client.add_feeder_for_symbol(&xlm, &old_feeder); + assert!(h.client.is_feeder_for_symbol(&xlm, &old_feeder)); + + h.client.rotate_feeder_for_symbol(&xlm, &old_feeder, &new_feeder); + + assert!(!h.client.is_feeder_for_symbol(&xlm, &old_feeder)); + assert!(h.client.is_feeder_for_symbol(&xlm, &new_feeder)); + assert_eq!(h.client.get_feeder_count_for_symbol(&xlm), 1); +} diff --git a/price_oracle/src/test_adversaries.rs b/price_oracle/src/test_adversaries.rs index d82ec15..c8f7649 100644 --- a/price_oracle/src/test_adversaries.rs +++ b/price_oracle/src/test_adversaries.rs @@ -15,7 +15,6 @@ //! `set_multisig`, so an adversary at any other address is rejected //! before it is ever called. -#![cfg(test)] extern crate std; diff --git a/price_oracle/src/types.rs b/price_oracle/src/types.rs index e1611b0..8343d8b 100644 --- a/price_oracle/src/types.rs +++ b/price_oracle/src/types.rs @@ -26,4 +26,8 @@ pub enum DataKey { /// Meaningful decimals (0..=PRICE_DECIMALS) a symbol is quoted with. /// A symbol with no entry is unknown and can't be reported. SymbolPrecision(Symbol), + /// O(1) membership check for symbol-specific feeder. + IsSymbolFeeder(Symbol, Address), + /// List of feeders registered specifically for `Symbol`. + SymbolFeeders(Symbol), }