Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,24 @@ program
.requiredOption('--decimals <number>', 'Decimal places', '7')
.requiredOption('--name <string>', 'Token name')
.requiredOption('--symbol <string>', 'Token symbol')
.option('--max-supply <number>', 'Optional maximum supply cap')
.action(async (options) => {
try {
const secret = getSecretKey();
if (!secret) throw new Error('Secret key not configured. Use `bc-forge config set secretKey <key>`');

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(
options.admin,
parseInt(options.decimals),
options.name,
options.symbol,
source
source,
maxSupply,
);

if (result.success) {
Expand Down
5 changes: 5 additions & 0 deletions contracts/token/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
101 changes: 69 additions & 32 deletions contracts/token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -79,6 +78,8 @@ pub enum TokenError {
InsufficientBalance = 4,
InsufficientAllowance = 5,
ContractPaused = 6,
MaxSupplyExceeded = 7,
MaxSupplyTooLow = 8,
}

#[contract]
Expand Down Expand Up @@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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(())
}
Expand All @@ -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(())
}
Expand Down Expand Up @@ -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, &current_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<i128> {
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();
Expand Down
2 changes: 1 addition & 1 deletion contracts/token/src/proptest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
20 changes: 20 additions & 0 deletions contracts/token/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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();
Expand Down
41 changes: 40 additions & 1 deletion sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<bigint | null> {
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<bigint | null> {
const result = await this.queryContract('remaining_mintable', []);
const native = scValToNative(result);
return native == null ? null : BigInt(native);
}

// ─── Batch Queries ───────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -187,10 +209,17 @@ export class bcForgeClient {
name: string,
symbol: string,
source: Keypair,
maxSupply?: bigint,
): Promise<TransactionResult> {
return this.invokeContract(
'initialize',
[addressToScVal(admin), u32ToScVal(decimals), stringToScVal(name), stringToScVal(symbol)],
[
addressToScVal(admin),
u32ToScVal(decimals),
stringToScVal(name),
stringToScVal(symbol),
i128ToScVal(maxSupply ?? 0n),
],
source,
);
}
Expand All @@ -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<TransactionResult> {
return this.invokeContract('set_max_supply', [i128ToScVal(newCap)], source);
}

/**
* Batch mint tokens to multiple recipients. Admin-only.
*
Expand Down
Loading