diff --git a/contracts/token/src/lib.rs b/contracts/token/src/lib.rs index a2e0010..c485b32 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -20,28 +20,7 @@ mod test; mod proptest; use soroban_sdk::token::TokenInterface; -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, String, -}; - -/// Errors returned by the token contract. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum TokenError { - /// The contract was initialized more than once. - AlreadyInitialized = 1, - /// The contract has not been initialized yet. - NotInitialized = 2, - /// The source account does not have enough tokens. - InsufficientBalance = 3, - /// The approved allowance is too small for the requested action. - InsufficientAllowance = 4, - /// The provided amount is invalid for this operation. - InvalidAmount = 5, - /// The contract is currently paused. - ContractPaused = 6, -} +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 bc_forge_admin::{self as admin, Role}; @@ -94,6 +73,14 @@ pub enum TokenAction { Unpause, } +/// Represents a mint recipient with address and amount. +#[derive(Clone)] +#[contracttype] +pub struct Recipient { + pub address: Address, + pub amount: i128, +} + // ───────────────────────────────────────────────────────────────────────────── // Contract Definition // ───────────────────────────────────────────────────────────────────────────── @@ -409,6 +396,52 @@ impl BcForgeToken { 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); + } + /// Transfers the admin role to a new address. Current admin-only. /// /// ⚠️ DEPRECATED: Use propose_owner() + accept_ownership() for safer two-step transfer. @@ -525,6 +558,61 @@ impl BcForgeToken { 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)); @@ -677,8 +765,6 @@ impl TokenInterface for BcForgeToken { 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); events::emit_transfer_from(&env, &spender, &from, &to, amount, allowance - amount); } diff --git a/contracts/token/src/test.rs b/contracts/token/src/test.rs index 927dfb7..e024e51 100644 --- a/contracts/token/src/test.rs +++ b/contracts/token/src/test.rs @@ -14,6 +14,7 @@ use soroban_sdk::testutils::Address as _; use soroban_sdk::{Address, Env, String}; +use crate::{BcForgeToken, BcForgeTokenClient, Recipient}; use crate::{BcForgeToken, BcForgeTokenClient, TokenError}; use crate::{BcForgeToken, BcForgeTokenClient}; use bc_forge_admin::Role; @@ -340,6 +341,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] @@ -599,3 +670,184 @@ fn test_version() { assert_eq!(client.version(), String::from_str(&env, "1.0.0")); } + +// ─── Batch Mint ────────────────────────────────────────────────────────────── + +#[test] +fn test_batch_mint_single_recipient() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _) = setup_contract(&env); + let _admin = init_default(&env, &client); + let recipient = Address::generate(&env); + + let recipients = vec![ + &env, + Recipient { + address: recipient.clone(), + amount: 1000, + }, + ]; + + client.batch_mint(&recipients); + + assert_eq!(client.balance(&recipient), 1000); + assert_eq!(client.supply(), 1000); +} + +#[test] +fn test_batch_mint_five_recipients() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _) = setup_contract(&env); + let _admin = init_default(&env, &client); + + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let r3 = Address::generate(&env); + let r4 = Address::generate(&env); + let r5 = Address::generate(&env); + + let recipients = vec![ + &env, + Recipient { address: r1.clone(), amount: 100 }, + Recipient { address: r2.clone(), amount: 200 }, + Recipient { address: r3.clone(), amount: 300 }, + Recipient { address: r4.clone(), amount: 400 }, + Recipient { address: r5.clone(), amount: 500 }, + ]; + + client.batch_mint(&recipients); + + assert_eq!(client.balance(&r1), 100); + assert_eq!(client.balance(&r2), 200); + assert_eq!(client.balance(&r3), 300); + assert_eq!(client.balance(&r4), 400); + assert_eq!(client.balance(&r5), 500); + assert_eq!(client.supply(), 1500); +} + +#[test] +fn test_batch_mint_ten_recipients() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _) = setup_contract(&env); + let _admin = init_default(&env, &client); + + let mut recipients_vec = Vec::new(&env); + let mut expected_total: i128 = 0; + + for i in 0..10 { + let recipient = Address::generate(&env); + let amount = (i + 1) as i128 * 100; + recipients_vec.push_back(Recipient { + address: recipient, + amount, + }); + expected_total += amount; + } + + client.batch_mint(&recipients_vec); + assert_eq!(client.supply(), expected_total); +} + +#[test] +#[should_panic(expected = "recipients list cannot be empty")] +fn test_batch_mint_empty_list_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _) = setup_contract(&env); + let _admin = init_default(&env, &client); + + let recipients: Vec = Vec::new(&env); + client.batch_mint(&recipients); +} + +#[test] +#[should_panic(expected = "mint amount must be positive for all recipients")] +fn test_batch_mint_with_zero_amount_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _) = setup_contract(&env); + let _admin = init_default(&env, &client); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + + let recipients = vec![ + &env, + Recipient { address: r1, amount: 100 }, + Recipient { address: r2, amount: 0 }, // Invalid: zero amount + ]; + + client.batch_mint(&recipients); +} + +#[test] +#[should_panic(expected = "mint amount must be positive for all recipients")] +fn test_batch_mint_with_negative_amount_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _) = setup_contract(&env); + let _admin = init_default(&env, &client); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + + let recipients = vec![ + &env, + Recipient { address: r1, amount: 100 }, + Recipient { address: r2, amount: -50 }, // Invalid: negative amount + ]; + + client.batch_mint(&recipients); +} + +#[test] +#[should_panic(expected = "contract is paused")] +fn test_batch_mint_while_paused_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _) = setup_contract(&env); + let _admin = init_default(&env, &client); + let recipient = Address::generate(&env); + + let recipients = vec![ + &env, + Recipient { + address: recipient, + amount: 100, + }, + ]; + + client.pause(); + client.batch_mint(&recipients); +} + +#[test] +fn test_batch_mint_atomic_supply_update() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _) = setup_contract(&env); + let _admin = init_default(&env, &client); + + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let r3 = Address::generate(&env); + + // Initial supply is 0 + assert_eq!(client.supply(), 0); + + let recipients = vec![ + &env, + Recipient { address: r1.clone(), amount: 100 }, + Recipient { address: r2.clone(), amount: 200 }, + Recipient { address: r3.clone(), amount: 300 }, + ]; + + client.batch_mint(&recipients); + + // Supply should be updated atomically + assert_eq!(client.supply(), 600); + assert_eq!(client.balance(&r1), 100); + assert_eq!(client.balance(&r2), 200); + assert_eq!(client.balance(&r3), 300); +} diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 3ed0eaa..401842c 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -201,6 +201,32 @@ export class bcForgeClient { return this.invokeContract('mint', [addressToScVal(to), i128ToScVal(amount)], source); } + /** + * Batch mint tokens to multiple recipients. Admin-only. + * + * @param recipients - Array of [address, amount] tuples + * @param source - Admin keypair + */ + async batchMint( + recipients: [string, bigint][], + source: Keypair + ): Promise { + // Convert recipients to the format expected by the contract + const recipientScVals = recipients.map(([address, amount]) => { + return nativeToScVal( + { + address: new Address(address).toScVal(), + amount: nativeToScVal(amount, { type: 'i128' }), + }, + { type: 'map' } + ); + }); + + const recipientsVec = nativeToScVal(recipientScVals, { type: 'vec' }); + + return this.invokeContract('batch_mint', [recipientsVec], source); + } + /** * Transfer tokens between addresses. *