From fa4ed4aa9fa53a2c6c739b810af3bda0dd461351 Mon Sep 17 00:00:00 2001 From: BC Forge Bot Date: Sat, 30 May 2026 22:25:14 +0100 Subject: [PATCH] feat: implement two-step ownership transfer with pending acceptance and expiration --- contracts/admin/src/lib.rs | 24 +++++++++++++++ contracts/token/src/lib.rs | 61 ++++++++++++++++++++++++++++---------- 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/contracts/admin/src/lib.rs b/contracts/admin/src/lib.rs index e76d173..499df33 100644 --- a/contracts/admin/src/lib.rs +++ b/contracts/admin/src/lib.rs @@ -8,6 +8,7 @@ use soroban_sdk::{contracttype, vec, Address, Env, String, Vec}; #[contracttype] pub enum AdminKey { Admin, + PendingAdmin, Role(Role, Address), /// The pool of administrator addresses for multi-sig. AdminPool, @@ -19,6 +20,13 @@ pub enum AdminKey { ProposalIdCounter, } +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub struct PendingAdminInfo { + pub address: Address, + pub expires_at: u64, +} + /// Enumeration of available roles. #[derive(Clone, Copy, PartialEq, Eq, Debug)] #[contracttype] @@ -45,6 +53,22 @@ pub fn set_admin(env: &Env, admin: &Address) { .set(&AdminKey::Role(Role::Admin, admin.clone()), &true); } +pub fn set_pending_admin(env: &Env, pending: &Address, expires_at: u64) { + let info = PendingAdminInfo { + address: pending.clone(), + expires_at, + }; + env.storage().instance().set(&AdminKey::PendingAdmin, &info); +} + +pub fn read_pending_admin(env: &Env) -> Option { + env.storage().instance().get(&AdminKey::PendingAdmin) +} + +pub fn remove_pending_admin(env: &Env) { + env.storage().instance().remove(&AdminKey::PendingAdmin); +} + pub fn get_admin(env: &Env) -> Address { env.storage() .instance() diff --git a/contracts/token/src/lib.rs b/contracts/token/src/lib.rs index 5faad34..01b2d03 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -45,6 +45,15 @@ pub struct LockupInfo { pub unlock_time: u64, } +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub struct PendingAdminInfo { + pub address: Address, + pub expires_at: u64, +} + +const PENDING_OWNER_TIMEOUT_SECS: u64 = 86400; // 24 hours + /// Information about an allowance, including amount and expiration. #[derive(Clone, Debug, PartialEq)] #[contracttype] @@ -233,9 +242,22 @@ impl BcForgeToken { Ok(()) } - fn read_pending_admin(env: &Env) -> Option
{ + fn read_pending_admin(env: &Env) -> Option { + // If there's a pending admin, return it; caller should check expiry. env.storage().instance().get(&DataKey::PendingAdmin) } + + fn read_active_pending_admin(env: &Env) -> Option { + if let Some(pending): Option = env.storage().instance().get(&DataKey::PendingAdmin) { + let now = env.ledger().timestamp(); + if pending.expires_at == 0 || now <= pending.expires_at { + return Some(pending); + } + // expired: clean up + env.storage().instance().remove(&DataKey::PendingAdmin); + } + None + } } #[contractimpl] @@ -473,43 +495,50 @@ impl BcForgeToken { } pub fn transfer_ownership(env: Env, new_admin: Address) -> Result<(), TokenError> { - let current_admin = Self::read_admin(&env)?; - current_admin.require_auth(); - Self::set_admin(&env, &new_admin); - events::emit_ownership_transferred(&env, ¤t_admin, &new_admin); - Ok(()) + // For safety, `transfer_ownership` now initiates a two-step transfer (propose). + // Keep signature for compatibility but set a pending admin with expiration. + Self::propose_owner(env, new_admin) } pub fn propose_owner(env: Env, new_admin: Address) -> Result<(), TokenError> { let current_admin = Self::read_admin(&env)?; current_admin.require_auth(); - env.storage() - .instance() - .set(&DataKey::PendingAdmin, &new_admin); + let now = env.ledger().timestamp(); + let pending = PendingAdminInfo { + address: new_admin.clone(), + expires_at: now + PENDING_OWNER_TIMEOUT_SECS, + }; + env.storage().instance().set(&DataKey::PendingAdmin, &pending); events::emit_ownership_proposed(&env, ¤t_admin, &new_admin); Ok(()) } pub fn accept_ownership(env: Env) { - let pending_admin = Self::read_pending_admin(&env).expect("no pending ownership transfer"); - pending_admin.require_auth(); + let pending = Self::read_active_pending_admin(&env).expect("no pending ownership transfer"); + // ensure the pending admin authorized the call + pending.address.require_auth(); let old_admin = Self::read_admin(&env).expect("contract not initialized"); - Self::set_admin(&env, &pending_admin); + Self::set_admin(&env, &pending.address); env.storage().instance().remove(&DataKey::PendingAdmin); - events::emit_ownership_accepted(&env, &old_admin, &pending_admin); + events::emit_ownership_accepted(&env, &old_admin, &pending.address); } pub fn cancel_transfer(env: Env) -> Result<(), TokenError> { + // keep old name for compatibility + Self::cancel_ownership_transfer(env) + } + + pub fn cancel_ownership_transfer(env: Env) -> Result<(), TokenError> { let current_admin = Self::read_admin(&env)?; current_admin.require_auth(); - let pending_admin = Self::read_pending_admin(&env).expect("no pending ownership transfer"); + let pending = Self::read_pending_admin(&env).expect("no pending ownership transfer"); env.storage().instance().remove(&DataKey::PendingAdmin); - events::emit_ownership_cancelled(&env, ¤t_admin, &pending_admin); + events::emit_ownership_cancelled(&env, ¤t_admin, &pending.address); Ok(()) } pub fn pending_owner(env: Env) -> Option
{ - Self::read_pending_admin(&env) + Self::read_active_pending_admin(&env).map(|p| p.address) } pub fn pause(env: Env) -> Result<(), TokenError> {