diff --git a/contracts/token/src/events.rs b/contracts/token/src/events.rs index 37de9f2..e1a6e89 100644 --- a/contracts/token/src/events.rs +++ b/contracts/token/src/events.rs @@ -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() diff --git a/contracts/token/src/lib.rs b/contracts/token/src/lib.rs index 6518570..a2e0010 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -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. @@ -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
{ + env.storage().instance().get(&DataKey::PendingAdmin) + } } // ───────────────────────────────────────────────────────────────────────────── @@ -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(); @@ -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
{ + 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)); diff --git a/contracts/token/src/test.rs b/contracts/token/src/test.rs index 60ca158..927dfb7 100644 --- a/contracts/token/src/test.rs +++ b/contracts/token/src/test.rs @@ -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] @@ -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); @@ -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);