Skip to content
Open
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
5 changes: 5 additions & 0 deletions contracts/payment-distributor/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>, new_recipient: &Address) {
let topics = (Symbol::new(env, "fee_recipient_updated"),);
Expand Down
29 changes: 29 additions & 0 deletions contracts/payment-distributor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions contracts/payment-distributor/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ pub fn get_admin(env: &Env) -> Option<Address> {
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<Address> {
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()
Expand Down
73 changes: 72 additions & 1 deletion contracts/payment-distributor/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
1 change: 1 addition & 0 deletions contracts/payment-distributor/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down