diff --git a/apexchainx_calculator/src/config_freeze.rs b/apexchainx_calculator/src/config_freeze.rs index c4230b3..d839bcb 100644 --- a/apexchainx_calculator/src/config_freeze.rs +++ b/apexchainx_calculator/src/config_freeze.rs @@ -53,48 +53,80 @@ pub fn is_config_frozen(env: &Env) -> bool { #[cfg(test)] mod tests { - use super::*; - use soroban_sdk::{testutils::Address as _, Address, Env}; use crate::{SLACalculatorContract, SLACalculatorContractClient}; + use soroban_sdk::{testutils::Address as _, Address, Env}; + + fn setup() -> (Env, SLACalculatorContractClient<'static>, Address, Address) { + let env = Env::default(); + let contract_id = env.register_contract(None, SLACalculatorContract); + let client = SLACalculatorContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let operator = Address::generate(&env); + client.initialize(&admin, &operator); + (env, client, admin, operator) + } #[test] fn test_config_unfrozen_by_default() { - let env = Env::default(); - assert!(!is_config_frozen(&env)); + let (_env, client, _admin, _operator) = setup(); + assert!(!client.is_config_frozen()); } #[test] fn test_freeze_and_query() { - let env = Env::default(); - freeze_config(&env); - assert!(is_config_frozen(&env)); + let (_env, client, admin, _operator) = setup(); + client.freeze_config(&admin); + assert!(client.is_config_frozen()); } #[test] fn test_unfreeze_restores_mutable_state() { - let env = Env::default(); - freeze_config(&env); - unfreeze_config(&env); - assert!(!is_config_frozen(&env)); + let (_env, client, admin, _operator) = setup(); + client.freeze_config(&admin); + client.unfreeze_config(&admin); + assert!(!client.is_config_frozen()); } #[test] - fn test_frozen_config_blocks_set_config() { - let env = Env::default(); - let contract_id = env.register_contract(None, SLACalculatorContract); - let client = SLACalculatorContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let operator = Address::generate(&env); - client.initialize(&admin, &operator); - freeze_config(&env); - assert!(is_config_frozen(&env)); + fn test_frozen_config_flag() { + let (_env, client, admin, _operator) = setup(); + client.freeze_config(&admin); + assert!(client.is_config_frozen()); + } + + #[test] + #[should_panic(expected = "#16")] + fn test_set_config_fails_when_frozen() { + let (_env, client, admin, _operator) = setup(); + client.freeze_config(&admin); + client.set_config( + &admin, + &soroban_sdk::symbol_short!("critical"), + &15, + &100, + &750, + ); + } + + #[test] + fn test_unfreeze_allows_set_config() { + let (_env, client, admin, _operator) = setup(); + client.freeze_config(&admin); + assert!(client.is_config_frozen()); + client.unfreeze_config(&admin); + assert!(!client.is_config_frozen()); + client.set_config( + &admin, + &soroban_sdk::symbol_short!("critical"), + &15, + &100, + &750, + ); } #[test] #[should_panic] fn test_stranger_cannot_set_config_when_unfrozen() { - // Verify that even without a freeze, an unauthorized caller cannot - // mutate config (admin-gated role check, not freeze-gated logic). let env = Env::default(); let contract_id = env.register_contract(None, SLACalculatorContract); let client = SLACalculatorContractClient::new(&env, &contract_id); @@ -102,7 +134,6 @@ mod tests { let operator = Address::generate(&env); client.initialize(&admin, &operator); let stranger = Address::generate(&env); - // stranger does not hold the admin role – require_admin must reject client.set_config( &stranger, &soroban_sdk::symbol_short!("critical"), diff --git a/apexchainx_calculator/src/event_schema.rs b/apexchainx_calculator/src/event_schema.rs index f3fa2b3..cc3c337 100644 --- a/apexchainx_calculator/src/event_schema.rs +++ b/apexchainx_calculator/src/event_schema.rs @@ -92,6 +92,16 @@ //! - topic[2]: caller Address //! - payload: () //! +//! ## cfg_frz (`cfg_frz`) +//! Emitted when the configuration is frozen by admin. +//! - topic[2]: caller Address +//! - payload: () +//! +//! ## cfg_unfrz (`cfg_unfrz`) +//! Emitted when the configuration is unfrozen by admin. +//! - topic[2]: caller Address +//! - payload: () +//! //! # Schema Versioning //! //! Breaking changes (field removal, type changes, reordering) MUST increment @@ -122,6 +132,8 @@ pub const EVENT_ADMIN_REN: Symbol = symbol_short!("adm_ren"); pub const EVENT_OP_PROP: Symbol = symbol_short!("op_prop"); pub const EVENT_OP_ACC: Symbol = symbol_short!("op_acc"); pub const EVENT_OP_CAN: Symbol = symbol_short!("op_can"); +pub const EVENT_CONFIG_FREEZE: Symbol = symbol_short!("cfg_frz"); +pub const EVENT_CONFIG_UNFREEZE: Symbol = symbol_short!("cfg_unfrz"); /// Returns the canonical event version string for consumer documentation. pub fn current_event_version() -> Symbol { @@ -156,6 +168,8 @@ mod tests { EVENT_OP_PROP, EVENT_OP_ACC, EVENT_OP_CAN, + EVENT_CONFIG_FREEZE, + EVENT_CONFIG_UNFREEZE, ]; for i in 0..names.len() { diff --git a/apexchainx_calculator/src/lib.rs b/apexchainx_calculator/src/lib.rs index a8d11bd..bd799e9 100644 --- a/apexchainx_calculator/src/lib.rs +++ b/apexchainx_calculator/src/lib.rs @@ -13,6 +13,7 @@ pub struct SLACalculatorContract; mod tests; pub mod config_bundle; +pub mod config_freeze; pub mod config_metadata; pub mod coordination_harness; pub mod cross_contract_safety; @@ -200,6 +201,12 @@ const EVENT_OP_ACC: Symbol = symbol_short!("op_acc"); /// Emitted when a pending operator proposal is cancelled. (SC-024) const EVENT_OP_CAN: Symbol = symbol_short!("op_can"); +/// Emitted when the configuration is frozen by admin. +const EVENT_CONFIG_FREEZE: Symbol = symbol_short!("cfg_frz"); + +/// Emitted when the configuration is unfrozen by admin. +const EVENT_CONFIG_UNFREEZE: Symbol = symbol_short!("cfg_unfrz"); + /// Canonical event version symbol used by all events. const EVENT_VERSION: Symbol = symbol_short!("v1"); @@ -251,8 +258,10 @@ pub enum SLAError { InvalidPenaltyAmount = 14, /// Computed reward amount is invalid (e.g., zero or negative). (SC-W5-046) InvalidRewardAmount = 15, + /// Configuration is frozen — config changes are blocked. + ConfigFrozen = 16, /// Input parameter violates documented constraints (e.g., reason too long). (#68) - InvalidInput = 16, + InvalidInput = 17, } // ----------------------------------------------------------------------- @@ -832,6 +841,38 @@ impl SLACalculatorContract { Ok(env.storage().instance().get(&PAUSE_INFO_KEY)) } + // ------------------------------------------------------------------- + // Config freeze / unfreeze (admin only) + // ------------------------------------------------------------------- + + /// Freezes the configuration, blocking further config updates. + /// Admin only. Emits a `cfg_frz` event. + pub fn freeze_config(env: Env, caller: Address) -> Result<(), SLAError> { + Self::check_version(&env)?; + Self::require_admin(&env, &caller)?; + config_freeze::freeze_config(&env); + env.events() + .publish((EVENT_CONFIG_FREEZE, EVENT_VERSION, caller), ()); + Ok(()) + } + + /// Unfreezes the configuration, re-allowing config updates. + /// Admin only. Emits a `cfg_unfrz` event. + pub fn unfreeze_config(env: Env, caller: Address) -> Result<(), SLAError> { + Self::check_version(&env)?; + Self::require_admin(&env, &caller)?; + config_freeze::unfreeze_config(&env); + env.events() + .publish((EVENT_CONFIG_UNFREEZE, EVENT_VERSION, caller), ()); + Ok(()) + } + + /// Returns `true` when the configuration is currently frozen. + pub fn is_config_frozen(env: Env) -> Result { + Self::check_version(&env)?; + Ok(config_freeze::is_config_frozen(&env)) + } + // ------------------------------------------------------------------- // Config management (admin only) #28 // ------------------------------------------------------------------- @@ -846,6 +887,7 @@ impl SLACalculatorContract { ) -> Result<(), SLAError> { Self::check_version(&env)?; Self::require_admin(&env, &caller)?; // #28 – admin role enforced + Self::require_not_frozen(&env)?; // #70 – Validate configuration parameters Self::validate_config( @@ -962,7 +1004,7 @@ impl SLACalculatorContract { // Emit in numeric order for deterministic consumption // All descriptions must be <= 32 bytes (Soroban Symbol constraint) - let entries: [(u32, &str, &str); 15] = [ + let entries: [(u32, &str, &str); 17] = [ (1, "AlreadyInitialized", "Contract already initialized"), (2, "NotInitialized", "Contract not yet initialized"), (3, "Unauthorized", "Caller lacks required role"), @@ -982,6 +1024,8 @@ impl SLACalculatorContract { (13, "DuplicateOutageInput", "Duplicate outage input"), (14, "InvalidPenaltyAmount", "Invalid penalty amount"), (15, "InvalidRewardAmount", "Invalid reward amount"), + (16, "ConfigFrozen", "Configuration is frozen"), + (17, "InvalidInput", "Invalid input parameter"), ]; for (code, label, description) in entries { @@ -1049,6 +1093,7 @@ impl SLACalculatorContract { features.push_back(symbol_short!("safe_call")); features.push_back(symbol_short!("ver_nego")); features.push_back(symbol_short!("corr_id")); + features.push_back(symbol_short!("freeze")); Ok(ContractMetadata { contract_name: symbol_short!("sla_calc"), @@ -1311,6 +1356,13 @@ impl SLACalculatorContract { Ok(()) } + fn require_not_frozen(env: &Env) -> Result<(), SLAError> { + if config_freeze::is_config_frozen(env) { + return Err(SLAError::ConfigFrozen); + } + Ok(()) + } + /// #70 – Validates configuration parameters to ensure safe and meaningful values. fn validate_config( severity: &Symbol, diff --git a/apexchainx_calculator/src/tests.rs b/apexchainx_calculator/src/tests.rs index 79d299e..1e0865e 100644 --- a/apexchainx_calculator/src/tests.rs +++ b/apexchainx_calculator/src/tests.rs @@ -444,6 +444,94 @@ fn test_calculate_sla_works_after_unpause() { assert_eq!(result.status, symbol_short!("met")); } +// ============================================================ +// Config freeze / unfreeze +// ============================================================ + +#[test] +fn test_config_starts_unfrozen() { + let (_env, client, _actors) = setup(); + assert!(!client.is_config_frozen()); +} + +#[test] +fn test_admin_can_freeze_and_unfreeze() { + let (_env, client, actors) = setup(); + assert!(!client.is_config_frozen()); + + client.freeze_config(&actors.admin); + assert!(client.is_config_frozen()); + + client.unfreeze_config(&actors.admin); + assert!(!client.is_config_frozen()); +} + +#[test] +#[should_panic] +fn test_operator_cannot_freeze() { + let (_env, client, actors) = setup(); + client.freeze_config(&actors.operator); +} + +#[test] +#[should_panic] +fn test_stranger_cannot_freeze() { + let (_env, client, actors) = setup(); + client.freeze_config(&actors.stranger); +} + +#[test] +#[should_panic] +fn test_operator_cannot_unfreeze() { + let (_env, client, actors) = setup(); + client.freeze_config(&actors.admin); + client.unfreeze_config(&actors.operator); +} + +#[test] +#[should_panic] +fn test_set_config_blocked_when_frozen() { + let (_env, client, actors) = setup(); + client.freeze_config(&actors.admin); + client.set_config(&actors.admin, &symbol_short!("critical"), &15, &100, &750); +} + +#[test] +fn test_set_config_works_after_unfreeze() { + let (_env, client, actors) = setup(); + client.freeze_config(&actors.admin); + client.unfreeze_config(&actors.admin); + // Should not panic after unfreeze + client.set_config(&actors.admin, &symbol_short!("critical"), &15, &100, &750); +} + +#[test] +fn test_freeze_emits_event() { + let (env, client, actors) = setup(); + client.freeze_config(&actors.admin); + let events = env.events().all(); + let (_, topics, _) = events.last().unwrap(); + assert_eq!(topics.len(), 3); + let name: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); + let version: Symbol = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(name, symbol_short!("cfg_frz")); + assert_eq!(version, symbol_short!("v1")); +} + +#[test] +fn test_unfreeze_emits_event() { + let (env, client, actors) = setup(); + client.freeze_config(&actors.admin); + client.unfreeze_config(&actors.admin); + let events = env.events().all(); + let (_, topics, _) = events.last().unwrap(); + assert_eq!(topics.len(), 3); + let name: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); + let version: Symbol = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(name, symbol_short!("cfg_unfrz")); + assert_eq!(version, symbol_short!("v1")); +} + // ============================================================ // SLA business logic correctness // ============================================================ @@ -1574,7 +1662,7 @@ fn test_get_contract_metadata_returns_expected_fields() { assert_eq!(meta.storage_version, 1); assert_eq!(meta.result_schema_version, 1); assert_eq!(meta.supported_severities.len(), 4); - assert_eq!(meta.features.len(), 9); + assert_eq!(meta.features.len(), 10); } #[test] @@ -1837,7 +1925,7 @@ fn test_pause_stores_reason_and_timestamp() { } #[test] -#[should_panic(expected = "#16")] +#[should_panic(expected = "#17")] fn test_pause_rejects_long_reason() { let (env, client, actors) = setup(); // 257-byte reason exceeds MAX_REASON_LEN (256)