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];