Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions contracts/predictify-hybrid/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 =====
Expand Down
2 changes: 2 additions & 0 deletions contracts/predictify-hybrid/src/err.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions contracts/predictify-hybrid/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
86 changes: 86 additions & 0 deletions contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});
}
2 changes: 2 additions & 0 deletions docs/DEPRECATION_POLICY.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading