diff --git a/contracts/price-oracle/src/auth.rs b/contracts/price-oracle/src/auth.rs index 3691e9d..e967d40 100644 --- a/contracts/price-oracle/src/auth.rs +++ b/contracts/price-oracle/src/auth.rs @@ -77,12 +77,11 @@ pub fn _is_authorized(env: &Env, caller: &Address) -> bool { return false; } - env.storage() + let admins = env + .storage() .instance() .get::>(&DataKey::Admin) - else { - return false; - }; + .unwrap_or_else(|| Vec::new(env)); // Stack-local fixed buffer — avoids any BTreeMap / HashMap heap allocation. const CAP: usize = 16; @@ -103,6 +102,15 @@ pub fn _is_authorized(env: &Env, caller: &Address) -> bool { false } +pub fn _require_auth_for_args( + env: &Env, + caller: &Address, + args: &[T], +) { + caller.require_auth_for_args(args); + let _ = env; +} + pub fn _require_authorized(env: &Env, caller: &Address) { if !_is_authorized(env, caller) { panic_with_error!(env, ContractError::NotAuthorized); @@ -295,7 +303,6 @@ pub fn _is_provider(env: &Env, addr: &Address) -> bool { return false; } - env.storage() // 1. Direct provider whitelist check if env .storage() diff --git a/contracts/price-oracle/src/lib.rs b/contracts/price-oracle/src/lib.rs index 7f0203c..df86cd6 100644 --- a/contracts/price-oracle/src/lib.rs +++ b/contracts/price-oracle/src/lib.rs @@ -451,7 +451,7 @@ pub trait StellarFlowTrait { /// Remove a coordinator node's circuit-breaker privileges. /// /// Only an authorized admin may call this. - fn remove_circuit_breaker_coordinator( + fn remove_breaker_coord( env: Env, admin: Address, coordinator: Address, @@ -2254,10 +2254,19 @@ impl PriceOracle { ttl: u64, liquidity: i128, ) -> Result<(), ContractError> { - _require_not_destroyed(&env)?; - _require_initialized(&env)?; - if crate::auth::_is_frozen(&env) { return Err(ContractError::ContractFrozen); } - source.require_auth(); + _require_not_destroyed(&env); + _require_initialized(&env); + crate::auth::_require_not_frozen(&env); + let auth_args = soroban_sdk::vec![ + &env, + asset.clone().into_val(&env), + price.into_val(&env), + decimals.into_val(&env), + confidence_score.into_val(&env), + ttl.into_val(&env), + liquidity.into_val(&env), + ]; + crate::auth::_require_auth_for_args(&env, &source, &auth_args); if !env .storage() @@ -3433,9 +3442,10 @@ impl PriceOracle { /// The owner can reassign the delegate by calling this again, or break the /// link immediately with `clear_vote_delegate`. pub fn delegate_vote(env: Env, owner: Address, delegate: Address) -> Result<(), ContractError> { - _require_not_destroyed(&env)?; - if crate::auth::_is_frozen(&env) { return Err(ContractError::ContractFrozen); } - owner.require_auth(); + _require_not_destroyed(&env); + crate::auth::_require_not_frozen(&env); + let auth_args = soroban_sdk::vec![&env, owner.clone().into_val(&env), delegate.clone().into_val(&env)]; + crate::auth::_require_auth_for_args(&env, &owner, &auth_args); if owner == delegate { return Err(ContractError::InvalidDelegate); @@ -3450,8 +3460,9 @@ impl PriceOracle { /// Remove the owner's active vote delegation. pub fn clear_vote_delegate(env: Env, owner: Address) -> Result<(), ContractError> { - _require_not_destroyed(&env)?; - owner.require_auth(); + _require_not_destroyed(&env); + let auth_args = soroban_sdk::vec![&env, owner.clone().into_val(&env)]; + crate::auth::_require_auth_for_args(&env, &owner, &auth_args); crate::auth::_remove_vote_delegate(&env, &owner); env.events() @@ -3471,10 +3482,11 @@ impl PriceOracle { admin: Address, delegate: Address, ) -> Result<(), ContractError> { - _require_not_destroyed(&env)?; - if crate::auth::_is_frozen(&env) { return Err(ContractError::ContractFrozen); } - admin.require_auth(); - if !crate::auth::_is_authorized(&env, &admin) { return Err(ContractError::NotAuthorized); } + _require_not_destroyed(&env); + crate::auth::_require_not_frozen(&env); + let auth_args = soroban_sdk::vec![&env, admin.clone().into_val(&env), delegate.clone().into_val(&env)]; + crate::auth::_require_auth_for_args(&env, &admin, &auth_args); + crate::auth::_require_authorized(&env, &admin); if admin == delegate { return Err(ContractError::InvalidDelegate); @@ -3495,9 +3507,10 @@ impl PriceOracle { /// Remove an active submission delegate from an administrative identity. pub fn revoke_delegate(env: Env, admin: Address) -> Result<(), ContractError> { - _require_not_destroyed(&env)?; - admin.require_auth(); - if !crate::auth::_is_authorized(&env, &admin) { return Err(ContractError::NotAuthorized); } + _require_not_destroyed(&env); + let auth_args = soroban_sdk::vec![&env, admin.clone().into_val(&env)]; + crate::auth::_require_auth_for_args(&env, &admin, &auth_args); + crate::auth::_require_authorized(&env, &admin); if let Some(delegate) = crate::auth::_get_delegate(&env, &admin) { crate::auth::_remove_delegate(&env, &admin); @@ -4347,7 +4360,7 @@ impl PriceOracle { } /// Get the relayer's current consecutive missed-block count. - pub fn get_provider_consecutive_missed_blocks(env: Env, relayer: Address) -> u32 { + pub fn get_missed_blocks(env: Env, relayer: Address) -> u32 { crate::slashing::get_consecutive_missed_blocks(&env, &relayer) } @@ -4437,14 +4450,14 @@ impl PriceOracle { /// Claim accumulated rewards for a relayer. This is a thin wrapper that /// delegates to the rewards module which enforces Checks-Effects-Interactions. - pub fn claim_rewards(env: Env, relayer: Address, token_contract: Address) -> i128 { + pub fn claim_relayer_rewards(env: Env, relayer: Address, token_contract: Address) -> i128 { crate::rewards::Rewards::claim_rewards(env, relayer, token_contract) } // ── Circuit-Breaker ─────────────────────────────────────────────────────── /// Register a new coordinator node that may trip/reset the circuit-breaker. - pub fn register_circuit_breaker_coordinator( + pub fn register_breaker_coord( env: Env, admin: Address, coordinator: Address, @@ -4454,7 +4467,7 @@ impl PriceOracle { } /// Remove a coordinator node's circuit-breaker privileges. - pub fn remove_circuit_breaker_coordinator( + pub fn remove_breaker_coord( env: Env, admin: Address, coordinator: Address, diff --git a/contracts/price-oracle/src/math.rs b/contracts/price-oracle/src/math.rs index c8c264b..027dd5e 100644 --- a/contracts/price-oracle/src/math.rs +++ b/contracts/price-oracle/src/math.rs @@ -302,9 +302,6 @@ pub fn require_nonzero_denominator(n: i128) -> Result<(), Error> { } } - // --- 3. Wrap in a Soroban String ------------------------------------------ - let text = core::str::from_utf8(&out[..pos]).expect("price formatting produced invalid utf-8"); - String::from_str(env, text) /// Validate that a slippage tolerance is within acceptable bounds. /// /// Slippage tolerance must be in the range [0, 10_000] basis points (0-100%). diff --git a/contracts/price-oracle/src/slashing.rs b/contracts/price-oracle/src/slashing.rs index a359fe8..eb229f5 100644 --- a/contracts/price-oracle/src/slashing.rs +++ b/contracts/price-oracle/src/slashing.rs @@ -1,5 +1,7 @@ use crate::median::{calculate_median, MedianError}; -use soroban_sdk::{contracttype, Vec}; +use soroban_sdk::{contracttype, Address, Env, String, Vec}; + +use crate::{ContractError, Error}; /// Discrete slashing tiers used to differentiate small communication noise from deliberate manipulation. #[contracttype] @@ -69,9 +71,6 @@ pub fn calculate_slashing_bps(deviation_bps: u128) -> u32 { } /// Analyze a faulty node price submission against a finalized median consensus price set. -/// -/// This returns the computed median, the absolute deviation in basis points, and -/// a burn rate that grows with the magnitude of the deviation. pub fn analyze_deviation_against_finalized_median( submitted_price: i128, consensus_prices: Vec, @@ -89,9 +88,7 @@ pub fn analyze_deviation_against_finalized_median( slashing_bps, tier, }) -use soroban_sdk::{contractevent, contracttype, Address, Env}; - -use crate::Error; +} pub const MIN_UNBONDING_DELAY_LEDGERS: u32 = 10_000; @@ -111,7 +108,6 @@ enum DataKey { Unbonding(Address), } -#[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct UnbondingQueued { pub validator: Address, @@ -120,7 +116,6 @@ pub struct UnbondingQueued { pub release_ledger: u32, } -#[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct UnbondingReleased { pub validator: Address, @@ -210,10 +205,137 @@ pub fn get_unbonding_request(env: &Env, validator: &Address) -> Option Result { + let current = env + .storage() + .persistent() + .get::(&crate::types::DataKey::ProviderConsecutiveMissedBlocks(relayer.clone())) + .unwrap_or(0); + let updated = current.saturating_add(missed_blocks); + env.storage() + .persistent() + .set(&crate::types::DataKey::ProviderConsecutiveMissedBlocks(relayer.clone()), &updated); + Ok(get_slash_multiplier(env, relayer)? ) +} + +pub fn report_successful_uptime(env: &Env, relayer: &Address) -> Result { + env.storage() + .persistent() + .remove(&crate::types::DataKey::ProviderConsecutiveMissedBlocks(relayer.clone())); + env.storage() + .persistent() + .set(&crate::types::DataKey::ProviderUptimeStreakStart(relayer.clone()), &env.ledger().timestamp()); + Ok(true) +} + +pub fn get_consecutive_missed_blocks(env: &Env, relayer: &Address) -> u32 { + env.storage() + .persistent() + .get::(&crate::types::DataKey::ProviderConsecutiveMissedBlocks(relayer.clone())) + .unwrap_or(0) +} + +pub fn get_slash_multiplier(env: &Env, relayer: &Address) -> Result { + let missed = get_consecutive_missed_blocks(env, relayer); + let multiplier = 1_i128 + (missed / 3) as i128; + Ok(multiplier.min(8)) +} + +pub fn get_uptime_streak_start(env: &Env, relayer: &Address) -> Option { + env.storage() + .persistent() + .get::(&crate::types::DataKey::ProviderUptimeStreakStart(relayer.clone())) +} + +pub fn parse_slash_amount(_env: &Env, data: &String) -> Result { + let text = data.to_string(); + text.parse::().map_err(|_| ContractError::InvalidSlashAmount) +} + +pub fn execute_slash_internal( + env: &Env, + executor: &Address, + bad_relayer: &Address, + amount: i128, +) -> Result<(), ContractError> { + executor.require_auth(); + if amount <= 0 { + return Err(ContractError::InvalidSlashAmount); + } + + let current_stake = env + .storage() + .persistent() + .get::(&crate::types::DataKey::ProviderStake(bad_relayer.clone())) + .unwrap_or(0); + if amount > current_stake { + return Err(ContractError::InsufficientStake); + } + + let new_stake = current_stake - amount; + env.storage() + .persistent() + .set(&crate::types::DataKey::ProviderStake(bad_relayer.clone()), &new_stake); + Ok(()) +} + +pub fn apply_slash_cap(raw_penalty: i128, bond_capacity: i128) -> i128 { + if bond_capacity <= 0 { + return 0; + } + + let cap = bond_capacity.saturating_mul(25).saturating_div(100); + raw_penalty.min(cap) +} + +pub fn deviation_multiplier(tier: DeviationTier) -> i128 { + match tier { + DeviationTier::Minor => 1, + DeviationTier::Moderate => 2, + DeviationTier::Significant => 4, + DeviationTier::Manipulation => 8, + } +} + +pub fn set_stake(env: &Env, relayer: &Address, amount: i128) { + env.storage() + .persistent() + .set(&crate::types::DataKey::ProviderStake(relayer.clone()), &amount); +} + +pub fn get_stake(env: &Env, relayer: &Address) -> i128 { + env.storage() + .persistent() + .get::(&crate::types::DataKey::ProviderStake(relayer.clone())) + .unwrap_or(0) +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum DeviationTier { + Minor, + Moderate, + Significant, + Manipulation, +} + #[cfg(test)] mod tests { use super::*; - use soroban_sdk::{vec, Env}; + use soroban_sdk::{contract, contractimpl, vec, Env, testutils::Address as _, testutils::Ledger}; + + #[contract] + struct TestContract; + + #[contractimpl] + impl TestContract {} + + fn setup() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(TestContract, ()); + let validator = Address::generate(&env); + (env, contract_id, validator) + } #[test] fn test_calculate_price_deviation_bps_returns_none_for_zero_median() { @@ -252,20 +374,6 @@ mod tests { fn test_slashing_tier_for_minor_node_hiccup() { assert_eq!(SlashingTier::from_deviation_bps(100), SlashingTier::NoPenalty); assert_eq!(SlashingTier::from_deviation_bps(180), SlashingTier::Low); - use soroban_sdk::{contract, contractimpl, testutils::Address as _, testutils::Ledger}; - - #[contract] - struct TestContract; - - #[contractimpl] - impl TestContract {} - - fn setup() -> (Env, Address, Address) { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(TestContract, ()); - let validator = Address::generate(&env); - (env, contract_id, validator) } #[test] @@ -291,13 +399,9 @@ mod tests { env.as_contract(&contract_id, || { request_unbonding(&env, &validator, 900).unwrap(); - env.ledger() - .set_sequence_number(MIN_UNBONDING_DELAY_LEDGERS); + env.ledger().set_sequence_number(MIN_UNBONDING_DELAY_LEDGERS); - assert_eq!( - release_unbonded_stake(&env, &validator), - Err(Error::UnbondingDelayActive) - ); + assert_eq!(release_unbonded_stake(&env, &validator), Err(Error::UnbondingDelayActive)); }); } @@ -308,8 +412,7 @@ mod tests { env.as_contract(&contract_id, || { request_unbonding(&env, &validator, 900).unwrap(); - env.ledger() - .set_sequence_number(1 + MIN_UNBONDING_DELAY_LEDGERS); + env.ledger().set_sequence_number(1 + MIN_UNBONDING_DELAY_LEDGERS); assert_eq!(release_unbonded_stake(&env, &validator), Ok(900)); let released = get_unbonding_request(&env, &validator).unwrap(); @@ -324,26 +427,16 @@ mod tests { env.as_contract(&contract_id, || { request_unbonding(&env, &validator, 900).unwrap(); - assert_eq!( - request_unbonding(&env, &validator, 700), - Err(Error::UnbondingAlreadyQueued) - ); + assert_eq!(request_unbonding(&env, &validator, 700), Err(Error::UnbondingAlreadyQueued)); }); } - // ── Test 1-10: apply_slash_cap and penalty cap ───────────────────────────── - #[test] fn test_1_apply_slash_cap_unit_tests() { - // raw_penalty > cap → capped assert_eq!(apply_slash_cap(500_000, 1_000_000), 250_000); - // raw_penalty < cap → no change assert_eq!(apply_slash_cap(100_000, 1_000_000), 100_000); - // raw_penalty == cap → no change assert_eq!(apply_slash_cap(250_000, 1_000_000), 250_000); - // bond_capacity = 0 → 0 assert_eq!(apply_slash_cap(250_000, 0), 0); - // raw_penalty = 0 → 0 assert_eq!(apply_slash_cap(0, 1_000_000), 0); } @@ -352,11 +445,10 @@ mod tests { let env = Env::default(); let relayer = Address::generate(&env); set_stake(&env, &relayer, 1_000_000); - - // Raw penalty would be 50% without cap, but should be capped at 25% + let capped = apply_slash_cap(500_000, 1_000_000); assert_eq!(capped, 250_000); - + let remaining = 1_000_000 - capped; assert_eq!(remaining, 750_000); } @@ -366,8 +458,8 @@ mod tests { let env = Env::default(); let relayer = Address::generate(&env); set_stake(&env, &relayer, 1_000_000); - - let raw = 50_000; // 5% of 1M + + let raw = 50_000; let capped = apply_slash_cap(raw, 1_000_000); assert_eq!(capped, raw); assert_eq!(1_000_000 - capped, 950_000); @@ -378,8 +470,8 @@ mod tests { let env = Env::default(); let relayer = Address::generate(&env); set_stake(&env, &relayer, 1_000_000); - - let raw = 250_000; // exactly 25% + + let raw = 250_000; let capped = apply_slash_cap(raw, 1_000_000); assert_eq!(capped, raw); } @@ -389,27 +481,26 @@ mod tests { let env = Env::default(); let relayer = Address::generate(&env); set_stake(&env, &relayer, 1_000_000); - - // Simulate minor connectivity drop (minor tier) + let base = 50_000; let tier_mult = deviation_multiplier(DeviationTier::Minor); - let raw = base * tier_mult; // 50_000 * 1 = 50_000 (5% of 1M, well below 25%) + let raw = base * tier_mult; let capped = apply_slash_cap(raw, 1_000_000); - + assert_eq!(capped, raw); - assert!(capped < 250_000); // < 25% - assert!(capped > 0); // non-zero penalty + assert!(capped < 250_000); + assert!(capped > 0); } #[test] fn test_6_severity_ordering_preserved_under_cap() { let bond_capacity = 1_000_000; - + let minor = apply_slash_cap(50_000 * deviation_multiplier(DeviationTier::Minor), bond_capacity); let moderate = apply_slash_cap(50_000 * deviation_multiplier(DeviationTier::Moderate), bond_capacity); let significant = apply_slash_cap(50_000 * deviation_multiplier(DeviationTier::Significant), bond_capacity); let manipulation = apply_slash_cap(50_000 * deviation_multiplier(DeviationTier::Manipulation), bond_capacity); - + assert!(minor < moderate); assert!(moderate < significant); assert!(significant < manipulation); @@ -417,31 +508,28 @@ mod tests { #[test] fn test_7_no_bankruptcy_from_single_incident() { - let min_stake = 100_000; // minimum viable bond - let raw = i128::MAX; // worst possible penalty + let min_stake = 100_000; + let raw = i128::MAX; let capped = apply_slash_cap(raw, min_stake); - - assert_eq!(capped, min_stake * 25 / 100); // exactly 25% + + assert_eq!(capped, min_stake * 25 / 100); let remaining = min_stake - capped; - assert_eq!(remaining, min_stake * 75 / 100); // 75% remains - assert!(remaining > 0); // not bankrupt + assert_eq!(remaining, min_stake * 75 / 100); + assert!(remaining > 0); } #[test] fn test_8_multiple_incidents_accumulate_independently() { let mut stake = 1_000_000; - - // First incident (capped at 25% of initial) + let cap1 = stake * 25 / 100; stake -= cap1; assert_eq!(stake, 750_000); - - // Second incident (capped at 25% of new stake) + let cap2 = stake * 25 / 100; stake -= cap2; assert_eq!(stake, 562_500); - - // Each cap calculated against current stake at time of incident + assert_eq!(cap1, 250_000); assert_eq!(cap2, 187_500); } @@ -450,8 +538,7 @@ mod tests { fn test_9_saturating_arithmetic_on_max_bond_value() { let max_bond = i128::MAX; let raw = i128::MAX; - - // Should not panic + let capped = apply_slash_cap(raw, max_bond); assert_eq!(capped, max_bond.saturating_mul(25).saturating_div(100)); } diff --git a/contracts/price-oracle/src/test.rs b/contracts/price-oracle/src/test.rs index ef07ed7..f11363c 100644 --- a/contracts/price-oracle/src/test.rs +++ b/contracts/price-oracle/src/test.rs @@ -127,7 +127,7 @@ fn test_get_price_existing_asset() { client.set_price(&asset, &1_000_000_i128); let result = client.try_get_price(&asset, &true); - let retrieved_price = result.unwrap() + let retrieved_price = result.unwrap(); assert_eq!(retrieved_price.price, 1_000_000_i128); assert_eq!(retrieved_price.timestamp, 1_234_567_890); assert_eq!(retrieved_price.decimals, 6u32); @@ -156,12 +156,11 @@ fn test_get_price_multiple_assets() { client .try_set_price(&ngn, &1_000_000_i128) - .unwrap() - + .unwrap(); + client .try_set_price(&kes, &50_000_000_000_i128) - .unwrap() - + .unwrap(); assert_eq!( client.try_get_price(&ngn, &true).unwrap().unwrap().price, @@ -184,17 +183,16 @@ fn test_get_price_after_update() { env.ledger().with_mut(|li| li.sequence_number = 1); client .try_set_price(&asset, &1_000_000_i128, &6u32, &3600u64) - .unwrap() - + .unwrap(); - let initial = client.try_get_price(&asset, &true).unwrap() + let initial = client.try_get_price(&asset, &true).unwrap(); assert_eq!(initial.price, 1_000_000_i128); assert_eq!(initial.timestamp, 1_234_567_890); env.ledger().with_mut(|li| li.timestamp = 1_234_567); client.set_price(&asset, &1_500_i128, &2u32, &3_600u64); - let updated = client.try_get_price(&asset, &true).unwrap() + let updated = client.try_get_price(&asset, &true).unwrap(); assert_eq!(updated.price, 1_200_000_i128); assert_eq!(updated.timestamp, 1_234_567_900); } @@ -414,14 +412,13 @@ fn test_set_and_get_price_bounds() { set_admin(&env, &contract_id, &admin); client.set_price_bounds(&admin, &asset, &500_i128, &2_000_i128); - let bounds = client.get_price_bounds(&asset) + let bounds = client.get_price_bounds(&asset); assert_eq!(bounds.min_price, 500_i128); assert_eq!(bounds.max_price, 2_000_i128); } #[test] fn test_set_price_bounds_emits_indexable_price_bounds_event() { -fn test_register_assets_with_config_applies_all_config_atomically() { let (env, contract_id, client) = setup(); let admin = Address::generate(&env); let asset = symbol_short!("NGN"); @@ -436,8 +433,12 @@ fn test_register_assets_with_config_applies_all_config_atomically() { } #[test] -fn test_set_price_floor_emits_indexable_price_floor_event() { +fn test_register_assets_with_config_applies_all_config_atomically() { + let (env, contract_id, client) = setup(); + let admin = Address::generate(&env); + let asset = symbol_short!("NGN"); + set_admin(&env, &contract_id, &admin); let config = AssetRegistrationConfig { asset: asset.clone(), name: Symbol::new(&env, "Nigerian Naira"), @@ -449,19 +450,18 @@ fn test_set_price_floor_emits_indexable_price_floor_event() { }; client - .register_assets_with_config(&admin, &vec![&env, config], &500_i128) - + .register_assets_with_config(&admin, &soroban_sdk::vec![&env, config], &500_i128); - let info = client.get_asset_info(&asset) + let info = client.get_asset_info(&asset); assert_eq!(info.name, Symbol::new(&env, "Nigerian Naira")); assert_eq!(info.base_decimals, 7); assert_eq!(info.quote_decimals, 2); - let meta = client.get_asset_meta(&asset) + let meta = client.get_asset_meta(&asset); assert_eq!(meta.base_decimals, 7); assert_eq!(meta.quote_decimals, 2); - let bounds = client.get_price_bounds(&asset) + let bounds = client.get_price_bounds(&asset); assert_eq!(bounds.min_price, 500_i128); assert_eq!(bounds.max_price, 2_000_i128); assert_eq!(client.get_max_deviation_percentage(), 500_i128); @@ -492,7 +492,7 @@ fn test_register_assets_with_config_rolls_back_on_invalid_config() { price_floor: Some(600_i128), }; - let result = client.try_register_assets_with_config(&admin, &vec![&env, bad_config], &500_i128); + let result = client.try_register_assets_with_config(&admin, &soroban_sdk::vec![&env, bad_config], &500_i128); match result { Err(Ok(err)) => assert_eq!(err, Error::InvalidPriceBounds), other => panic!("expected InvalidPriceBounds, got {:?}", other), @@ -1587,7 +1587,7 @@ fn test_get_prices_with_status_marks_stale_entry() { let results = client.get_prices_with_status(&assets); assert_eq!(results.len(), 1); - let entry = results.get(0).unwrap() + let entry = results.get(0).unwrap(); assert_eq!(entry.price, 1_500_i128); assert!(entry.is_stale); } @@ -1633,7 +1633,7 @@ fn test_set_price_bounds_and_get() { let asset = symbol_short!("NGN"); client.set_price_bounds(&admin, &asset, &500_i128, &2_000_i128); - let bounds = client.get_price_bounds(&asset) + let bounds = client.get_price_bounds(&asset); assert_eq!(bounds.min_price, 500_i128); assert_eq!(bounds.max_price, 2_000_i128); } @@ -2753,7 +2753,7 @@ fn test_ledger_gap_new_provider_allowed() { env.ledger().with_mut(|li| li.sequence_number = 100); client.init_admin(&admin); - client.add_asset(&admin, &asset) + client.add_asset(&admin, &asset); env.as_contract(&contract_id, || { crate::auth::_add_provider(&env, &provider); @@ -2780,7 +2780,7 @@ fn test_ledger_gap_insufficient_gap_rejected() { env.ledger().with_mut(|li| li.sequence_number = 100); client.init_admin(&admin); - client.add_asset(&admin, &asset) + client.add_asset(&admin, &asset); env.as_contract(&contract_id, || { crate::auth::_add_provider(&env, &provider); @@ -2822,7 +2822,7 @@ fn test_ledger_gap_exactly_3_blocks_allowed() { env.ledger().with_mut(|li| li.sequence_number = 100); client.init_admin(&admin); - client.add_asset(&admin, &asset) + client.add_asset(&admin, &asset); env.as_contract(&contract_id, || { crate::auth::_add_provider(&env, &provider); @@ -2859,7 +2859,7 @@ fn test_ledger_gap_more_than_3_blocks_allowed() { env.ledger().with_mut(|li| li.sequence_number = 100); client.init_admin(&admin); - client.add_asset(&admin, &asset) + client.add_asset(&admin, &asset); env.as_contract(&contract_id, || { crate::auth::_add_provider(&env, &provider); @@ -2897,7 +2897,7 @@ fn test_ledger_gap_multiple_providers_independent() { env.ledger().with_mut(|li| li.sequence_number = 100); client.init_admin(&admin); - client.add_asset(&admin, &asset) + client.add_asset(&admin, &asset); env.as_contract(&contract_id, || { crate::auth::_add_provider(&env, &provider_a); @@ -2969,7 +2969,7 @@ fn test_ledger_gap_provider_last_seen_ledger_tracking() { env.ledger().with_mut(|li| li.sequence_number = 100); client.init_admin(&admin); - client.add_asset(&admin, &asset) + client.add_asset(&admin, &asset); env.as_contract(&contract_id, || { crate::auth::_add_provider(&env, &provider); @@ -3002,35 +3002,6 @@ fn test_ledger_gap_provider_last_seen_ledger_tracking() { ); } - let contract_id = env.register_contract(None, PriceOracle); - let client = PriceOracleClient::new(&env, &contract_id); - - let admin1 = ::generate(&env); - let admin2 = ::generate(&env); - - client.init_admin(&admin1); - env.as_contract(&contract_id, || { - crate::auth::_add_authorized(&env, &admin2); - }); - - client.self_destruct(&admin1, &admin2); - - let events = env.events().all(); - let debug_str = alloc::format!("{:?}", events); - assert!( - debug_str.contains("contract_destroyed"), - "Should emit contract_destroyed event" - ); - assert!( - debug_str.contains(&format!("{:?}", admin1)), - "Event should contain admin1" - ); - assert!( - debug_str.contains(&format!("{:?}", admin2)), - "Event should contain admin2" - ); -} - #[test] #[should_panic(expected = "Error(ContractDestroyed)")] fn test_self_destruct_prevents_double_destruct() { @@ -3117,8 +3088,8 @@ fn test_try_unsubscribe_from_price_updates() { let callback2 = Address::generate(&env); // Subscribe both - client.try_subscribe_to_price_updates(&callback1) - client.try_subscribe_to_price_updates(&callback2) + client.try_subscribe_to_price_updates(&callback1); + client.try_subscribe_to_price_updates(&callback2); assert_eq!(client.get_price_update_subscribers().len(), 2); // Unsubscribe first @@ -3138,7 +3109,7 @@ fn test_unsubscribe_nonexistent_fails() { let callback2 = Address::generate(&env); // Subscribe only callback1 - client.try_subscribe_to_price_updates(&callback1) + client.try_subscribe_to_price_updates(&callback1); // Try to unsubscribe callback2 (not subscribed) let result = client.try_unsubscribe_from_price_updates(&callback2); @@ -3308,7 +3279,7 @@ fn test_buffer_truncation_with_equal_weights() { // Have all 13 providers submit prices env.ledger().with_mut(|li| li.sequence_number = 300); for i in 0..13 { - let provider = providers.get(i) + let provider = providers.get(i); let price = 800_000_i128 + (i as i128 * 10); client.update_price(&provider, &asset, &price, &6u32, 90u32, &3600u64, &100_000_i128); } @@ -3317,7 +3288,7 @@ fn test_buffer_truncation_with_equal_weights() { let buffer = client.get_price_buffer_data(&asset); assert!(buffer.is_some(), "Buffer should exist"); - let buffer_data = buffer + let buffer_data = buffer; assert_eq!( buffer_data.entries.len(), 11, @@ -3359,7 +3330,7 @@ fn test_median_calculation_after_truncation() { // Have all 12 providers submit prices env.ledger().with_mut(|li| li.sequence_number = 400); for i in 0..12 { - let provider = providers.get(i) + let provider = providers.get(i); let price = 1_000_000_i128 + (i as i128 * 1000); client.update_price(&provider, &asset, &price, &6u32, 90u32, &3600u64, &100_000_i128); } @@ -3372,7 +3343,7 @@ fn test_median_calculation_after_truncation() { ); // The low-weight provider (index 11) should have been excluded - let buffer = client.get_price_buffer_data(&asset) + let buffer = client.get_price_buffer_data(&asset); assert_eq!(buffer.entries.len(), 11, "Buffer should contain 11 entries"); } @@ -3631,7 +3602,7 @@ fn test_graceful_recovery_clears_metrics() { assert_eq!(client.get_twap(&asset), Some(1000)); assert_eq!(client.get_last_n_events(&5).len(), 1); - assert_eq!(client.get_provider_consecutive_missed_blocks(&provider), 5); + assert_eq!(client.get_missed_blocks(&provider), 5); // 2. Emergency Halt client.set_emergency_halt(&admin1, &admin2, &true); @@ -3645,6 +3616,6 @@ fn test_graceful_recovery_clears_metrics() { // 4. Verify Metrics Cleared assert_eq!(client.get_twap(&asset), None); assert_eq!(client.get_last_n_events(&5).len(), 0); - assert_eq!(client.get_provider_consecutive_missed_blocks(&provider), 0); + assert_eq!(client.get_missed_blocks(&provider), 0); assert_eq!(client.get_provider_last_seen_ledger(&provider), 500); } diff --git a/contracts/price-oracle/src/validation.rs b/contracts/price-oracle/src/validation.rs index 2c756e0..724acfd 100644 --- a/contracts/price-oracle/src/validation.rs +++ b/contracts/price-oracle/src/validation.rs @@ -134,32 +134,33 @@ mod tests { let result = filter_feeds_by_deviation(&twap, feeds, &env); assert_eq!(result.len(), 2); } -//! Liquidity volume validation module — flash loan manipulation prevention. -//! -//! Aggregating market prices from thinly backed liquidity channels can expose -//! downstream financial engines to flash loan price manipulations. This module -//! implements explicit liquidity volume validation checks that terminate -//! transaction paths early if a validator node's reported pool liquidity falls -//! below the configured minimum security threshold. -//! -//! # Security Model -//! -//! Flash loan attacks exploit temporary price dislocations in low-liquidity pools. -//! By requiring minimum liquidity thresholds, we ensure that price submissions -//! come from markets with sufficient depth to resist manipulation. -//! -//! # Flow -//! 1. Admin sets liquidity threshold per asset via `set_liquidity_threshold`. -//! 2. Provider submits price + liquidity data via `update_price`. -//! 3. Contract validates liquidity meets threshold before accepting submission. -//! 4. Submissions below threshold are rejected with `LiquidityBelowThreshold` error. -//! -//! # Storage layout -//! | Key | Type | Description | -//! |--------------------------------------|-----------|------------------------------------------------| -//! | `DataKey::LiquidityThreshold(Symbol)` | `i128` | Minimum liquidity required per asset (stroops) | -//! | `DataKey::ProviderReportedLiquidity(Address, Symbol)` | `i128` | Last reported liquidity by provider for asset | -//! | `DataKey::LastLiquidityValidation(Symbol)` | `u64` | Timestamp of last successful validation | +} +// Liquidity volume validation module — flash loan manipulation prevention. +// +// Aggregating market prices from thinly backed liquidity channels can expose +// downstream financial engines to flash loan price manipulations. This module +// implements explicit liquidity volume validation checks that terminate +// transaction paths early if a validator node's reported pool liquidity falls +// below the configured minimum security threshold. +// +// # Security Model +// +// Flash loan attacks exploit temporary price dislocations in low-liquidity pools. +// By requiring minimum liquidity thresholds, we ensure that price submissions +// come from markets with sufficient depth to resist manipulation. +// +// # Flow +// 1. Admin sets liquidity threshold per asset via `set_liquidity_threshold`. +// 2. Provider submits price + liquidity data via `update_price`. +// 3. Contract validates liquidity meets threshold before accepting submission. +// 4. Submissions below threshold are rejected with `LiquidityBelowThreshold` error. +// +// # Storage layout +// | Key | Type | Description | +// |--------------------------------------|-----------|------------------------------------------------| +// | `DataKey::LiquidityThreshold(Symbol)` | `i128` | Minimum liquidity required per asset (stroops) | +// | `DataKey::ProviderReportedLiquidity(Address, Symbol)` | `i128` | Last reported liquidity by provider for asset | +// | `DataKey::LastLiquidityValidation(Symbol)` | `u64` | Timestamp of last successful validation | use soroban_sdk::{Address, Env, Symbol, Vec};