diff --git a/contracts/predictify-hybrid/src/admin.rs b/contracts/predictify-hybrid/src/admin.rs index dcea05f4..beba0e68 100644 --- a/contracts/predictify-hybrid/src/admin.rs +++ b/contracts/predictify-hybrid/src/admin.rs @@ -1683,6 +1683,14 @@ pub struct PendingThresholdUpdate { pub proposed_by: Address, } +/// State for multisig signer rotation cooldowns +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct MultisigRotationState { + pub cooldown_seconds: u64, + pub last_rotation_timestamp: u64, +} + pub struct MultisigManager; impl MultisigManager { @@ -1896,6 +1904,71 @@ impl MultisigManager { let count_key = Symbol::new(env, "AdminCount"); env.storage().persistent().get(&count_key).unwrap_or(1) } + + /// Get current rotation state + pub fn get_rotation_state(env: &Env) -> MultisigRotationState { + env.storage() + .persistent() + .get(&crate::storage::DataKey::MultisigRotationState) + .unwrap_or(MultisigRotationState { + cooldown_seconds: 3600, // 1 hour default + last_rotation_timestamp: 0, + }) + } + + /// Set rotation cooldown (Emergency permission required) + pub fn set_rotation_cooldown(env: &Env, admin: &Address, cooldown_seconds: u64) -> Result<(), Error> { + admin.require_auth(); + AdminAccessControl::validate_permission(env, admin, &AdminPermission::Emergency)?; + let mut state = Self::get_rotation_state(env); + state.cooldown_seconds = cooldown_seconds; + env.storage().persistent().set(&crate::storage::DataKey::MultisigRotationState, &state); + Ok(()) + } + + /// Enforce rotation cooldown + fn enforce_rotation_cooldown(env: &Env, admin: &Address) -> Result<(), Error> { + let mut state = Self::get_rotation_state(env); + let current_time = env.ledger().timestamp(); + + if current_time < state.last_rotation_timestamp + state.cooldown_seconds { + EventEmitter::emit_signer_rotation_cooldown_hit(env, admin, state.last_rotation_timestamp, state.cooldown_seconds); + return Err(Error::SignerRotationCooldown); + } + + state.last_rotation_timestamp = current_time; + env.storage().persistent().set(&crate::storage::DataKey::MultisigRotationState, &state); + Ok(()) + } + + /// Add a new signer enforcing rotation cooldown + pub fn add_signer(env: &Env, admin: &Address, new_signer: &Address, role: AdminRole) -> Result<(), Error> { + admin.require_auth(); + Self::enforce_rotation_cooldown(env, admin)?; + AdminManager::add_admin(env, admin, new_signer, role) + } + + /// Remove a signer enforcing rotation cooldown + pub fn remove_signer(env: &Env, admin: &Address, old_signer: &Address) -> Result<(), Error> { + admin.require_auth(); + Self::enforce_rotation_cooldown(env, admin)?; + AdminManager::remove_admin(env, admin, old_signer) + } + + /// Rotate signer enforcing rotation cooldown + pub fn rotate_signer( + env: &Env, + admin: &Address, + old_signer: &Address, + new_signer: &Address, + role: AdminRole, + ) -> Result<(), Error> { + admin.require_auth(); + Self::enforce_rotation_cooldown(env, admin)?; + + AdminManager::remove_admin(env, admin, old_signer)?; + AdminManager::add_admin(env, admin, new_signer, role) + } } // ===== ADMIN FUNCTIONS ===== diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index 29996cd1..273ab37f 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -195,6 +195,8 @@ pub enum Error { DuplicateMarketId = 441, /// Override replay detected. Nonce has already been used. ReplayedOverride = 442, + /// Signer rotation cooldown has not yet expired. + SignerRotationCooldown = 443, // ===== CIRCUIT BREAKER ERRORS ===== /// Circuit breaker has not been initialized. Initialize before use. diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index e0daa171..1db1924e 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -3093,6 +3093,16 @@ impl EventEmitter { .publish((symbol_short!("adm_deact"), admin.clone()), event); } + /// Emit signer rotation cooldown hit event + pub fn emit_signer_rotation_cooldown_hit(env: &Env, admin: &Address, last_rotation: u64, cooldown: u64) { + let topics = (Symbol::new(env, "Admin"), Symbol::new(env, "SignerRotationCooldownHit")); + let mut data = Map::new(env); + data.set(String::from_str(env, "admin"), admin.to_val()); + data.set(String::from_str(env, "last_rotation"), last_rotation); + data.set(String::from_str(env, "cooldown"), cooldown); + env.events().publish(topics, data); + } + /// Emit market closed event pub fn emit_market_closed(env: &Env, market_id: &Symbol, admin: &Address) { let event = MarketClosedEvent { diff --git a/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs b/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs index 688a80a3..fe31bae8 100644 --- a/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs +++ b/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs @@ -1236,3 +1236,89 @@ fn test_double_execute_blocked_even_after_extra_approval() { ); }); } + +// ===== SIGNER ROTATION COOLDOWN TESTS ===== + +#[test] +fn test_rotation_cooldown_enforced_rotate_signer() { + let (env, contract_id, admin) = setup_contract(); + env.as_contract(&contract_id, || { + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + let admin4 = Address::generate(&env); + + // Use regular AdminManager for setup to bypass cooldown initially + AdminManager::add_admin(&env, &admin, &admin2, AdminRole::SuperAdmin).unwrap(); + + // First rotation succeeds + let res = MultisigManager::rotate_signer(&env, &admin, &admin2, &admin3, AdminRole::SuperAdmin); + assert!(res.is_ok()); + + // Second rotation immediately after fails + let res2 = MultisigManager::rotate_signer(&env, &admin, &admin3, &admin4, AdminRole::SuperAdmin); + assert_eq!(res2, Err(Error::SignerRotationCooldown)); + + // Warp time past default 1-hour cooldown + warp_time(&env, 3601); + + // Rotation succeeds now + let res3 = MultisigManager::rotate_signer(&env, &admin, &admin3, &admin4, AdminRole::SuperAdmin); + assert!(res3.is_ok()); + }); +} + +#[test] +fn test_rotation_cooldown_enforced_add_remove_signer() { + let (env, contract_id, admin) = setup_contract(); + env.as_contract(&contract_id, || { + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + + // First action (add) succeeds + let res = MultisigManager::add_signer(&env, &admin, &admin2, AdminRole::SuperAdmin); + assert!(res.is_ok()); + + // Immediate second action (remove) fails due to cooldown + let res2 = MultisigManager::remove_signer(&env, &admin, &admin2); + assert_eq!(res2, Err(Error::SignerRotationCooldown)); + + // Warp time past cooldown + warp_time(&env, 3601); + + // Now remove succeeds + let res3 = MultisigManager::remove_signer(&env, &admin, &admin2); + assert!(res3.is_ok()); + + // Immediate next action fails + let res4 = MultisigManager::add_signer(&env, &admin, &admin3, AdminRole::SuperAdmin); + assert_eq!(res4, Err(Error::SignerRotationCooldown)); + }); +} + +#[test] +fn test_set_rotation_cooldown() { + let (env, contract_id, admin) = setup_contract(); + env.as_contract(&contract_id, || { + let res = MultisigManager::set_rotation_cooldown(&env, &admin, 7200); + assert!(res.is_ok()); + + let state = MultisigManager::get_rotation_state(&env); + assert_eq!(state.cooldown_seconds, 7200); + + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + + // Action 1 + MultisigManager::add_signer(&env, &admin, &admin2, AdminRole::SuperAdmin).unwrap(); + + // Try after 1 hour (fails because new cooldown is 2 hours) + warp_time(&env, 3601); + let res2 = MultisigManager::add_signer(&env, &admin, &admin3, AdminRole::SuperAdmin); + assert_eq!(res2, Err(Error::SignerRotationCooldown)); + + // Warp another hour + warp_time(&env, 3600); + let res3 = MultisigManager::add_signer(&env, &admin, &admin3, AdminRole::SuperAdmin); + assert!(res3.is_ok()); + }); +} diff --git a/docs/DEPRECATION_POLICY.md b/docs/DEPRECATION_POLICY.md index 98ee0bc4..201a69e6 100644 --- a/docs/DEPRECATION_POLICY.md +++ b/docs/DEPRECATION_POLICY.md @@ -1,5 +1,7 @@ # Deprecation Policy + + ## Overview This document describes the deprecation policy for the Predictify Hybrid contract. As the platform evolves, certain entrypoints become obsolete and need to be phased out. A structured deprecation process ensures that callers have adequate notice and can migrate smoothly.