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: 5 additions & 0 deletions cli/src/schema/bc-forge.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
1 change: 1 addition & 0 deletions cli/src/utils/config-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions contracts/token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
161 changes: 161 additions & 0 deletions sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<InitVerificationResult> {
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 ───────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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<TransactionResult> {
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<TransactionResult> {
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<string> {
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<boolean> {
const result = await this.queryContract('has_role', [
nativeToScVal(role),
addressToScVal(address),
]);
return scValToNative(result) as boolean;
}

// ─── Clawback / Regulatory ───────────────────────────────────────────────

/**
Expand Down
2 changes: 1 addition & 1 deletion sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading