diff --git a/Cargo.toml b/Cargo.toml index 93282a36..60e70633 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "contracts/vault-health-monitor", "contracts/multisig-weight-guard", "contracts/yield-unwind", + "contracts/multisig-admin-rotation", ] # tests/fuzz/fuzz/ is the cargo-fuzz coverage-guided target subcrate. # It depends on libfuzzer-sys, which only builds on nightly Rust, so it diff --git a/contracts/multisig-admin-rotation/Cargo.toml b/contracts/multisig-admin-rotation/Cargo.toml new file mode 100644 index 00000000..2d732a28 --- /dev/null +++ b/contracts/multisig-admin-rotation/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "multisig-admin-rotation" +version = "0.1.0" +edition = "2021" + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/multisig-admin-rotation/src/lib.rs b/contracts/multisig-admin-rotation/src/lib.rs new file mode 100644 index 00000000..f115ff3c --- /dev/null +++ b/contracts/multisig-admin-rotation/src/lib.rs @@ -0,0 +1,112 @@ +#![no_std] + +soroban-sdk::contractimport!(); + +use soroban-sdk::{contract, contractimpl, contracttype, Address, Env, Vec, Bytes}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + AdminKeys, + PendingRotation, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingAdminRotation { + pub new_admin_keys: Vec
, + pub effective_at: u64, +} + +#[contract] +pub struct MultisigAdminRotationContract; + +#[contractimpl] +impl MultisigAdminRotationContract { + pub fn initialize(env: Env, admin_keys: Vec
) { + if env.storage().persistent().has(&DataKey::AdminKeys) { + panic!("already initialized"); + } + if admin_keys.is_empty() { + panic!("admin keys cannot be empty"); + } + env.storage().persistent().set(&DataKey::AdminKeys, &admin_keys); + } + + pub fn propose_rotation(env: Env, approvers: Vec
, new_admin_keys: Vec
) { + let current_keys: Vec
= env + .storage() + .persistent() + .get(&DataKey::AdminKeys) + .expect("not initialized"); + + if new_admin_keys.is_empty() { + panic!("new admin keys cannot be empty"); + } + + // Require 100% threshold signature approval of the current master key set + if approvers.len() != current_keys.len() { + panic!("100% threshold approval required: approvers count mismatch"); + } + + for key in current_keys.iter() { + key.require_auth(); + let mut found = false; + for approver in approvers.iter() { + if approver == key { + found = true; + break; + } + } + if !found { + panic!("100% threshold approval required: missing master key approval"); + } + } + + let current_timestamp = env.ledger().timestamp(); + let effective_at = current_timestamp + 86400; // 24-hour timelock delay + + let pending = PendingAdminRotation { + new_admin_keys, + effective_at, + }; + + env.storage().persistent().set(&DataKey::PendingRotation, &pending); + } + + pub fn apply_rotation(env: Env) { + let pending: PendingAdminRotation = env + .storage() + .persistent() + .get(&DataKey::PendingRotation) + .expect("no pending rotation"); + + let current_timestamp = env.ledger().timestamp(); + if current_timestamp < pending.effective_at { + panic!("24-hour timelock delay has not expired"); + } + + env.storage().persistent().set(&DataKey::AdminKeys, &pending.new_admin_keys); + env.storage().persistent().remove(&DataKey::PendingRotation); + + // Emit AdminKeysRotated system audit event + env.events().publish( + (Bytes::from_slice(&env, b"AdminKeysRotated"),), + pending.new_admin_keys, + ); + } + + pub fn get_admin_keys(env: Env) -> Vec
{ + env.storage() + .persistent() + .get(&DataKey::AdminKeys) + .expect("not initialized"); + env.storage().persistent().get(&DataKey::AdminKeys).unwrap() + } + + pub fn get_pending_rotation(env: Env) -> Option { + env.storage().persistent().get(&DataKey::PendingRotation) + } +} + +#[cfg(test)]mod test; diff --git a/contracts/multisig-admin-rotation/src/test.rs b/contracts/multisig-admin-rotation/src/test.rs new file mode 100644 index 00000000..48f1e631 --- /dev/null +++ b/contracts/multisig-admin-rotation/src/test.rs @@ -0,0 +1,52 @@ +use super::*; +use soroban-sdk::{Env, Vec}; +use soroban-sdk::testutils::{Address as _, Ledger}; + +#[test] +fn test_multisig_admin_rotation_flow() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, MultisigAdminRotationContract); + let client = MultisigAdminRotationContractClient::new(&env, &contract_id); + + let key1 = Address::generate(&env); + let key2 = Address::generate(&env); + let mut initial_keys = Vec::new(&env); + initial_keys.push_back(key1.clone()); + initial_keys.push_back(key2.clone()); + + client.initialize(&initial_keys); + assert_eq!(client.get_admin_keys(), initial_keys); + + let new_key1 = Address::generate(&env); + let new_key2 = Address::generate(&env); + let mut new_keys = Vec::new(&env); + new_keys.push_back(new_key1.clone()); + new_keys.push_back(new_key2.clone()); + + let mut approvers = Vec::new(&env); + approvers.push_back(key1.clone()); + approvers.push_back(key2.clone()); + + // Propose rotation + client.propose_rotation(&approvers, &new_keys); + + let pending = client.get_pending_rotation().unwrap(); + assert_eq!(pending.new_admin_keys, new_keys); + + // Attempt applying before 24 hours should fail + let result = std::panic::catch_unwind(|| { + client.apply_rotation(); + }); + assert!(result.is_err()); + + // Advance ledger timestamp by 24 hours (86400 seconds) + env.ledger().set_timestamp(env.ledger().timestamp() + 86400); + + // Apply rotation successfully + client.apply_rotation(); + + assert_eq!(client.get_admin_keys(), new_keys); + assert!(client.get_pending_rotation().is_none()); +}