diff --git a/contracts/campaign-escrow/src/events.rs b/contracts/campaign-escrow/src/events.rs index 2b2b4cd..1eea7a7 100644 --- a/contracts/campaign-escrow/src/events.rs +++ b/contracts/campaign-escrow/src/events.rs @@ -106,6 +106,24 @@ pub struct ContractUnpaused { pub admin: Address, } +#[contractevent] +#[derive(Clone, Debug)] +pub struct AdminProposed { + #[topic] + pub current_admin: Address, + #[topic] + pub new_admin: Address, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct AdminTransferred { + #[topic] + pub previous_admin: Address, + #[topic] + pub new_admin: Address, +} + #[contractevent] #[derive(Clone, Debug)] pub struct ContractUpgraded { diff --git a/contracts/campaign-escrow/src/lib.rs b/contracts/campaign-escrow/src/lib.rs index dc9177f..fe9e375 100644 --- a/contracts/campaign-escrow/src/lib.rs +++ b/contracts/campaign-escrow/src/lib.rs @@ -112,6 +112,45 @@ impl CampaignEscrowContract { storage::get_paused(&env) } + /// Propose a new admin address. The transfer is not finalized until the + /// proposed address calls `accept_admin`, proving control of that key. + pub fn propose_admin( + env: Env, + current_admin: Address, + new_admin: Address, + ) -> Result<(), Error> { + require_admin(&env, ¤t_admin)?; + storage::set_pending_admin(&env, &new_admin); + storage::extend_instance_ttl(&env); + events::AdminProposed { + current_admin, + new_admin, + } + .publish(&env); + Ok(()) + } + + /// Accept a pending admin transfer. Only the exact proposed address may + /// finalize the handover. + pub fn accept_admin(env: Env, new_admin: Address) -> Result<(), Error> { + new_admin.require_auth(); + let pending_admin = storage::get_pending_admin(&env).ok_or(Error::Unauthorized)?; + if pending_admin != new_admin { + return Err(Error::Unauthorized); + } + + let previous_admin = storage::get_admin(&env)?; + storage::set_admin(&env, &new_admin); + storage::clear_pending_admin(&env); + storage::extend_instance_ttl(&env); + events::AdminTransferred { + previous_admin, + new_admin, + } + .publish(&env); + Ok(()) + } + /// Update the platform fee for future `claim_payment` calls. /// The fee is read at claim time, so a fee change affects pending campaigns. /// Callable only by the admin. diff --git a/contracts/campaign-escrow/src/storage.rs b/contracts/campaign-escrow/src/storage.rs index 7efdf95..d2625e1 100644 --- a/contracts/campaign-escrow/src/storage.rs +++ b/contracts/campaign-escrow/src/storage.rs @@ -24,6 +24,7 @@ const INSTANCE_LIFETIME_THRESHOLD: u32 = 500_000; #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { Admin, + PendingAdmin, Treasury, FeeBps, DisputeContract, @@ -64,6 +65,20 @@ pub fn get_admin(env: &Env) -> Result { .ok_or(Error::NotInitialized) } +pub fn set_pending_admin(env: &Env, pending_admin: &Address) { + env.storage() + .instance() + .set(&DataKey::PendingAdmin, pending_admin); +} + +pub fn get_pending_admin(env: &Env) -> Option
{ + env.storage().instance().get(&DataKey::PendingAdmin) +} + +pub fn clear_pending_admin(env: &Env) { + env.storage().instance().remove(&DataKey::PendingAdmin); +} + pub fn set_treasury(env: &Env, treasury: &Address) { env.storage().instance().set(&DataKey::Treasury, treasury); } diff --git a/contracts/campaign-escrow/src/test.rs b/contracts/campaign-escrow/src/test.rs index a510498..b762774 100644 --- a/contracts/campaign-escrow/src/test.rs +++ b/contracts/campaign-escrow/src/test.rs @@ -519,6 +519,97 @@ mod test_protocol_config { } } +mod test_admin_transfer { + use super::test_helpers::setup_env; + use crate::{storage, CampaignEscrowContractClient, Error}; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::Address; + + #[test] + fn propose_admin_rejects_non_admin() { + let (env, contract_id) = setup_env(); + let client = CampaignEscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let dispute_contract = Address::generate(&env); + let not_admin = Address::generate(&env); + let new_admin = Address::generate(&env); + client.initialize(&admin, &dispute_contract, &250); + + let result = client.try_propose_admin(¬_admin, &new_admin); + + assert_eq!(result, Err(Ok(Error::Unauthorized))); + } + + #[test] + fn propose_admin_stores_pending_candidate() { + let (env, contract_id) = setup_env(); + let client = CampaignEscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let dispute_contract = Address::generate(&env); + let new_admin = Address::generate(&env); + client.initialize(&admin, &dispute_contract, &250); + + client.propose_admin(&admin, &new_admin); + + let pending_admin = env.as_contract(&client.address, || storage::get_pending_admin(&env)); + assert_eq!(pending_admin, Some(new_admin)); + } + + #[test] + fn full_two_step_transfer_replaces_admin() { + let (env, contract_id) = setup_env(); + let client = CampaignEscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let dispute_contract = Address::generate(&env); + let new_admin = Address::generate(&env); + client.initialize(&admin, &dispute_contract, &250); + + client.propose_admin(&admin, &new_admin); + client.accept_admin(&new_admin); + + let pending_admin = env.as_contract(&client.address, || storage::get_pending_admin(&env)); + assert_eq!(pending_admin, None); + let stored_admin = env.as_contract(&client.address, || storage::get_admin(&env)); + assert_eq!(stored_admin, Ok(new_admin.clone())); + + let old_admin_result = client.try_update_fee_bps(&admin, &100); + assert_eq!(old_admin_result, Err(Ok(Error::Unauthorized))); + + client.update_fee_bps(&new_admin, &100); + assert_eq!(client.get_protocol_config().fee_bps, 100); + } + + #[test] + fn accept_admin_rejects_wrong_address() { + let (env, contract_id) = setup_env(); + let client = CampaignEscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let dispute_contract = Address::generate(&env); + let new_admin = Address::generate(&env); + let wrong_admin = Address::generate(&env); + client.initialize(&admin, &dispute_contract, &250); + + client.propose_admin(&admin, &new_admin); + let result = client.try_accept_admin(&wrong_admin); + + assert_eq!(result, Err(Ok(Error::Unauthorized))); + } + + #[test] + fn accept_admin_before_propose_is_unauthorized() { + let (env, contract_id) = setup_env(); + let client = CampaignEscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let dispute_contract = Address::generate(&env); + let new_admin = Address::generate(&env); + client.initialize(&admin, &dispute_contract, &250); + + let result = client.try_accept_admin(&new_admin); + + assert_eq!(result, Err(Ok(Error::Unauthorized))); + } +} + mod test_auth_failures { use super::test_helpers::*; use crate::Error;