diff --git a/acbu_oracle/src/lib.rs b/acbu_oracle/src/lib.rs index 57e7ff21..5dea0cbd 100644 --- a/acbu_oracle/src/lib.rs +++ b/acbu_oracle/src/lib.rs @@ -438,6 +438,16 @@ impl OracleContract { } } + if sources.len() > 0 { + let min_sigs: u32 = env + .storage() + .instance() + .get(&DATA_KEY.min_signatures) + .unwrap(); + let required = min_sigs.max(MIN_ORACLE_SOURCE_FEEDS); + if sources.len() < required { + env.panic_with_error(OracleError::InsufficientOracleSources); + } let min_sigs: u32 = env .storage() .instance() diff --git a/acbu_oracle/tests/test.rs b/acbu_oracle/tests/test.rs index 50875d1c..8b1b8248 100644 --- a/acbu_oracle/tests/test.rs +++ b/acbu_oracle/tests/test.rs @@ -699,3 +699,142 @@ fn test_rate_not_initialized_before_first_submission() { let result = client.try_get_acbu_usd_rate_with_timestamp(); assert!(result.is_err(), "get_acbu_usd_rate_with_timestamp should fail before first submission"); } + +// ─── Oracle quorum / InsufficientOracleSources tests (#319) ────────────────── + +/// Submitting sources.len() > 0 but below max(min_signatures, MIN_ORACLE_SOURCE_FEEDS=3) +/// must panic with InsufficientOracleSources (#7009). +#[test] +#[should_panic] +fn test_update_rate_too_few_sources_panics() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let admin = Address::generate(&env); + let validator = Address::generate(&env); + let mut validators = Vec::new(&env); + validators.push_back(validator.clone()); + + let ngn = CurrencyCode::new(&env, "NGN"); + let mut currencies = Vec::new(&env); + currencies.push_back(ngn.clone()); + let mut basket_weights = Map::new(&env); + basket_weights.set(ngn.clone(), 10_000i128); + + let contract_id = env.register_contract(None, OracleContract); + let client = OracleContractClient::new(&env, &contract_id); + // min_signatures = 1, but MIN_ORACLE_SOURCE_FEEDS = 3, so required = max(1,3) = 3 + client.initialize(&admin, &validators, &1u32, ¤cies, &basket_weights); + + let mut two_sources = Vec::new(&env); + two_sources.push_back(1_000_000i128); + two_sources.push_back(1_000_000i128); + // 2 sources < 3 required → must panic + client.update_rate(&validator, &ngn, &1_000_000i128, &two_sources, &env.ledger().timestamp()); +} + +/// Submitting exactly MIN_ORACLE_SOURCE_FEEDS sources must succeed. +#[test] +fn test_update_rate_exactly_min_sources_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let admin = Address::generate(&env); + let validator = Address::generate(&env); + let mut validators = Vec::new(&env); + validators.push_back(validator.clone()); + + let ngn = CurrencyCode::new(&env, "NGN"); + let mut currencies = Vec::new(&env); + currencies.push_back(ngn.clone()); + let mut basket_weights = Map::new(&env); + basket_weights.set(ngn.clone(), 10_000i128); + + let contract_id = env.register_contract(None, OracleContract); + let client = OracleContractClient::new(&env, &contract_id); + client.initialize(&admin, &validators, &1u32, ¤cies, &basket_weights); + + // Exactly 3 sources (MIN_ORACLE_SOURCE_FEEDS) — must not panic. + let mut three_sources = Vec::new(&env); + three_sources.push_back(1_000_000i128); + three_sources.push_back(1_001_000i128); + three_sources.push_back(999_000i128); + client.update_rate(&validator, &ngn, &1_000_000i128, &three_sources, &env.ledger().timestamp()); + + let stored = client.get_rate(&ngn); + // median of [999_000, 1_000_000, 1_001_000] = 1_000_000 + assert_eq!(stored, 1_000_000, "median of 3 sources should be 1_000_000"); +} + +/// Submitting zero sources must still be accepted (bypass path — rate param is used directly). +#[test] +fn test_update_rate_zero_sources_bypasses_quorum_check() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let admin = Address::generate(&env); + let validator = Address::generate(&env); + let mut validators = Vec::new(&env); + validators.push_back(validator.clone()); + + let ngn = CurrencyCode::new(&env, "NGN"); + let mut currencies = Vec::new(&env); + currencies.push_back(ngn.clone()); + let mut basket_weights = Map::new(&env); + basket_weights.set(ngn.clone(), 10_000i128); + + let contract_id = env.register_contract(None, OracleContract); + let client = OracleContractClient::new(&env, &contract_id); + client.initialize(&admin, &validators, &1u32, ¤cies, &basket_weights); + + // Empty sources: quorum guard is skipped entirely; rate param is stored as-is. + let empty_sources = Vec::new(&env); + let submitted_rate = 1_234_567i128; + client.update_rate(&validator, &ngn, &submitted_rate, &empty_sources, &env.ledger().timestamp()); + + assert_eq!(client.get_rate(&ngn), submitted_rate, "empty sources should store the rate param directly"); +} + +/// When min_signatures > MIN_ORACLE_SOURCE_FEEDS the larger threshold applies. +#[test] +#[should_panic] +fn test_update_rate_min_sigs_larger_than_min_feeds_is_enforced() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let admin = Address::generate(&env); + let v1 = Address::generate(&env); + let v2 = Address::generate(&env); + let v3 = Address::generate(&env); + let v4 = Address::generate(&env); + let v5 = Address::generate(&env); + + let mut validators = Vec::new(&env); + validators.push_back(v1.clone()); + validators.push_back(v2.clone()); + validators.push_back(v3.clone()); + validators.push_back(v4.clone()); + validators.push_back(v5.clone()); + + let ngn = CurrencyCode::new(&env, "NGN"); + let mut currencies = Vec::new(&env); + currencies.push_back(ngn.clone()); + let mut basket_weights = Map::new(&env); + basket_weights.set(ngn.clone(), 10_000i128); + + let contract_id = env.register_contract(None, OracleContract); + let client = OracleContractClient::new(&env, &contract_id); + // min_signatures = 5 > MIN_ORACLE_SOURCE_FEEDS = 3, so required = 5 + client.initialize(&admin, &validators, &5u32, ¤cies, &basket_weights); + + // Only 3 sources — below min_signatures=5 threshold → must panic + let mut three_sources = Vec::new(&env); + three_sources.push_back(1_000_000i128); + three_sources.push_back(1_001_000i128); + three_sources.push_back(999_000i128); + client.update_rate(&v1, &ngn, &1_000_000i128, &three_sources, &env.ledger().timestamp()); +} diff --git a/acbu_reserve_tracker/src/lib.rs b/acbu_reserve_tracker/src/lib.rs index 280c3465..5f0d128d 100644 --- a/acbu_reserve_tracker/src/lib.rs +++ b/acbu_reserve_tracker/src/lib.rs @@ -21,6 +21,7 @@ pub enum ReserveTrackerError { AdminTimelockNotElapsed = 8005, NoPendingAdminToCancel = 8006, Unauthorized = 8007, + DuplicateCurrency = 8008, AttestationNotFound = 8008, InvalidMerkleProof = 8009, InvalidCustodian = 8010, diff --git a/acbu_reserve_tracker/tests/test.rs b/acbu_reserve_tracker/tests/test.rs index 38ee1db7..e6647010 100644 --- a/acbu_reserve_tracker/tests/test.rs +++ b/acbu_reserve_tracker/tests/test.rs @@ -743,4 +743,4 @@ fn test_get_latest_attestation_before_submit_panics() { result.is_err(), "get_latest_attestation must panic when no attestation has been submitted" ); -} \ No newline at end of file +}