From 2e7874e403fb9f8bea6ca820b72fd98600740204 Mon Sep 17 00:00:00 2001 From: BCForge Developer Date: Fri, 24 Apr 2026 09:15:49 -0400 Subject: [PATCH] feat(token): add batch_mint() function for multi-recipient minting - Add Recipient struct for type-safe batch operations - Implement batch_mint() function with admin-only access - Validate contract is not paused before batch mint - Validate all recipient amounts are positive (atomic revert) - Emit individual mint events per recipient for indexing - Update total supply atomically once at end of batch - Add SDK batchMint() method with [address, amount][] interface - Add 8 comprehensive tests: * Single recipient mint * Five recipients mint * Ten recipients mint * Empty list fails * Zero amount causes full revert * Negative amount causes full revert * Paused state prevents batch mint * Atomic supply update verification - Optimized for gas efficiency on airdrops and initial distributions Closes #12 --- contracts/token/src/lib.rs | 56 ++++++++++- contracts/token/src/test.rs | 183 +++++++++++++++++++++++++++++++++++- sdk/src/client.ts | 26 +++++ 3 files changed, 263 insertions(+), 2 deletions(-) diff --git a/contracts/token/src/lib.rs b/contracts/token/src/lib.rs index 10f6385..875da94 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -18,7 +18,7 @@ mod events; mod test; use soroban_sdk::token::TokenInterface; -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String}; +use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec}; /// Storage keys for the token contract state. #[derive(Clone)] @@ -44,6 +44,14 @@ pub enum DataKey { Supply, } +/// Represents a mint recipient with address and amount. +#[derive(Clone)] +#[contracttype] +pub struct Recipient { + pub address: Address, + pub amount: i128, +} + // ───────────────────────────────────────────────────────────────────────────── // Contract Definition // ───────────────────────────────────────────────────────────────────────────── @@ -208,6 +216,52 @@ impl BcForgeToken { events::emit_mint(&env, &admin, &to, amount, balance, supply); } + /// 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. diff --git a/contracts/token/src/test.rs b/contracts/token/src/test.rs index 9dec98f..e4386f8 100644 --- a/contracts/token/src/test.rs +++ b/contracts/token/src/test.rs @@ -14,7 +14,7 @@ use soroban_sdk::testutils::Address as _; use soroban_sdk::{Address, Env, String}; -use crate::{BcForgeToken, BcForgeTokenClient}; +use crate::{BcForgeToken, BcForgeTokenClient, Recipient}; /// Helper: register the contract and return a client. fn setup_contract(env: &Env) -> (BcForgeTokenClient<'_>, Address) { @@ -463,3 +463,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 f4f825c..43d3066 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -199,6 +199,32 @@ export class bcForgeClient { ], 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. *