From 44e1a4e9b1a3103cc7570b2fbfa9b8bc5951df53 Mon Sep 17 00:00:00 2001 From: Liam Phung Date: Fri, 16 Oct 2026 14:26:57 +0700 Subject: [PATCH] feat(registry): add two-step set_admin, accept_admin, and cancel_admin_transfer (#1207) --- contracts/registry/src/errors.rs | 2 + contracts/registry/src/events.rs | 15 ++++++ contracts/registry/src/lib.rs | 69 +++++++++++++++++++++++++++ contracts/registry/tests/auth_snap.rs | 60 ++++++++++++++++++++++- 4 files changed, 145 insertions(+), 1 deletion(-) diff --git a/contracts/registry/src/errors.rs b/contracts/registry/src/errors.rs index 72e12ef4..3df723c3 100644 --- a/contracts/registry/src/errors.rs +++ b/contracts/registry/src/errors.rs @@ -26,4 +26,6 @@ pub enum RegistryError { /// Metadata is empty, exceeds the byte bound, or contains invalid /// (non-visible-ASCII / control / whitespace-delimited) encodings (code 10). InvalidMetadata = 10, + /// No pending admin nomination exists to accept or cancel (code 11). + NoPendingAdmin = 11, } diff --git a/contracts/registry/src/events.rs b/contracts/registry/src/events.rs index 818e761f..070dc196 100644 --- a/contracts/registry/src/events.rs +++ b/contracts/registry/src/events.rs @@ -28,6 +28,21 @@ pub fn event_version_v1(env: &Env) -> Symbol { Symbol::new(env, "callora.v1") } +/// Returns the Symbol for the `"admin_nominated"` event topic. +pub fn event_admin_nominated(env: &Env) -> Symbol { + Symbol::new(env, "admin_nominated") +} + +/// Returns the Symbol for the `"admin_accepted"` event topic. +pub fn event_admin_accepted(env: &Env) -> Symbol { + Symbol::new(env, "admin_accepted") +} + +/// Returns the Symbol for the `"admin_cancelled"` event topic. +pub fn event_admin_cancelled(env: &Env) -> Symbol { + Symbol::new(env, "admin_cancelled") +} + #[cfg(test)] mod tests { use super::*; diff --git a/contracts/registry/src/lib.rs b/contracts/registry/src/lib.rs index 412919df..b32e4652 100644 --- a/contracts/registry/src/lib.rs +++ b/contracts/registry/src/lib.rs @@ -42,6 +42,7 @@ pub const INSTANCE_TTL_EXTENSION: u32 = INSTANCE_TTL_THRESHOLD; #[derive(Clone, Debug, PartialEq)] pub enum StorageKey { Admin, + PendingAdmin, Catalog, RegisteredCount, Offering(String), @@ -296,4 +297,72 @@ impl CalloraRegistry { Self::extend_offering_ttl(&env, &key); Ok(record) } + + /// Return the current admin address. + pub fn get_admin(env: Env) -> Result { + Self::extend_instance_ttl(&env); + Self::admin(&env) + } + + /// Return the pending nominated admin address, if any. + pub fn get_pending_admin(env: Env) -> Option
{ + Self::extend_instance_ttl(&env); + env.storage().instance().get(&StorageKey::PendingAdmin) + } + + /// Nominate a new admin for two-step admin rotation. + pub fn set_admin(env: Env, caller: Address, new_admin: Address) -> Result<(), RegistryError> { + Self::extend_instance_ttl(&env); + caller.require_auth(); + let admin = Self::admin(&env)?; + if caller != admin { + return Err(RegistryError::Unauthorized); + } + env.storage() + .instance() + .set(&StorageKey::PendingAdmin, &new_admin); + env.events() + .publish((events::event_admin_nominated(&env), caller), new_admin); + Ok(()) + } + + /// Accept a pending admin nomination. Must be authorized by the nominee. + pub fn accept_admin(env: Env, caller: Address) -> Result<(), RegistryError> { + Self::extend_instance_ttl(&env); + caller.require_auth(); + let old_admin = Self::admin(&env)?; + let pending: Address = env + .storage() + .instance() + .get(&StorageKey::PendingAdmin) + .ok_or(RegistryError::NoPendingAdmin)?; + if caller != pending { + return Err(RegistryError::Unauthorized); + } + let inst = env.storage().instance(); + inst.set(&StorageKey::Admin, &pending); + inst.remove(&StorageKey::PendingAdmin); + env.events() + .publish((events::event_admin_accepted(&env), old_admin), pending); + Ok(()) + } + + /// Cancel an active admin nomination. Must be authorized by the current admin. + pub fn cancel_admin_transfer(env: Env, caller: Address) -> Result<(), RegistryError> { + Self::extend_instance_ttl(&env); + caller.require_auth(); + let admin = Self::admin(&env)?; + if caller != admin { + return Err(RegistryError::Unauthorized); + } + let cancelled: Address = env + .storage() + .instance() + .get(&StorageKey::PendingAdmin) + .ok_or(RegistryError::NoPendingAdmin)?; + env.storage().instance().remove(&StorageKey::PendingAdmin); + env.events() + .publish((events::event_admin_cancelled(&env), caller), cancelled); + Ok(()) + } } diff --git a/contracts/registry/tests/auth_snap.rs b/contracts/registry/tests/auth_snap.rs index d0a3884e..1d5a64e6 100644 --- a/contracts/registry/tests/auth_snap.rs +++ b/contracts/registry/tests/auth_snap.rs @@ -2,7 +2,7 @@ extern crate std; -use callora_registry::{CalloraRegistry, CalloraRegistryClient}; +use callora_registry::{CalloraRegistry, CalloraRegistryClient, RegistryError}; use soroban_sdk::testutils::Address as _; use soroban_sdk::{Address, Env, String}; @@ -87,3 +87,61 @@ fn registered_count_does_not_require_auth() { let count = client.registered_count(); assert_eq!(count, 0); } + +#[test] +fn set_accept_and_cancel_admin_require_auth() { + let env = Env::default(); + let (admin, _catalog, client) = setup(&env); + let nominee = Address::generate(&env); + + env.set_auths(&[]); + assert!(client.try_set_admin(&admin, &nominee).is_err()); + + env.mock_all_auths(); + client.set_admin(&admin, &nominee); + + env.set_auths(&[]); + assert!(client.try_accept_admin(&nominee).is_err()); + assert!(client.try_cancel_admin_transfer(&admin).is_err()); +} + +#[test] +fn nominate_accept_and_cancel_admin_transfer_lifecycle() { + let env = Env::default(); + let (admin, _catalog, client) = setup(&env); + let nominee = Address::generate(&env); + let outsider = Address::generate(&env); + + client.set_admin(&admin, &nominee); + assert_eq!(client.get_pending_admin(), Some(nominee.clone())); + + // Outsider cannot accept or cancel + assert_eq!( + client.try_accept_admin(&outsider), + Err(Ok(RegistryError::Unauthorized)) + ); + assert_eq!( + client.try_cancel_admin_transfer(&outsider), + Err(Ok(RegistryError::Unauthorized)) + ); + + // Current admin can cancel + client.cancel_admin_transfer(&admin); + assert_eq!(client.get_pending_admin(), None); + assert_eq!( + client.try_accept_admin(&nominee), + Err(Ok(RegistryError::NoPendingAdmin)) + ); + + // Re-nominate and accept as nominee + client.set_admin(&admin, &nominee); + client.accept_admin(&nominee); + assert_eq!(client.get_admin(), nominee); + assert_eq!(client.get_pending_admin(), None); + + // Old admin is no longer authorized + assert_eq!( + client.try_set_admin(&admin, &outsider), + Err(Ok(RegistryError::Unauthorized)) + ); +}