diff --git a/cli/src/schema/bc-forge.schema.json b/cli/src/schema/bc-forge.schema.json index 27a77430..1b3ce539 100644 --- a/cli/src/schema/bc-forge.schema.json +++ b/cli/src/schema/bc-forge.schema.json @@ -29,6 +29,11 @@ "pattern": "^G[A-Z2-7]{55}$", "description": "Stellar G-address of the contract admin" }, + "pauser": { + "type": "string", + "pattern": "^G[A-Z2-7]{55}$", + "description": "Stellar G-address to receive the Pauser role (multisig)" + }, "network": { "type": "string", "enum": ["mainnet", "testnet", "futurenet", "standalone", "custom"], diff --git a/cli/src/utils/config-parser.ts b/cli/src/utils/config-parser.ts index e0a0dd3c..db40ada5 100644 --- a/cli/src/utils/config-parser.ts +++ b/cli/src/utils/config-parser.ts @@ -20,6 +20,7 @@ export interface BcForgeConfig { symbol: string; decimals?: number; admin?: string; + pauser?: string; network?: 'mainnet' | 'testnet' | 'futurenet' | 'standalone' | 'custom' | string; rpcUrl?: string; networkPassphrase?: string; diff --git a/contracts/token/src/lib.rs b/contracts/token/src/lib.rs index 8be794c3..371594f1 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -318,10 +318,12 @@ impl BcForgeToken { /// Initializes the token contract. /// /// Sets the admin address, decimals, name, and symbol. + /// Configures default rate limits for mint, transfer, transfer_from, burn, and burn_from operations. /// Emits the `init` event. Can only be called once. /// /// @notice Initializes the token contract with the given admin, decimals, name, and symbol. /// @dev This function can only be called once. Subsequent calls will revert with `AlreadyInitialized`. + /// Default rate limits are set to 1000 operations per 60-second window for each operation type. /// @param env The Soroban environment. /// @param admin_address The address to set as the contract admin. /// @param decimal The number of decimal places for the token. @@ -345,10 +347,55 @@ impl BcForgeToken { env.storage().instance().set(&DataKey::Symbol, &symbol); Self::write_supply(&env, 0); Self::write_max_supply(&env, i128::MAX); + + Self::set_default_rate_limits(&env); + events::emit_initialized(&env, &admin_address, decimal, &name, &symbol); Ok(()) } + /// Sets default rate limits for all operation types during initialization. + /// + /// Configures global rate limits with sensible defaults: + /// - 1000 operations per 60-second window for each operation type. + /// + /// @param env The Soroban environment. + fn set_default_rate_limits(env: &Env) { + let default_limit: u64 = 1000; + let default_window: u64 = 60; + + bc_forge_rate_limit::BcForgeRateLimit::internal_set_global_rate_limit( + env, + &soroban_sdk::String::from_str(env, crate::rate_limit::OPERATION_MINT), + default_limit, + default_window, + ); + bc_forge_rate_limit::BcForgeRateLimit::internal_set_global_rate_limit( + env, + &soroban_sdk::String::from_str(env, crate::rate_limit::OPERATION_TRANSFER), + default_limit, + default_window, + ); + bc_forge_rate_limit::BcForgeRateLimit::internal_set_global_rate_limit( + env, + &soroban_sdk::String::from_str(env, crate::rate_limit::OPERATION_TRANSFER_FROM), + default_limit, + default_window, + ); + bc_forge_rate_limit::BcForgeRateLimit::internal_set_global_rate_limit( + env, + &soroban_sdk::String::from_str(env, crate::rate_limit::OPERATION_BURN), + default_limit, + default_window, + ); + bc_forge_rate_limit::BcForgeRateLimit::internal_set_global_rate_limit( + env, + &soroban_sdk::String::from_str(env, crate::rate_limit::OPERATION_BURN_FROM), + default_limit, + default_window, + ); + } + /// Returns the admin address. /// /// @notice Returns the address of the contract admin. diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 923eaaef..576696bf 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -60,6 +60,26 @@ export interface BatchMintRecipient { amount: bigint; } +/** Result of on-chain state verification after initialization */ +export interface InitVerificationResult { + /** Whether all checks passed */ + valid: boolean; + /** Admin address from contract */ + admin?: string; + /** Token name from contract */ + name?: string; + /** Token symbol from contract */ + symbol?: string; + /** Token decimals from contract */ + decimals?: number; + /** Total token supply */ + totalSupply?: bigint; + /** Whether the Pauser role was granted to the expected address */ + pauserGranted?: boolean; + /** List of verification errors (empty if valid) */ + errors: string[]; +} + /** Role for role-based access control */ export enum Role { Admin = 'Admin', @@ -171,6 +191,94 @@ export class bcForgeClient { return scValToNative(result) as string; } + // ─── Initialization Verification ────────────────────────────────────────── + + /** + * Verify the on-chain state matches expected values after initialization. + * + * Queries the contract for its current state and compares against the + * expected values provided during initialization. + * + * @param expectedAdmin - The expected admin address + * @param expectedName - The expected token name + * @param expectedSymbol - The expected token symbol + * @param expectedDecimals - The expected number of decimals + * @param expectedPauser - Optional pauser address to verify role grant + * @returns Verification result with any mismatches + */ + async verifyInitializedState( + expectedAdmin: string, + expectedName: string, + expectedSymbol: string, + expectedDecimals: number, + expectedPauser?: string, + ): Promise { + const errors: string[] = []; + const result: InitVerificationResult = { valid: false, errors }; + + try { + const onChainAdmin = await this.getAdmin(); + result.admin = onChainAdmin; + if (onChainAdmin !== expectedAdmin) { + errors.push(`Admin mismatch: expected ${expectedAdmin}, got ${onChainAdmin}`); + } + } catch (err: any) { + errors.push(`Failed to query admin: ${err.message}`); + } + + try { + const onChainName = await this.getName(); + result.name = onChainName; + if (onChainName !== expectedName) { + errors.push(`Name mismatch: expected "${expectedName}", got "${onChainName}"`); + } + } catch (err: any) { + errors.push(`Failed to query name: ${err.message}`); + } + + try { + const onChainSymbol = await this.getSymbol(); + result.symbol = onChainSymbol; + if (onChainSymbol !== expectedSymbol) { + errors.push(`Symbol mismatch: expected "${expectedSymbol}", got "${onChainSymbol}"`); + } + } catch (err: any) { + errors.push(`Failed to query symbol: ${err.message}`); + } + + try { + const onChainDecimals = await this.getDecimals(); + result.decimals = onChainDecimals; + if (onChainDecimals !== expectedDecimals) { + errors.push(`Decimals mismatch: expected ${expectedDecimals}, got ${onChainDecimals}`); + } + } catch (err: any) { + errors.push(`Failed to query decimals: ${err.message}`); + } + + try { + const totalSupply = await this.getTotalSupply(); + result.totalSupply = totalSupply; + } catch (err: any) { + errors.push(`Failed to query total supply: ${err.message}`); + } + + if (expectedPauser) { + try { + const hasPauserRole = await this.hasRole(Role.Pauser, expectedPauser); + result.pauserGranted = hasPauserRole; + if (!hasPauserRole) { + errors.push(`Pauser role not granted to ${expectedPauser}`); + } + } catch (err: any) { + errors.push(`Failed to check Pauser role: ${err.message}`); + } + } + + result.valid = errors.length === 0; + return result; + } + // ─── Batch Queries ─────────────────────────────────────────────────────── /** @@ -848,6 +956,59 @@ export class bcForgeClient { ); } + /** + * Grant the Pauser role to an address. Admin-only. + * + * @param address - Address to grant the Pauser role to + * @param source - Admin keypair + */ + async grantPauser(address: string, source: Keypair): Promise { + return this.invokeContract( + 'grant_role', + [addressToScVal(source.publicKey()), nativeToScVal(Role.Pauser), addressToScVal(address)], + source, + ); + } + + /** + * Revoke the Pauser role from an address. Admin-only. + * + * @param address - Address to revoke the Pauser role from + * @param source - Admin keypair + */ + async revokePauser(address: string, source: Keypair): Promise { + return this.invokeContract( + 'revoke_role', + [addressToScVal(source.publicKey()), nativeToScVal(Role.Pauser), addressToScVal(address)], + source, + ); + } + + /** + * Get the contract admin address. + * + * @returns The admin address + */ + async getAdmin(): Promise { + const result = await this.queryContract('admin', []); + return scValToNative(result) as string; + } + + /** + * Check if an address holds a specific role. + * + * @param role - The role to check for + * @param address - The address to check + * @returns Whether the address holds the role + */ + async hasRole(role: Role, address: string): Promise { + const result = await this.queryContract('has_role', [ + nativeToScVal(role), + addressToScVal(address), + ]); + return scValToNative(result) as boolean; + } + // ─── Clawback / Regulatory ─────────────────────────────────────────────── /** diff --git a/sdk/src/index.ts b/sdk/src/index.ts index bd70cb9f..968490cf 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -19,7 +19,7 @@ */ export { bcForgeClient, Role } from './client'; -export type { BatchMintRecipient, bcForgeClientConfig, TransactionResult } from './client'; +export type { BatchMintRecipient, bcForgeClientConfig, TransactionResult, InitVerificationResult } from './client'; export { buildInvokeTransaction, submitTransaction, scValToNative } from './utils'; export { bcForgeEventType, decodeEvent, decodeDiagnosticEvent, subscribeEvents } from './events'; export type { bcForgeEvent, SubscriptionOptions } from './events';