From 28238a288af832da0a48a7c0750a98091da09c88 Mon Sep 17 00:00:00 2001 From: oche2920 Date: Wed, 27 May 2026 06:55:55 +0100 Subject: [PATCH] feat(token): separate minter role from admin role - Add DataKey::Minter(Address) to token contract storage keys - Add grant_minter/revoke_minter admin-only functions - Add is_minter query function - Update mint and batch_mint to require Role::Minter instead of admin - Emit mtr_grnt/mtr_rvk events on role changes - Fix AdminKey enum: move AdminPool, Threshold, Proposal, ProposalIdCounter out of Role enum where they were incorrectly merged - Fix missing closing braces in admin has_role and require_role (merge artifacts) - Fix symbol_short names exceeding 9-char limit in events.rs - Clean up duplicate use statements and function bodies in token lib.rs - Update all tests and proptests to use new mint(minter, to, amount) signature Closes #54 --- contracts/admin/src/lib.rs | 20 +- contracts/token/src/events.rs | 20 +- contracts/token/src/lib.rs | 603 +++++++++++--------------------- contracts/token/src/proptest.rs | 22 +- 4 files changed, 238 insertions(+), 427 deletions(-) diff --git a/contracts/admin/src/lib.rs b/contracts/admin/src/lib.rs index 8578a8b..403748b 100644 --- a/contracts/admin/src/lib.rs +++ b/contracts/admin/src/lib.rs @@ -15,6 +15,14 @@ pub enum AdminKey { Admin, /// Role assignments: (Role, Address) -> bool Role(Role, Address), + /// The pool of administrator addresses for multi-sig. + AdminPool, + /// Minimum signatures required for multi-sig actions. + Threshold, + /// Active proposals: proposal_id -> Proposal. + Proposal(u64), + /// Counter for generating unique proposal IDs. + ProposalIdCounter, } /// Enumeration of available roles. @@ -25,14 +33,6 @@ pub enum Role { Admin = 0, /// Account authorized to mint tokens. Minter = 1, - /// The pool of administrator addresses for multi-sig. - AdminPool, - /// Minimum signatures required for multi-sig actions. - Threshold, - /// Active proposals: proposal_id -> Proposal. - Proposal(u64), - /// Counter for generating unique proposal IDs. - ProposalIdCounter, } /// A proposal for a multi-signature action. @@ -93,7 +93,7 @@ pub fn has_role(env: &Env, role: Role, address: &Address) -> bool { return true; } env.storage().persistent().has(&AdminKey::Role(role, address.clone())) -// ─── Multi-Sig Primitives ─────────────────────────────────────────────────── +} /// Configures the multi-signature admin pool. pub fn set_admin_pool(env: &Env, pool: Vec
, threshold: u32) { @@ -137,7 +137,7 @@ pub fn require_role(env: &Env, role: Role, address: &Address) { panic!("unauthorized: missing role"); } address.require_auth(); -// ─── Proposals ────────────────────────────────────────────────────────────── +} /// Creates a new proposal for an administrative action. pub fn create_proposal(env: &Env, creator: Address, description: String) -> u64 { diff --git a/contracts/token/src/events.rs b/contracts/token/src/events.rs index e1a6e89..910e630 100644 --- a/contracts/token/src/events.rs +++ b/contracts/token/src/events.rs @@ -90,7 +90,7 @@ pub fn emit_ownership_proposed(env: &Env, old_admin: &Address, pending_admin: &A /// 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"),), + (symbol_short!("own_acpt"),), (old_admin.clone(), new_admin.clone()), ); } @@ -98,7 +98,7 @@ pub fn emit_ownership_accepted(env: &Env, old_admin: &Address, new_admin: &Addre /// 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"),), + (symbol_short!("own_cncl"),), (admin.clone(), cancelled_admin.clone()), ); } @@ -162,3 +162,19 @@ pub fn emit_update_symbol(env: &Env, admin: &Address, old_symbol: &String, new_s (admin.clone(), old_symbol.clone(), new_symbol.clone()), ); } + +/// Emitted when the Minter role is granted to an address. +pub fn emit_minter_granted(env: &Env, admin: &Address, minter: &Address) { + env.events().publish( + (symbol_short!("mtr_grnt"),), + (admin.clone(), minter.clone()), + ); +} + +/// Emitted when the Minter role is revoked from an address. +pub fn emit_minter_revoked(env: &Env, admin: &Address, minter: &Address) { + env.events().publish( + (symbol_short!("mtr_rvk"),), + (admin.clone(), minter.clone()), + ); +} diff --git a/contracts/token/src/lib.rs b/contracts/token/src/lib.rs index c485b32..5f0921f 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -5,7 +5,7 @@ //! //! ## Features //! - SEP-41 compliant (balance, transfer, approve, burn) -//! - Admin-only minting with supply tracking +//! - Minter role separate from admin: only addresses with the Minter role can mint //! - Emergency pause/unpause via lifecycle module //! - Two-step ownership transfer support //! - Structured event emissions for off-chain indexing @@ -20,18 +20,13 @@ mod test; mod proptest; use soroban_sdk::token::TokenInterface; -use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec}; -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; -use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env, String}; +use soroban_sdk::{contract, contractimpl, contracttype, contracterror, Address, BytesN, Env, String, Vec}; use bc_forge_admin::{self as admin, Role}; /// Storage keys for the token contract state. #[derive(Clone)] #[contracttype] 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. @@ -54,6 +49,8 @@ pub enum DataKey { Lockup(Address), /// Associated action for a proposal ID. ProposalAction(u64), + /// Whether an address holds the Minter role (mirrors admin module, kept for query convenience). + Minter(Address), } /// Information about a token lockup/vesting. @@ -81,6 +78,20 @@ pub struct Recipient { pub amount: i128, } +/// Contract-level errors. +#[derive(Clone, Copy, Debug, PartialEq)] +#[contracterror] +#[repr(u32)] +pub enum TokenError { + NotInitialized = 1, + AlreadyInitialized = 2, + InvalidAmount = 3, + InsufficientBalance = 4, + InsufficientAllowance = 5, + ContractPaused = 6, + Unauthorized = 7, +} + // ───────────────────────────────────────────────────────────────────────────── // Contract Definition // ───────────────────────────────────────────────────────────────────────────── @@ -93,23 +104,6 @@ pub struct BcForgeToken; // ───────────────────────────────────────────────────────────────────────────── impl BcForgeToken { - /// Returns an initialized admin address or a contract error. - fn read_admin(env: &Env) -> Result { - env.storage() - .instance() - .get(&DataKey::Admin) - .ok_or(TokenError::NotInitialized) - } - - /// Returns `Ok(())` when the contract has been initialized. - fn ensure_initialized(env: &Env) -> Result<(), TokenError> { - if env.storage().instance().has(&DataKey::Admin) { - Ok(()) - } else { - Err(TokenError::NotInitialized) - } - } - /// Returns `Ok(())` when the contract is not paused. fn ensure_not_paused(env: &Env) -> Result<(), TokenError> { if bc_forge_lifecycle::is_paused(env) { @@ -145,14 +139,15 @@ impl BcForgeToken { /// Reads the spending allowance for (owner → spender), defaulting to 0. /// Returns 0 if the allowance has expired. fn read_allowance(env: &Env, from: &Address, spender: &Address) -> i128 { - // Check if allowance has expired - if let Some(exp_ledger) = env.storage().persistent().get(&DataKey::AllowanceExp(from.clone(), spender.clone())) { - let current_ledger = env.ledger().sequence(); - if current_ledger > exp_ledger { - return 0; // Allowance expired + if let Some(exp_ledger) = env + .storage() + .persistent() + .get::<_, u32>(&DataKey::AllowanceExp(from.clone(), spender.clone())) + { + if env.ledger().sequence() > exp_ledger { + return 0; } } - env.storage() .persistent() .get(&DataKey::Allowance(from.clone(), spender.clone())) @@ -164,8 +159,6 @@ impl BcForgeToken { env.storage() .persistent() .set(&DataKey::Allowance(from.clone(), spender.clone()), &amount); - - // Store expiration if non-zero (0 means no expiration) if exp > 0 { env.storage() .persistent() @@ -174,29 +167,23 @@ impl BcForgeToken { } /// Moves `amount` tokens from `from` to `to`. - /// Returns the new balances (from_balance, to_balance). fn move_balance( env: &Env, from: &Address, to: &Address, amount: i128, ) -> Result<(i128, i128), TokenError> { - fn move_balance(env: &Env, from: &Address, to: &Address, amount: i128) -> (i128, i128) { let from_balance = Self::read_balance(env, from); if from_balance < amount { return Err(TokenError::InsufficientBalance); } - if from == to { return Ok((from_balance, from_balance)); } - let new_from = from_balance - amount; let new_to = Self::read_balance(env, to) + amount; - Self::write_balance(env, from, new_from); Self::write_balance(env, to, new_to); - Ok((new_from, new_to)) } @@ -210,25 +197,16 @@ impl BcForgeToken { env.storage().instance().set(&DataKey::Supply, &supply); } - /// Reads the admin address via the admin module. - fn read_admin(env: &Env) -> Address { - admin::get_admin(env) - bc_forge_admin::get_admin(env) - } - - /// Internal logic for minting. - fn internal_mint(env: &Env, to: Address, amount: i128) { + /// Internal logic for minting — no auth checks, callers must verify. + fn internal_mint(env: &Env, minter: &Address, to: &Address, amount: i128) { if amount <= 0 { panic!("mint amount must be positive"); } - - let balance = Self::read_balance(env, &to) + amount; - Self::write_balance(env, &to, balance); - + let balance = Self::read_balance(env, to) + amount; + Self::write_balance(env, to, balance); let supply = Self::read_supply(env) + amount; Self::write_supply(env, supply); - - events::emit_mint(env, &bc_forge_admin::get_admin(env), &to, amount, balance, supply); + events::emit_mint(env, minter, to, amount, balance, supply); } /// Reads the pending admin address (if any). @@ -238,125 +216,161 @@ impl BcForgeToken { } // ───────────────────────────────────────────────────────────────────────────── -// Custom Admin / Lifecycle / Clawback / Locking Functions +// Custom Admin / Lifecycle / Clawback / Locking / Minter Functions // ───────────────────────────────────────────────────────────────────────────── #[contractimpl] impl BcForgeToken { /// Initializes the token contract with an admin and metadata. - /// - /// # Arguments - /// * `admin` - The address that will have minting privileges. - /// * `decimal` - Number of decimal places (e.g., 7 for Stellar standard). - /// * `name` - Human-readable token name. - /// * `symbol` - Token ticker symbol. - pub fn initialize( - env: Env, - admin: Address, - decimal: u32, - name: String, - symbol: String, - ) -> Result<(), TokenError> { - if env.storage().instance().has(&DataKey::Admin) { - return Err(TokenError::AlreadyInitialized); + /// The admin is NOT automatically a minter — use `grant_minter` to assign minting rights. pub fn initialize(env: Env, admin: Address, decimal: u32, name: String, symbol: String) { if admin::has_admin(&env) { panic!("already initialized"); } - admin::set_admin(&env, &admin); - if bc_forge_admin::has_admin(&env) { - panic!("already initialized"); - } - - bc_forge_admin::set_admin(&env, &admin); env.storage().instance().set(&DataKey::Decimals, &decimal); env.storage().instance().set(&DataKey::Name, &name); env.storage().instance().set(&DataKey::Symbol, &symbol); Self::write_supply(&env, 0); - events::emit_initialized(&env, &admin, decimal, &name, &symbol); - - Ok(()) } - /// Mints `amount` tokens to the `to` address. Admin-only. + // ─── Minter Role Management ─────────────────────────────────────────────── + + /// Grants the Minter role to `minter`. Admin-only. /// - /// # Arguments - /// * `to` - Recipient address. - /// * `amount` - Number of tokens to mint (must be positive). - pub fn mint(env: Env, to: Address, amount: i128) -> Result<(), TokenError> { - Self::ensure_initialized(&env)?; - Self::ensure_not_paused(&env)?; + /// After this call, `minter` may call `mint` and `batch_mint`. + pub fn grant_minter(env: Env, minter: Address) { + admin::require_admin(&env); + admin::grant_role(&env, Role::Minter, &minter); + events::emit_minter_granted(&env, &admin::get_admin(&env), &minter); + } + + /// Revokes the Minter role from `minter`. Admin-only. + pub fn revoke_minter(env: Env, minter: Address) { + admin::require_admin(&env); + admin::revoke_role(&env, Role::Minter, &minter); + events::emit_minter_revoked(&env, &admin::get_admin(&env), &minter); + } + /// Returns `true` if `address` currently holds the Minter role. + pub fn is_minter(env: Env, address: Address) -> bool { + admin::has_role(&env, Role::Minter, &address) + } + + // ─── Minting ────────────────────────────────────────────────────────────── + + /// Mints `amount` tokens to `to`. Requires the Minter role. + /// + /// # Panics + /// Panics if caller lacks the Minter role, amount ≤ 0, or contract is paused. + pub fn mint(env: Env, minter: Address, to: Address, amount: i128) { + bc_forge_lifecycle::require_not_paused(&env); + admin::require_role(&env, Role::Minter, &minter); if amount <= 0 { - return Err(TokenError::InvalidAmount); + panic!("mint amount must be positive"); + } + Self::internal_mint(&env, &minter, &to, amount); + } + + /// Mints tokens to multiple recipients atomically. Requires the Minter role. /// /// # Panics - /// Panics if caller is not admin, amount is non-positive, or contract is paused. - pub fn mint(env: Env, caller: Address, to: Address, amount: i128) { - /// Mints `amount` tokens to the `to` address. Single-admin auth. - pub fn mint(env: Env, to: Address, amount: i128) { + /// Panics if caller lacks the Minter role, list is empty, any amount ≤ 0, or contract is paused. + pub fn batch_mint(env: Env, minter: Address, recipients: Vec) { bc_forge_lifecycle::require_not_paused(&env); - Self::read_admin(&env).require_auth(); - Self::internal_mint(&env, to, amount); + admin::require_role(&env, Role::Minter, &minter); + + if recipients.is_empty() { + panic!("recipients list cannot be empty"); + } + + // Validate all amounts before touching state + for i in 0..recipients.len() { + let r = recipients.get(i).expect("recipient should exist"); + if r.amount <= 0 { + panic!("mint amount must be positive for all recipients"); + } + } + + let mut total_minted: i128 = 0; + for i in 0..recipients.len() { + let r = recipients.get(i).expect("recipient should exist"); + let balance = Self::read_balance(&env, &r.address) + r.amount; + Self::write_balance(&env, &r.address, balance); + total_minted += r.amount; + let running_supply = Self::read_supply(&env) + total_minted; + events::emit_mint(&env, &minter, &r.address, r.amount, balance, running_supply); + } + + let new_supply = Self::read_supply(&env) + total_minted; + Self::write_supply(&env, new_supply); } + // ─── Multi-sig ──────────────────────────────────────────────────────────── + /// Configures the multi-signature admin pool. pub fn set_admin_pool(env: Env, pool: Vec
, threshold: u32) { - Self::read_admin(&env).require_auth(); - bc_forge_admin::set_admin_pool(&env, pool, threshold); + admin::require_admin(&env); + admin::set_admin_pool(&env, pool, threshold); } /// Creates a proposal for a multi-sig token action. - pub fn propose_action(env: Env, admin: Address, action: TokenAction, description: String) -> u64 { - let id = bc_forge_admin::create_proposal(&env, admin, description); + pub fn propose_action(env: Env, admin_addr: Address, action: TokenAction, description: String) -> u64 { + let id = admin::create_proposal(&env, admin_addr, description); env.storage().instance().set(&DataKey::ProposalAction(id), &action); id } /// Approves an existing proposal. - pub fn approve_proposal(env: Env, admin: Address, proposal_id: u64) { - bc_forge_admin::approve_proposal(&env, admin, proposal_id); + pub fn approve_proposal(env: Env, admin_addr: Address, proposal_id: u64) { + admin::approve_proposal(&env, admin_addr, proposal_id); } /// Executes a proposal once quorum is reached. pub fn execute_proposal(env: Env, proposal_id: u64) { - bc_forge_admin::mark_executed(&env, proposal_id); - let action: TokenAction = env.storage().instance().get(&DataKey::ProposalAction(proposal_id)) + admin::mark_executed(&env, proposal_id); + let action: TokenAction = env + .storage() + .instance() + .get(&DataKey::ProposalAction(proposal_id)) .expect("proposal action not found"); match action { TokenAction::Mint(to, amount) => { bc_forge_lifecycle::require_not_paused(&env); - Self::internal_mint(&env, to, amount); - }, + // Proposals execute as the contract admin acting as minter + let a = admin::get_admin(&env); + Self::internal_mint(&env, &a, &to, amount); + } TokenAction::Pause => { - let admin = bc_forge_admin::get_admin(&env); - bc_forge_lifecycle::pause(env.clone(), admin.clone()); - events::emit_paused(&env, &admin); - }, + let a = admin::get_admin(&env); + bc_forge_lifecycle::pause(env.clone(), a.clone()); + events::emit_paused(&env, &a); + } TokenAction::Unpause => { - let admin = bc_forge_admin::get_admin(&env); - bc_forge_lifecycle::unpause(env.clone(), admin.clone()); - events::emit_unpaused(&env, &admin); + let a = admin::get_admin(&env); + bc_forge_lifecycle::unpause(env.clone(), a.clone()); + events::emit_unpaused(&env, &a); } } env.storage().instance().remove(&DataKey::ProposalAction(proposal_id)); } - let admin = Self::read_admin(&env)?; - admin.require_auth(); - admin::require_role(&env, Role::Minter, &caller); + // ─── Clawback ───────────────────────────────────────────────────────────── + /// Sets the specifically designated ClawbackAdmin. - pub fn set_clawback_admin(env: Env, admin: Address) { - Self::read_admin(&env).require_auth(); - env.storage().instance().set(&DataKey::ClawbackAdmin, &admin); + pub fn set_clawback_admin(env: Env, clawback_admin: Address) { + admin::require_admin(&env); + env.storage().instance().set(&DataKey::ClawbackAdmin, &clawback_admin); } /// Recovers asset balances from client allocations. SEP-0008 compliant. pub fn clawback(env: Env, from: Address, to: Address, amount: i128) { - let claw_admin: Address = env.storage().instance().get(&DataKey::ClawbackAdmin) + let claw_admin: Address = env + .storage() + .instance() + .get(&DataKey::ClawbackAdmin) .expect("clawback admin not set"); claw_admin.require_auth(); @@ -364,111 +378,32 @@ impl BcForgeToken { panic!("clawback amount must be positive"); } - events::emit_mint(&env, &admin, &to, amount, balance, supply); - - Ok(()) - } - - /// Transfers the admin role to a new address. Current admin-only. - /// - /// # Arguments - /// * `new_admin` - The address to receive admin privileges. - pub fn transfer_ownership(env: Env, new_admin: Address) -> Result<(), TokenError> { - Self::ensure_initialized(&env)?; - let admin = Self::read_admin(&env)?; - events::emit_mint(&env, &caller, &to, amount, balance, supply); - } - - /// Grants a role to an address. Admin-only. - pub fn grant_role(env: Env, role: Role, address: Address) { - admin::grant_role(&env, role, &address); - } - - /// Revokes a role from an address. Admin-only. - pub fn revoke_role(env: Env, role: Role, address: Address) { - admin::revoke_role(&env, role, &address); - } - - /// Checks if an address has a role. - pub fn has_role(env: Env, role: Role, address: Address) -> bool { - admin::has_role(&env, role, &address) - Self::move_balance(&env, &from, &to, amount); + Self::move_balance(&env, &from, &to, amount) + .unwrap_or_else(|_| panic!("insufficient balance for clawback")); events::emit_clawback(&env, &claw_admin, &from, &to, amount); } - /// Mints tokens to multiple recipients in a single transaction. Admin-only. - /// - /// # Arguments - /// * `recipients` - Vector of (address, amount) pairs. - /// - /// # Panics - /// Panics if caller is not admin, contract is paused, any amount is non-positive, - /// or if the recipients list is empty. - /// - /// # Note - /// All mints are atomic - if any recipient has an invalid amount, the entire batch reverts. - pub fn batch_mint(env: Env, recipients: Vec) { - bc_forge_lifecycle::require_not_paused(&env); - - let admin = Self::read_admin(&env); - admin.require_auth(); - - if recipients.is_empty() { - panic!("recipients list cannot be empty"); - } - - // First pass: validate all amounts are positive - for i in 0..recipients.len() { - let recipient = recipients.get(i).expect("recipient should exist"); - if recipient.amount <= 0 { - panic!("mint amount must be positive for all recipients"); - } - } - - // Second pass: perform all mints and calculate total - let mut total_minted: i128 = 0; - for i in 0..recipients.len() { - let recipient = recipients.get(i).expect("recipient should exist"); - let balance = Self::read_balance(&env, &recipient.address) + recipient.amount; - Self::write_balance(&env, &recipient.address, balance); - total_minted += recipient.amount; - - // Emit individual mint event per recipient - events::emit_mint(&env, &admin, &recipient.address, recipient.amount, balance, Self::read_supply(&env) + total_minted); - } - - // Update total supply atomically once at the end - let new_supply = Self::read_supply(&env) + total_minted; - Self::write_supply(&env, new_supply); - } + // ─── Token Locking ──────────────────────────────────────────────────────── - /// 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(); - + admin::require_admin(&env); + let balance = Self::read_balance(&env, &user); if balance < amount { panic!("insufficient balance to lock"); } - - // Subtract from spendable balance Self::write_balance(&env, &user, balance - amount); - - let mut lockup = env.storage().persistent().get::<_, LockupInfo>(&DataKey::Lockup(user.clone())) + + let mut lockup = env + .storage() + .persistent() + .get::<_, LockupInfo>(&DataKey::Lockup(user.clone())) .unwrap_or(LockupInfo { amount: 0, unlock_time: 0 }); - lockup.amount += amount; if unlock_time > lockup.unlock_time { lockup.unlock_time = unlock_time; } - env.storage().persistent().set(&DataKey::Lockup(user.clone()), &lockup); events::emit_locked(&env, &user, amount, lockup.unlock_time); } @@ -476,227 +411,127 @@ impl BcForgeToken { /// Withdraws locked tokens past the release interval. pub fn withdraw_locked(env: Env, user: Address) { user.require_auth(); - - let lockup: LockupInfo = env.storage().persistent().get(&DataKey::Lockup(user.clone())) + + let lockup: LockupInfo = env + .storage() + .persistent() + .get(&DataKey::Lockup(user.clone())) .expect("no lockup found"); - + if env.ledger().timestamp() < lockup.unlock_time { panic!("tokens are still locked"); } - + let balance = Self::read_balance(&env, &user); Self::write_balance(&env, &user, balance + lockup.amount); env.storage().persistent().remove(&DataKey::Lockup(user.clone())); - events::emit_withdraw_locked(&env, &user, lockup.amount); } - /// Transfers the admin role to a new address. - pub fn transfer_ownership(env: Env, new_admin: Address) { - let admin = admin::get_admin(&env); - admin.require_auth(); + // ─── Ownership ──────────────────────────────────────────────────────────── + /// Immediately transfers the admin role to `new_admin`. Current admin-only. + /// + /// ⚠️ Prefer `propose_owner` + `accept_ownership` for safer two-step transfer. + pub fn transfer_ownership(env: Env, new_admin: Address) { + let current = admin::get_admin(&env); + current.require_auth(); admin::set_admin(&env, &new_admin); - bc_forge_admin::set_admin(&env, &new_admin); - events::emit_ownership_transferred(&env, &admin, &new_admin); - - Ok(()) + events::emit_ownership_transferred(&env, ¤t, &new_admin); } /// 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(); - + let current = admin::get_admin(&env); + current.require_auth(); env.storage().instance().set(&DataKey::PendingAdmin, &new_admin); - events::emit_ownership_proposed(&env, &admin, &new_admin); + events::emit_ownership_proposed(&env, ¤t, &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); + let pending = Self::read_pending_admin(&env).expect("no pending ownership transfer"); + pending.require_auth(); + let old = admin::get_admin(&env); + admin::set_admin(&env, &pending); env.storage().instance().remove(&DataKey::PendingAdmin); - - events::emit_ownership_accepted(&env, &old_admin, &pending_admin); + events::emit_ownership_accepted(&env, &old, &pending); } /// 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"); - + let current = admin::get_admin(&env); + current.require_auth(); + let pending = Self::read_pending_admin(&env).expect("no pending ownership transfer"); env.storage().instance().remove(&DataKey::PendingAdmin); - events::emit_ownership_cancelled(&env, &admin, &pending_admin); + events::emit_ownership_cancelled(&env, ¤t, &pending); } /// 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) } - /// 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)); - env.storage().instance().get(&DataKey::Supply).unwrap_or(0) - } + // ─── Lifecycle ──────────────────────────────────────────────────────────── /// Pauses all token operations. Admin-only. - pub fn pause(env: Env) -> Result<(), TokenError> { - Self::ensure_initialized(&env)?; - let admin = Self::read_admin(&env)?; - /// Pauses all token operations. pub fn pause(env: Env) { - let admin = Self::read_admin(&env); - bc_forge_lifecycle::pause(env.clone(), admin.clone()); - events::emit_paused(&env, &admin); - - Ok(()) + let a = admin::get_admin(&env); + bc_forge_lifecycle::pause(env.clone(), a.clone()); + events::emit_paused(&env, &a); } /// Unpauses token operations. Admin-only. - pub fn unpause(env: Env) -> Result<(), TokenError> { - Self::ensure_initialized(&env)?; - let admin = Self::read_admin(&env)?; - /// Unpauses token operations. pub fn unpause(env: Env) { - let admin = Self::read_admin(&env); - bc_forge_lifecycle::unpause(env.clone(), admin.clone()); - events::emit_unpaused(&env, &admin); - - Ok(()) + let a = admin::get_admin(&env); + bc_forge_lifecycle::unpause(env.clone(), a.clone()); + events::emit_unpaused(&env, &a); } - /// Upgrades the contract to a new WASM hash. Admin-only. - pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), TokenError> { - Self::ensure_initialized(&env)?; - let admin = Self::read_admin(&env)?; - admin.require_auth(); - - env.deployer() - .update_current_contract_wasm(new_wasm_hash.clone()); - events::emit_upgrade(&env, &admin, &new_wasm_hash); + // ─── Misc ───────────────────────────────────────────────────────────────── - Ok(()) + /// Upgrades the contract to a new WASM hash. Admin-only. + pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { + let a = admin::get_admin(&env); + a.require_auth(); + env.deployer().update_current_contract_wasm(new_wasm_hash.clone()); + events::emit_upgrade(&env, &a, &new_wasm_hash); } /// Returns the contract version. pub fn version(env: Env) -> String { - String::from_str(&env, "1.1.0") + String::from_str(&env, "1.2.0") } - /// Updates the token name. Admin-only. - pub fn update_name(env: Env, new_name: String) -> Result<(), TokenError> { - Self::ensure_initialized(&env)?; - let admin = Self::read_admin(&env)?; - admin.require_auth(); + /// Returns the total token supply. + pub fn supply(env: Env) -> i128 { + Self::read_supply(&env) + } + /// Updates the token name. Admin-only. + pub fn update_name(env: Env, new_name: String) { + let a = admin::get_admin(&env); + a.require_auth(); let old_name = env .storage() .instance() .get(&DataKey::Name) .unwrap_or_else(|| String::from_str(&env, "bc-forge")); - env.storage().instance().set(&DataKey::Name, &new_name); - events::emit_update_name(&env, &admin, &old_name, &new_name); - - Ok(()) + events::emit_update_name(&env, &a, &old_name, &new_name); } /// Updates the token symbol. Admin-only. - pub fn update_symbol(env: Env, new_symbol: String) -> Result<(), TokenError> { - Self::ensure_initialized(&env)?; - let admin = Self::read_admin(&env)?; - admin.require_auth(); - + pub fn update_symbol(env: Env, new_symbol: String) { + let a = admin::get_admin(&env); + a.require_auth(); let old_symbol = env .storage() .instance() .get(&DataKey::Symbol) .unwrap_or_else(|| String::from_str(&env, "SFG")); - env.storage().instance().set(&DataKey::Symbol, &new_symbol); - events::emit_update_symbol(&env, &admin, &old_symbol, &new_symbol); - - Ok(()) + events::emit_update_symbol(&env, &a, &old_symbol, &new_symbol); } } @@ -707,20 +542,10 @@ impl BcForgeToken { #[contractimpl] impl TokenInterface for BcForgeToken { fn allowance(env: Env, from: Address, spender: Address) -> i128 { - Self::panic_on_err(&env, Self::ensure_initialized(&env)); Self::read_allowance(&env, &from, &spender) } - /// Approves `spender` to spend up to `amount` tokens on behalf of `from`. - /// - /// # Arguments - /// * `from` - The token owner granting the allowance. - /// * `spender` - The address being granted spending rights. - /// * `amount` - Maximum tokens the spender can use. - /// * `exp` - Expiration ledger sequence (0 means no expiration). fn approve(env: Env, from: Address, spender: Address, amount: i128, exp: u32) { - fn approve(env: Env, from: Address, spender: Address, amount: i128, _exp: u32) { - Self::panic_on_err(&env, Self::ensure_initialized(&env)); from.require_auth(); if amount < 0 { soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); @@ -730,107 +555,77 @@ impl TokenInterface for BcForgeToken { } fn balance(env: Env, id: Address) -> i128 { - Self::panic_on_err(&env, Self::ensure_initialized(&env)); Self::read_balance(&env, &id) } - /// Transfers `amount` tokens from `from` to `to`. fn transfer(env: Env, from: Address, to: Address, amount: i128) { Self::panic_on_err(&env, Self::ensure_not_paused(&env)); - Self::panic_on_err(&env, Self::ensure_initialized(&env)); from.require_auth(); - if amount <= 0 { soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); } - let _ = Self::panic_on_err(&env, Self::move_balance(&env, &from, &to, amount)); events::emit_transfer(&env, &from, &to, amount); } - /// Transfers `amount` tokens from `from` to `to` using `spender`'s allowance. fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128) { Self::panic_on_err(&env, Self::ensure_not_paused(&env)); - Self::panic_on_err(&env, Self::ensure_initialized(&env)); spender.require_auth(); - if amount <= 0 { soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); } - let allowance = Self::read_allowance(&env, &from, &spender); if allowance < amount { soroban_sdk::panic_with_error!(&env, TokenError::InsufficientAllowance); } - - Self::move_balance(&env, &from, &to, amount); - Self::write_allowance(&env, &from, &spender, allowance - amount, 0); // Keep original expiration + let _ = Self::panic_on_err(&env, Self::move_balance(&env, &from, &to, amount)); + Self::write_allowance(&env, &from, &spender, allowance - amount, 0); events::emit_transfer_from(&env, &spender, &from, &to, amount, allowance - amount); } - /// Burns `amount` tokens from `from`'s balance, reducing total supply. fn burn(env: Env, from: Address, amount: i128) { Self::panic_on_err(&env, Self::ensure_not_paused(&env)); - Self::panic_on_err(&env, Self::ensure_initialized(&env)); from.require_auth(); - if amount <= 0 { soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); } - let balance = Self::read_balance(&env, &from); if balance < amount { soroban_sdk::panic_with_error!(&env, TokenError::InsufficientBalance); } - let new_balance = balance - amount; Self::write_balance(&env, &from, new_balance); - let supply = Self::read_supply(&env) - amount; Self::write_supply(&env, supply); - events::emit_burn(&env, &from, amount, new_balance, supply); } - /// Burns `amount` tokens from `from` using `spender`'s allowance. fn burn_from(env: Env, spender: Address, from: Address, amount: i128) { Self::panic_on_err(&env, Self::ensure_not_paused(&env)); - Self::panic_on_err(&env, Self::ensure_initialized(&env)); spender.require_auth(); - if amount <= 0 { soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); } - let allowance = Self::read_allowance(&env, &from, &spender); if allowance < amount { soroban_sdk::panic_with_error!(&env, TokenError::InsufficientAllowance); } - let balance = Self::read_balance(&env, &from); if balance < amount { soroban_sdk::panic_with_error!(&env, TokenError::InsufficientBalance); } - - Self::write_allowance(&env, &from, &spender, allowance - amount, 0); // Keep original expiration + Self::write_allowance(&env, &from, &spender, allowance - amount, 0); Self::write_balance(&env, &from, balance - amount); - let supply = Self::read_supply(&env) - amount; Self::write_supply(&env, supply); - events::emit_burn(&env, &from, amount, balance - amount, supply); } fn decimals(env: Env) -> u32 { - Self::panic_on_err(&env, Self::ensure_initialized(&env)); - env.storage() - .instance() - .get(&DataKey::Decimals) - .unwrap_or(7) + env.storage().instance().get(&DataKey::Decimals).unwrap_or(7) } fn name(env: Env) -> String { - Self::panic_on_err(&env, Self::ensure_initialized(&env)); env.storage() .instance() .get(&DataKey::Name) @@ -838,11 +633,9 @@ impl TokenInterface for BcForgeToken { } fn symbol(env: Env) -> String { - Self::panic_on_err(&env, Self::ensure_initialized(&env)); env.storage() .instance() .get(&DataKey::Symbol) .unwrap_or_else(|| String::from_str(&env, "SFG")) } } - diff --git a/contracts/token/src/proptest.rs b/contracts/token/src/proptest.rs index 4e92596..1b49c8c 100644 --- a/contracts/token/src/proptest.rs +++ b/contracts/token/src/proptest.rs @@ -10,18 +10,20 @@ use soroban_sdk::testutils::Address as _; use soroban_sdk::{Address, Env, String}; use crate::{BcForgeToken, BcForgeTokenClient}; -/// Helper: setup a fresh environment and initialized client. +/// Helper: setup a fresh environment, initialized client, and admin granted the Minter role. fn setup_test_env() -> (Env, BcForgeTokenClient<'static>, Address) { let env = Env::default(); env.mock_all_auths(); let contract_id = env.register(BcForgeToken, ()); let client = BcForgeTokenClient::new(&env, &contract_id); - + let admin = Address::generate(&env); let name = String::from_str(&env, "PropTest Token"); let symbol = String::from_str(&env, "PTT"); client.initialize(&admin, &7, &name, &symbol); - + // Grant admin the Minter role so property tests can mint freely. + client.grant_minter(&admin); + (env, client, admin) } @@ -34,11 +36,11 @@ proptest! { initial_mint in 1..i128::MAX / 4, transfer_amount in 1..i128::MAX / 4 ) { - let (env, client, _) = setup_test_env(); + let (env, client, admin) = setup_test_env(); let user_a = Address::generate(&env); let user_b = Address::generate(&env); - client.mint(&user_a, &initial_mint); + client.mint(&admin, &user_a, &initial_mint); let initial_supply = client.supply(); // If transfer_amount > initial_mint, it should panic (insufficient balance) @@ -61,11 +63,11 @@ proptest! { mint2 in 1..i128::MAX / 4, burn_amount in 1..i128::MAX / 4 ) { - let (env, client, _) = setup_test_env(); + let (env, client, admin) = setup_test_env(); let user = Address::generate(&env); - client.mint(&user, &mint1); - client.mint(&user, &mint2); + client.mint(&admin, &user, &mint1); + client.mint(&admin, &user, &mint2); let expected_supply = mint1 + mint2; assert_eq!(client.supply(), expected_supply); @@ -89,12 +91,12 @@ proptest! { t2 in 1..i128::MAX / 8, t3 in 1..i128::MAX / 8 ) { - let (env, client, _) = setup_test_env(); + let (env, client, admin) = setup_test_env(); let user_a = Address::generate(&env); let user_b = Address::generate(&env); let user_c = Address::generate(&env); - client.mint(&user_a, &initial_balance); + client.mint(&admin, &user_a, &initial_balance); // Simple sequence of transfers let amounts = [t1, t2, t3];