Skip to content
Merged
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
18 changes: 18 additions & 0 deletions contracts/campaign-escrow/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 39 additions & 0 deletions contracts/campaign-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &current_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.
Expand Down
15 changes: 15 additions & 0 deletions contracts/campaign-escrow/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const INSTANCE_LIFETIME_THRESHOLD: u32 = 500_000;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DataKey {
Admin,
PendingAdmin,
Treasury,
FeeBps,
DisputeContract,
Expand Down Expand Up @@ -64,6 +65,20 @@ pub fn get_admin(env: &Env) -> Result<Address, Error> {
.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<Address> {
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);
}
Expand Down
91 changes: 91 additions & 0 deletions contracts/campaign-escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&not_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;
Expand Down