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
24 changes: 24 additions & 0 deletions contracts/token/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ pub fn emit_ownership_transferred(env: &Env, old_admin: &Address, new_admin: &Ad
);
}

/// Emitted when a new admin is proposed (two-step transfer).
pub fn emit_ownership_proposed(env: &Env, old_admin: &Address, pending_admin: &Address) {
env.events().publish(
(symbol_short!("own_prop"),),
(old_admin.clone(), pending_admin.clone()),
);
}

/// Emitted when pending admin accepts ownership.
pub fn emit_ownership_accepted(env: &Env, old_admin: &Address, new_admin: &Address) {
env.events().publish(
(symbol_short!("own_accept"),),
(old_admin.clone(), new_admin.clone()),
);
}

/// Emitted when ownership transfer is cancelled.
pub fn emit_ownership_cancelled(env: &Env, admin: &Address, cancelled_admin: &Address) {
env.events().publish(
(symbol_short!("own_cancel"),),
(admin.clone(), cancelled_admin.clone()),
);
}

/// Emitted when the contract is paused.
pub fn emit_paused(env: &Env, admin: &Address) {
env.events()
Expand Down
69 changes: 69 additions & 0 deletions contracts/token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ pub enum DataKey {
// Admin is now handled by bc_forge_admin
/// The contract admin address (legacy/internal).
Admin,
/// Pending admin for two-step ownership transfer.
PendingAdmin,
/// Spending allowance: (owner, spender) → amount.
Allowance(Address, Address),
/// Allowance expiration: (owner, spender) → ledger sequence.
Expand Down Expand Up @@ -241,6 +243,11 @@ impl BcForgeToken {

events::emit_mint(env, &bc_forge_admin::get_admin(env), &to, amount, balance, supply);
}

/// Reads the pending admin address (if any).
fn read_pending_admin(env: &Env) -> Option<Address> {
env.storage().instance().get(&DataKey::PendingAdmin)
}
}

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -402,6 +409,13 @@ impl BcForgeToken {
events::emit_clawback(&env, &claw_admin, &from, &to, amount);
}

/// Transfers the admin role to a new address. Current admin-only.
///
/// ⚠️ DEPRECATED: Use propose_owner() + accept_ownership() for safer two-step transfer.
/// This function is kept for backward compatibility but may be removed in future versions.
///
/// # Arguments
/// * `new_admin` - The address to receive admin privileges.
/// Locks tokens for a user until a specific ledger timestamp.
pub fn lock_tokens(env: Env, user: Address, amount: i128, unlock_time: u64) {
Self::read_admin(&env).require_auth();
Expand Down Expand Up @@ -456,6 +470,61 @@ impl BcForgeToken {
Ok(())
}

/// Proposes a new admin for two-step ownership transfer. Current admin-only.
///
/// # Arguments
/// * `new_admin` - The address to propose as the new admin.
///
/// # Panics
/// Panics if caller is not the current admin.
pub fn propose_owner(env: Env, new_admin: Address) {
let admin = Self::read_admin(&env);
admin.require_auth();

env.storage().instance().set(&DataKey::PendingAdmin, &new_admin);
events::emit_ownership_proposed(&env, &admin, &new_admin);
}

/// Accepts pending ownership transfer. Only the pending admin can call this.
///
/// # Panics
/// Panics if there is no pending admin or if caller is not the pending admin.
pub fn accept_ownership(env: Env) {
let pending_admin = Self::read_pending_admin(&env)
.expect("no pending ownership transfer");

pending_admin.require_auth();

let old_admin = Self::read_admin(&env);
env.storage().instance().set(&DataKey::Admin, &pending_admin);
env.storage().instance().remove(&DataKey::PendingAdmin);

events::emit_ownership_accepted(&env, &old_admin, &pending_admin);
}

/// Cancels a pending ownership transfer. Current admin-only.
///
/// # Panics
/// Panics if caller is not the current admin or if there is no pending transfer.
pub fn cancel_transfer(env: Env) {
let admin = Self::read_admin(&env);
admin.require_auth();

let pending_admin = Self::read_pending_admin(&env)
.expect("no pending ownership transfer");

env.storage().instance().remove(&DataKey::PendingAdmin);
events::emit_ownership_cancelled(&env, &admin, &pending_admin);
}

/// Returns the pending admin address if there is a pending transfer.
///
/// # Returns
/// Some(Address) if there is a pending admin, None otherwise.
pub fn pending_owner(env: Env) -> Option<Address> {
Self::read_pending_admin(&env)
}

/// Returns the total token supply.
pub fn supply(env: Env) -> i128 {
Self::panic_on_err(&env, Self::ensure_initialized(&env));
Expand Down
151 changes: 151 additions & 0 deletions contracts/token/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,76 @@ fn test_transfer_from_with_expired_allowance_fails() {
client.transfer_from(&spender, &owner, &receiver, &200);
}

#[test]
fn test_allowance_with_future_expiration() {
let env = Env::default();
env.mock_all_auths();
let (client, _) = setup_contract(&env);
let _admin = init_default(&env, &client);
let owner = Address::generate(&env);
let spender = Address::generate(&env);
let receiver = Address::generate(&env);

client.mint(&owner, &1000);

// Set expiration to ledger 1000 (future)
let current_ledger = env.ledger().sequence();
env.ledger().set(current_ledger + 100);

client.approve(&owner, &spender, &500, &1000);

// Should be usable
assert_eq!(client.allowance(&owner, &spender), 500);

client.transfer_from(&spender, &owner, &receiver, &200);
assert_eq!(client.balance(&receiver), 200);
assert_eq!(client.allowance(&owner, &spender), 300);
}

#[test]
fn test_allowance_with_past_expiration_returns_zero() {
let env = Env::default();
env.mock_all_auths();
let (client, _) = setup_contract(&env);
let _admin = init_default(&env, &client);
let owner = Address::generate(&env);
let spender = Address::generate(&env);

client.mint(&owner, &1000);

// Set expiration to ledger 100
client.approve(&owner, &spender, &500, &100);

// Move to ledger 200 (past expiration)
env.ledger().set(200);

// Allowance should be 0 (expired)
assert_eq!(client.allowance(&owner, &spender), 0);
}

#[test]
#[should_panic(expected = "insufficient allowance")]
fn test_transfer_from_with_expired_allowance_fails() {
let env = Env::default();
env.mock_all_auths();
let (client, _) = setup_contract(&env);
let _admin = init_default(&env, &client);
let owner = Address::generate(&env);
let spender = Address::generate(&env);
let receiver = Address::generate(&env);

client.mint(&owner, &1000);

// Set expiration to ledger 100
client.approve(&owner, &spender, &500, &100);

// Move to ledger 200 (past expiration)
env.ledger().set(200);

// Should fail with insufficient allowance (expired)
client.transfer_from(&spender, &owner, &receiver, &200);
}

// ─── Burn ────────────────────────────────────────────────────────────────────

#[test]
Expand Down Expand Up @@ -344,11 +414,40 @@ fn test_transfer_ownership() {
}

#[test]
fn test_two_step_ownership_transfer_happy_path() {
fn test_role_management() {
let env = Env::default();
env.mock_all_auths();
let (client, _) = setup_contract(&env);
let admin = init_default(&env, &client);
let new_admin = Address::generate(&env);
let user = Address::generate(&env);

// Initially no pending owner
assert!(client.pending_owner().is_none());

// Propose new admin
client.propose_owner(&new_admin);

// Check pending owner
let pending = client.pending_owner();
assert!(pending.is_some());
assert_eq!(pending.unwrap(), new_admin);

// New admin accepts
client.accept_ownership();

// Pending owner should be cleared
assert!(client.pending_owner().is_none());

// New admin should be able to mint
client.mint(&user, &500);
assert_eq!(client.balance(&user), 500);
}

#[test]
#[should_panic(expected = "no pending ownership transfer")]
fn test_accept_ownership_without_proposal_fails() {
let minter = Address::generate(&env);
let user = Address::generate(&env);

Expand All @@ -375,6 +474,58 @@ fn test_mint_unauthorized_role() {
env.mock_all_auths();
let (client, _) = setup_contract(&env);
let _admin = init_default(&env, &client);

// Try to accept without proposal
client.accept_ownership();
}

#[test]
fn test_cancel_transfer() {
let env = Env::default();
env.mock_all_auths();
let (client, _) = setup_contract(&env);
let admin = init_default(&env, &client);
let new_admin = Address::generate(&env);

// Propose new admin
client.propose_owner(&new_admin);
assert!(client.pending_owner().is_some());

// Cancel the transfer
client.cancel_transfer();

// Pending owner should be cleared
assert!(client.pending_owner().is_none());
}

#[test]
#[should_panic(expected = "no pending ownership transfer")]
fn test_cancel_transfer_without_proposal_fails() {
let env = Env::default();
env.mock_all_auths();
let (client, _) = setup_contract(&env);
let _admin = init_default(&env, &client);

// Try to cancel without proposal
client.cancel_transfer();
}

#[test]
fn test_double_propose_updates_pending_admin() {
let env = Env::default();
env.mock_all_auths();
let (client, _) = setup_contract(&env);
let _admin = init_default(&env, &client);
let first_proposal = Address::generate(&env);
let second_proposal = Address::generate(&env);

// First proposal
client.propose_owner(&first_proposal);
assert_eq!(client.pending_owner().unwrap(), first_proposal);

// Second proposal (should override first)
client.propose_owner(&second_proposal);
assert_eq!(client.pending_owner().unwrap(), second_proposal);
let non_minter = Address::generate(&env);
let user = Address::generate(&env);

Expand Down
Loading