diff --git a/cli/src/index.ts b/cli/src/index.ts index ba8a77a..7bdc778 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -55,6 +55,7 @@ program .requiredOption('--decimals ', 'Decimal places', '7') .requiredOption('--name ', 'Token name') .requiredOption('--symbol ', 'Token symbol') + .option('--max-supply ', 'Optional maximum supply cap') .action(async (options) => { try { const secret = getSecretKey(); @@ -62,6 +63,7 @@ program const source = Keypair.fromSecret(secret); const client = new bcForgeClient(getClientConfig()); + const maxSupply = options.maxSupply ? BigInt(options.maxSupply) : undefined; console.log(chalk.yellow('Initializing contract...')); const result = await client.initialize( @@ -69,7 +71,8 @@ program parseInt(options.decimals), options.name, options.symbol, - source + source, + maxSupply, ); if (result.success) { diff --git a/contracts/token/src/events.rs b/contracts/token/src/events.rs index b400f9b..031f5cd 100644 --- a/contracts/token/src/events.rs +++ b/contracts/token/src/events.rs @@ -28,6 +28,11 @@ pub fn emit_mint( ); } +/// Emitted when the maximum mintable supply is configured or updated. +pub fn emit_max_supply_set(env: &Env, admin: &Address, max_supply: i128) { + env.events().publish((symbol_short!("max_supply_set"),), (admin.clone(), max_supply)); +} + /// Emitted when tokens are burned. pub fn emit_burn(env: &Env, from: &Address, amount: i128, new_balance: i128, new_supply: i128) { env.events().publish( diff --git a/contracts/token/src/lib.rs b/contracts/token/src/lib.rs index 5faad34..a8d1c9c 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -26,13 +26,12 @@ pub enum DataKey { /// Spending allowance: (owner, spender) → amount and expiration. Allowance(Address, Address), /// Token balance for an address. - Allowance(Address, Address), - AllowanceExp(Address, Address), Balance(Address), Name, Symbol, Decimals, Supply, + MaxSupply, ClawbackAdmin, Lockup(Address), ProposalAction(u64), @@ -79,6 +78,8 @@ pub enum TokenError { InsufficientBalance = 4, InsufficientAllowance = 5, ContractPaused = 6, + MaxSupplyExceeded = 7, + MaxSupplyTooLow = 8, } #[contract] @@ -138,30 +139,12 @@ impl BcForgeToken { .persistent() .get(&DataKey::Allowance(from.clone(), spender.clone())) .unwrap_or(AllowanceInfo { amount: 0, exp_ledger: 0 }); - - // Check if allowance has expired - if allowance_info.exp_ledger > 0 { - let current_ledger = env.ledger().sequence(); - if current_ledger > allowance_info.exp_ledger as u64 { - return 0; // Allowance expired - } - } - - allowance_info.amount - if let Some(exp_ledger) = env - .storage() - .persistent() - .get::<_, u32>(&DataKey::AllowanceExp(from.clone(), spender.clone())) - { - if exp_ledger > 0 && env.ledger().sequence() > exp_ledger { - return 0; - } - } - env.storage() - .persistent() - .get(&DataKey::Allowance(from.clone(), spender.clone())) - .unwrap_or(0) + if allowance_info.exp_ledger > 0 && env.ledger().sequence() > allowance_info.exp_ledger as u64 { + 0 + } else { + allowance_info.amount + } } fn write_allowance(env: &Env, from: &Address, spender: &Address, amount: i128, exp: u32) { @@ -177,10 +160,6 @@ impl BcForgeToken { .persistent() .get(&DataKey::Allowance(from.clone(), spender.clone())) .unwrap_or(AllowanceInfo { amount: 0, exp_ledger: 0 }) - .set(&DataKey::Allowance(from.clone(), spender.clone()), &amount); - env.storage() - .persistent() - .set(&DataKey::AllowanceExp(from.clone(), spender.clone()), &exp); } fn move_balance( @@ -213,6 +192,14 @@ impl BcForgeToken { env.storage().instance().set(&DataKey::Supply, &supply); } + fn read_max_supply(env: &Env) -> i128 { + env.storage().instance().get(&DataKey::MaxSupply).unwrap_or(0) + } + + fn write_max_supply(env: &Env, max_supply: i128) { + env.storage().instance().set(&DataKey::MaxSupply, &max_supply); + } + fn internal_mint( env: &Env, admin: &Address, @@ -223,12 +210,18 @@ impl BcForgeToken { return Err(TokenError::InvalidAmount); } + let current_supply = Self::read_supply(env); + let max_supply = Self::read_max_supply(env); + let new_supply = current_supply.checked_add(amount).ok_or(TokenError::InvalidAmount)?; + if max_supply > 0 && new_supply > max_supply { + return Err(TokenError::MaxSupplyExceeded); + } + let balance = Self::read_balance(env, to) + amount; Self::write_balance(env, to, balance); + Self::write_supply(env, new_supply); - let supply = Self::read_supply(env) + amount; - Self::write_supply(env, supply); - events::emit_mint(env, admin, to, amount, balance, supply); + events::emit_mint(env, admin, to, amount, balance, new_supply); Ok(()) } @@ -246,17 +239,26 @@ impl BcForgeToken { decimal: u32, name: String, symbol: String, + max_supply: i128, ) -> Result<(), TokenError> { if env.storage().instance().has(&DataKey::Admin) { return Err(TokenError::AlreadyInitialized); } + if max_supply < 0 { + return Err(TokenError::InvalidAmount); + } + Self::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); + Self::write_max_supply(&env, max_supply); events::emit_initialized(&env, &admin, decimal, &name, &symbol); + if max_supply > 0 { + events::emit_max_supply_set(&env, &admin, max_supply); + } Ok(()) } @@ -480,6 +482,41 @@ impl BcForgeToken { Ok(()) } + pub fn set_max_supply(env: Env, new_cap: i128) -> Result<(), TokenError> { + Self::ensure_initialized(&env)?; + let current_admin = Self::read_admin(&env)?; + current_admin.require_auth(); + + if new_cap < 0 { + return Err(TokenError::InvalidAmount); + } + + let current_supply = Self::read_supply(&env); + if new_cap > 0 && new_cap < current_supply { + return Err(TokenError::MaxSupplyTooLow); + } + + Self::write_max_supply(&env, new_cap); + events::emit_max_supply_set(&env, ¤t_admin, new_cap); + Ok(()) + } + + pub fn get_max_supply(env: Env) -> i128 { + Self::panic_on_err(&env, Self::ensure_initialized(&env)); + Self::read_max_supply(&env) + } + + pub fn remaining_mintable(env: Env) -> Option { + Self::panic_on_err(&env, Self::ensure_initialized(&env)); + let max_supply = Self::read_max_supply(&env); + if max_supply == 0 { + None + } else { + let supply = Self::read_supply(&env); + Some(if supply >= max_supply { 0 } else { max_supply - supply }) + } + } + pub fn propose_owner(env: Env, new_admin: Address) -> Result<(), TokenError> { let current_admin = Self::read_admin(&env)?; current_admin.require_auth(); diff --git a/contracts/token/src/proptest.rs b/contracts/token/src/proptest.rs index 4e92596..31b22b9 100644 --- a/contracts/token/src/proptest.rs +++ b/contracts/token/src/proptest.rs @@ -20,7 +20,7 @@ fn setup_test_env() -> (Env, BcForgeTokenClient<'static>, Address) { 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); + client.initialize(&admin, &7, &name, &symbol, &0); (env, client, admin) } diff --git a/contracts/token/src/test.rs b/contracts/token/src/test.rs index 1de36a0..fa090de 100644 --- a/contracts/token/src/test.rs +++ b/contracts/token/src/test.rs @@ -15,6 +15,7 @@ fn setup(env: &Env) -> (BcForgeTokenClient<'_>, Address) { &7, &String::from_str(env, "bc-forge Token"), &String::from_str(env, "SFG"), + &0, ); (client, admin) @@ -36,6 +37,25 @@ fn test_transfer() { assert_eq!(client.supply(), 1000); } +#[test] +fn test_max_supply_cap_prevents_over_mint() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup(&env); + let recipient = Address::generate(&env); + + client.set_max_supply(&1000); + assert_eq!(client.get_max_supply(), 1000); + assert_eq!(client.remaining_mintable(), Some(1000)); + + client.mint(&admin, &recipient, &800); + assert_eq!(client.remaining_mintable(), Some(200)); + assert_eq!(client.supply(), 800); + + assert_eq!(client.try_mint(&admin, &recipient, &300), Err(Ok(TokenError::MaxSupplyExceeded))); + assert_eq!(client.remaining_mintable(), Some(200)); +} + #[test] fn test_transfer_insufficient_balance_returns_error() { let env = Env::default(); diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 5afb91c..8deffce 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -140,6 +140,28 @@ export class bcForgeClient { return scValToNative(result) as string; } + /** + * Get the configured maximum supply cap. + * + * @returns Max supply as bigint, or null when no cap is configured. + */ + async getMaxSupply(): Promise { + const result = await this.queryContract('get_max_supply', []); + const native = scValToNative(result); + return native == null ? null : BigInt(native); + } + + /** + * Get the remaining mintable amount under the current supply cap. + * + * @returns Remaining mintable amount as bigint, or null when no cap is configured. + */ + async getRemainingMintable(): Promise { + const result = await this.queryContract('remaining_mintable', []); + const native = scValToNative(result); + return native == null ? null : BigInt(native); + } + // ─── Batch Queries ─────────────────────────────────────────────────────── /** @@ -187,10 +209,17 @@ export class bcForgeClient { name: string, symbol: string, source: Keypair, + maxSupply?: bigint, ): Promise { return this.invokeContract( 'initialize', - [addressToScVal(admin), u32ToScVal(decimals), stringToScVal(name), stringToScVal(symbol)], + [ + addressToScVal(admin), + u32ToScVal(decimals), + stringToScVal(name), + stringToScVal(symbol), + i128ToScVal(maxSupply ?? 0n), + ], source, ); } @@ -206,6 +235,16 @@ export class bcForgeClient { return this.invokeContract('mint', [addressToScVal(to), i128ToScVal(amount)], source); } + /** + * Set the maximum mintable supply. + * + * @param newCap - New supply ceiling, or 0 to remove the cap. + * @param source - Admin keypair + */ + async setMaxSupply(newCap: bigint, source: Keypair): Promise { + return this.invokeContract('set_max_supply', [i128ToScVal(newCap)], source); + } + /** * Batch mint tokens to multiple recipients. Admin-only. *