Skip to content
Closed
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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions contracts/multisig-admin-rotation/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
112 changes: 112 additions & 0 deletions contracts/multisig-admin-rotation/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Address>,
pub effective_at: u64,
}

#[contract]
pub struct MultisigAdminRotationContract;

#[contractimpl]
impl MultisigAdminRotationContract {
pub fn initialize(env: Env, admin_keys: Vec<Address>) {
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<Address>, new_admin_keys: Vec<Address>) {
let current_keys: Vec<Address> = 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<Address> {
env.storage()
.persistent()
.get(&DataKey::AdminKeys)
.expect("not initialized");
env.storage().persistent().get(&DataKey::AdminKeys).unwrap()
}

pub fn get_pending_rotation(env: Env) -> Option<PendingAdminRotation> {
env.storage().persistent().get(&DataKey::PendingRotation)
}
}

#[cfg(test)]mod test;
52 changes: 52 additions & 0 deletions contracts/multisig-admin-rotation/src/test.rs
Original file line number Diff line number Diff line change
@@ -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());
}
Loading