diff --git a/contracts/payment-distributor/src/events.rs b/contracts/payment-distributor/src/events.rs index 0ffe2c5..e63debd 100644 --- a/contracts/payment-distributor/src/events.rs +++ b/contracts/payment-distributor/src/events.rs @@ -9,6 +9,11 @@ pub fn initialized(env: &Env, admin: &Address) { env.events().publish(topics, admin.clone()); } +pub fn admin_transferred(env: &Env, previous_admin: &Address, new_admin: &Address) { + let topics = (Symbol::new(env, "admin_transferred"),); + env.events().publish(topics, (previous_admin.clone(), new_admin.clone())); +} + /// Issue #122: Fee recipient updated event pub fn fee_recipient_updated(env: &Env, old_recipient: Option
, new_recipient: &Address) { let topics = (Symbol::new(env, "fee_recipient_updated"),); diff --git a/contracts/payment-distributor/src/lib.rs b/contracts/payment-distributor/src/lib.rs index 53982de..a8c5935 100644 --- a/contracts/payment-distributor/src/lib.rs +++ b/contracts/payment-distributor/src/lib.rs @@ -627,6 +627,35 @@ impl PaymentDistributor { storage::get_admin(&env).ok_or(Error::NotInit) } + /// Issue #380: Transfer admin ownership (Step 1). + /// Proposes a new admin. The new admin must call `accept_admin` to finalize. + pub fn transfer_admin(env: Env, current_admin: Address, new_admin: Address) -> Result<(), Error> { + let stored_admin = storage::get_admin(&env).ok_or(Error::NotInit)?; + if current_admin != stored_admin { + return Err(Error::Unauthorized); + } + current_admin.require_auth(); + storage::set_pending_admin(&env, &new_admin); + Ok(()) + } + + /// Issue #380: Accept admin ownership (Step 2). + /// Finalizes the transfer of admin ownership. + pub fn accept_admin(env: Env, new_admin: Address) -> Result<(), Error> { + let pending = storage::get_pending_admin(&env).ok_or(Error::Unauthorized)?; + if new_admin != pending { + return Err(Error::Unauthorized); + } + new_admin.require_auth(); + + let previous_admin = storage::get_admin(&env).ok_or(Error::NotInit)?; + storage::set_admin(&env, &new_admin); + storage::clear_pending_admin(&env); + + events::admin_transferred(&env, &previous_admin, &new_admin); + Ok(()) + } + /// Issue #122: Set the fee recipient address for platform fees. /// Only the admin can update the fee recipient. /// Emits a fee_recipient_updated event for audit trails. diff --git a/contracts/payment-distributor/src/storage.rs b/contracts/payment-distributor/src/storage.rs index a3875a0..8e9088b 100644 --- a/contracts/payment-distributor/src/storage.rs +++ b/contracts/payment-distributor/src/storage.rs @@ -28,6 +28,18 @@ pub fn get_admin(env: &Env) -> Option { env.storage().instance().get(&StorageKey::Admin) } +pub fn set_pending_admin(env: &Env, admin: &Address) { + env.storage().instance().set(&StorageKey::PendingAdmin, admin); +} + +pub fn get_pending_admin(env: &Env) -> Option { + env.storage().instance().get(&StorageKey::PendingAdmin) +} + +pub fn clear_pending_admin(env: &Env) { + env.storage().instance().remove(&StorageKey::PendingAdmin); +} + pub fn set_fee_recipient(env: &Env, fee_recipient: &Address) { env.storage() .instance() diff --git a/contracts/payment-distributor/src/test.rs b/contracts/payment-distributor/src/test.rs index 711a570..ce70d73 100644 --- a/contracts/payment-distributor/src/test.rs +++ b/contracts/payment-distributor/src/test.rs @@ -4923,8 +4923,79 @@ fn acceptance_criteria_no_unexpected_dust_after_completed_distribution() { let distributor = ctx.payment_token.balance(&ctx.distributor_id); let escrow = ctx.payment_token.balance(&ctx.escrow_id); - // No dust in distributor +// No dust in distributor assert_eq!(distributor, 0); // All escrowed funds distributed or reserved assert_eq!(escrow, 0); } + +// ────────────────────────────────────────────────────────────────────────────── +// ADMIN TRANSFER TWO-STEP PROCESS TESTS +// ────────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_successful_two_step_admin_transfer() { + let env = Env::default(); + env.mock_all_auths(); + + let ctx = setup(&env, 0, false); + let new_admin = Address::generate(&env); + + // Step 1: transfer_admin + ctx.distributor.transfer_admin(&ctx.admin, &new_admin); + + // Step 2: accept_admin + ctx.distributor.accept_admin(&new_admin); + + assert_eq!(ctx.distributor.get_admin(), new_admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_unauthorized_caller_on_transfer_admin() { + let env = Env::default(); + env.mock_all_auths(); + + let ctx = setup(&env, 0, false); + let unauthorized = Address::generate(&env); + let new_admin = Address::generate(&env); + + // Fails with Unauthorized + ctx.distributor.transfer_admin(&unauthorized, &new_admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_unauthorized_caller_on_accept_admin() { + let env = Env::default(); + env.mock_all_auths(); + + let ctx = setup(&env, 0, false); + let new_admin = Address::generate(&env); + let unauthorized = Address::generate(&env); + + ctx.distributor.transfer_admin(&ctx.admin, &new_admin); + + // Fails with Unauthorized (wrong caller) + ctx.distributor.accept_admin(&unauthorized); +} + +#[test] +fn test_chained_transfers_and_event_log_emission() { + let env = Env::default(); + env.mock_all_auths(); + + let ctx = setup(&env, 0, false); + let admin_1 = Address::generate(&env); + let admin_2 = Address::generate(&env); + + // Transfer 1 + ctx.distributor.transfer_admin(&ctx.admin, &admin_1); + ctx.distributor.accept_admin(&admin_1); + assert_eq!(ctx.distributor.get_admin(), admin_1); + + // Transfer 2 + ctx.distributor.transfer_admin(&admin_1, &admin_2); + ctx.distributor.accept_admin(&admin_2); + assert_eq!(ctx.distributor.get_admin(), admin_2); +} diff --git a/contracts/payment-distributor/src/types.rs b/contracts/payment-distributor/src/types.rs index efa4e16..ff43a3f 100644 --- a/contracts/payment-distributor/src/types.rs +++ b/contracts/payment-distributor/src/types.rs @@ -6,6 +6,7 @@ use crate::errors::Error; #[derive(Clone, Debug, Eq, PartialEq)] pub enum StorageKey { Admin, + PendingAdmin, Distribution(soroban_sdk::Address, soroban_sdk::Symbol), /// Ordered platform fee tiers. FeeTiers,