diff --git a/contracts/admin/src/lib.rs b/contracts/admin/src/lib.rs index 823cfc92..0fe502ed 100644 --- a/contracts/admin/src/lib.rs +++ b/contracts/admin/src/lib.rs @@ -687,13 +687,74 @@ pub struct UpgradeProposal { /// whose 32-byte payload is all zeros. No private key can ever produce a /// signature for it, so it is used as the canonical zero-address sentinel /// that must never be allowed to hold a role. -const ZERO_ADDRESS_STRKEY: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; +/// +/// @title ZERO_ADDRESS_STRKEY +/// @notice The Stellar zero address constant ("GAAAA...WHF") used for zero-address validation. +/// @dev This is the canonical zero-address sentinel; no private key can ever produce a signature for it. +pub const ZERO_ADDRESS_STRKEY: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; -fn is_zero_address(env: &Env, address: &Address) -> bool { +/// Returns `true` if `address` is the canonical zero-address sentinel. +/// +/// The zero address ("GAAAA…WHF") is an ed25519 public key whose 32-byte +/// payload is all zeros. No private key can ever produce a signature for it, +/// so holding a role there would be unrecoverable. This helper is used +/// throughout the admin module to reject zero addresses before any storage +/// writes. +/// +/// # Arguments +/// +/// * `env` - The Soroban environment +/// * `address` - The address to check +/// +/// # Returns +/// +/// `true` if `address` equals the zero-address sentinel, `false` otherwise. +/// +/// # Examples +/// +/// ```rust,ignore +/// // Check if an address is the zero address +/// if is_zero_address(env, &some_address) { +/// // Reject the address +/// } +/// ``` +/// +/// @notice Checks whether `address` is the canonical zero-address sentinel. +/// @dev Compares `address` against `Address::from_str(env, ZERO_ADDRESS_STRKEY). +/// @param env The Soroban environment. +/// @param address The address to check. +/// @return `true` if `address` is the zero address, `false` otherwise. +pub fn is_zero_address(env: &Env, address: &Address) -> bool { *address == Address::from_str(env, ZERO_ADDRESS_STRKEY) } -fn require_non_zero_address(env: &Env, address: &Address) { +/// Requires that `address` is not the zero-address sentinel. +/// +/// Panics with [`AdminError::InvalidAddress`] if `address` equals the +/// canonical zero address ("GAAAA…WHF"). Use this guard before any storage +/// write that associates an address with a role or administrative privilege. +/// +/// # Arguments +/// +/// * `env` - The Soroban environment +/// * `address` - The address to validate +/// +/// # Panics +/// +/// Panics with [`AdminError::InvalidAddress`] if `address` is the zero address. +/// +/// # Examples +/// +/// ```rust,ignore +/// // Reject zero address before granting a role +/// require_non_zero_address(env, &address); +/// ``` +/// +/// @notice Reverts if `address` is the canonical zero-address sentinel. +/// @dev Panics with `AdminError::InvalidAddress` when `address` is the zero address. +/// @param env The Soroban environment. +/// @param address The address to validate. +pub fn require_non_zero_address(env: &Env, address: &Address) { if is_zero_address(env, address) { soroban_sdk::panic_with_error!(env, AdminError::InvalidAddress); } @@ -2208,6 +2269,14 @@ mod tests { super::require_pauser(&env, &address); } + pub fn is_zero_address(env: Env, address: Address) -> bool { + super::is_zero_address(&env, &address) + } + + pub fn require_non_zero_address(env: Env, address: Address) { + super::require_non_zero_address(&env, &address); + } + pub fn require_deployer(env: Env) { super::require_deployer(&env); } diff --git a/docs/ACCESS_CONTROL.md b/docs/ACCESS_CONTROL.md index dccf903a..3f4c2930 100644 --- a/docs/ACCESS_CONTROL.md +++ b/docs/ACCESS_CONTROL.md @@ -254,7 +254,8 @@ contract's `Role` enum. - **Admin is a superset.** Any address holding `Admin` passes every role check. - **Zero-address rejection.** `GAAAA…WHF` can never hold a role; all guards - reject it before storage writes. + reject it before storage writes. Use [`is_zero_address`] and + [`require_non_zero_address`] for validation in consuming contracts. - **Storage slot isolation.** Each `AdminKey` variant uses a unique enum discriminant. Domain separation (`instance` vs `persistent`) provides an additional layer. @@ -263,6 +264,51 @@ contract's `Role` enum. - **Idempotent proposals.** Duplicate approvals and double-execution are rejected at the contract level. +## Zero-address validation helpers + +The admin module exports two public helpers for zero-address validation: + +| Function | Signature | Description | +| --- | --- | --- | +| `is_zero_address` | `pub fn is_zero_address(env: &Env, address: &Address) -> bool` | Returns `true` if `address` is the zero-address sentinel | +| `require_non_zero_address` | `pub fn require_non_zero_address(env: &Env, address: &Address)` | Panics with `InvalidAddress` if `address` is the zero address | +| `ZERO_ADDRESS_STRKEY` | `pub const ZERO_ADDRESS_STRKEY: &str` | The Stellar zero address constant | + +These are used throughout the admin module in: +- `set_admin` — rejects zero address before storing +- `grant_role` — rejects zero address before role assignment +- `_grant_role` — rejects zero address before storage write +- `revoke_role` — rejects zero address before role removal +- `_revoke_role` — rejects zero address before storage mutation +- `has_role` — short-circuits to `false` for zero address +- `set_admin_pool` — rejects zero addresses in the pool + +### Usage in consuming contracts + +```rust,ignore +use bc_forge_admin::{is_zero_address, require_non_zero_address}; + +// Check without panicking +if is_zero_address(env, &some_address) { + // Handle the zero address case +} + +// Guard before a storage write +require_non_zero_address(env, &new_address); +``` + +### TypeScript SDK + +The SDK exports a client-side `isZeroAddress` helper: + +```typescript +import { isZeroAddress, ZERO_ADDRESS } from '@bc-forge/sdk'; + +if (isZeroAddress(someAddress)) { + throw new Error('Invalid address: zero address is not allowed'); +} +``` + ## Source of truth - Role definitions and guards: diff --git a/docs/UPGRADE_GUIDE.md b/docs/UPGRADE_GUIDE.md index 94338fb3..5c2a496f 100644 --- a/docs/UPGRADE_GUIDE.md +++ b/docs/UPGRADE_GUIDE.md @@ -255,6 +255,8 @@ If you have an existing contract that was deployed before the `SuperAdmin` role was introduced, use `migrate_admin` to enable `SuperAdmin`-based guards without resetting state. +### Option 1: CLI + ```bash stellar contract invoke \ --id \ @@ -263,8 +265,55 @@ stellar contract invoke \ migrate_admin ``` +### Option 2: TypeScript SDK + +```typescript +import { bcForgeClient } from '@bc-forge/sdk'; +import { Keypair } from '@stellar/stellar-sdk'; + +const client = new bcForgeClient({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + contractId: '', +}); + +const adminKeypair = Keypair.fromSecret(process.env.ADMIN_SECRET!); +const result = await client.migrateAdmin(adminKeypair); +console.log('Migration TX:', result.hash); +``` + +### Option 3: Standalone migration script + +A standalone migration script is available at `migrations/rbac-migration.ts`. +It provides a complete migration workflow with verification: + +```bash +# Dry-run (simulate without submitting) +npx ts-node migrations/rbac-migration.ts \ + --rpc-url https://soroban-testnet.stellar.org \ + --network-passphrase "Test SDF Network ; September 2015" \ + --contract-id \ + --admin-secret \ + --dry-run + +# Execute migration +npx ts-node migrations/rbac-migration.ts \ + --rpc-url https://soroban-testnet.stellar.org \ + --network-passphrase "Test SDF Network ; September 2015" \ + --contract-id \ + --admin-secret +``` + +The script performs the following steps: +1. Verifies the contract has an admin set +2. Checks if migration is already complete (idempotent) +3. Executes the migration transaction +4. Verifies the admin now has the SuperAdmin role + +### Storage migration process + This is a one-shot, idempotent operation: -- Reads the current admin from instance storage. +- Reads the current admin from instance storage (`AdminKey::Admin`). - Creates a persistent `SuperAdmin(admin)` entry. - Safe to call multiple times (no-op on subsequent calls). diff --git a/migrations/rbac-migration.ts b/migrations/rbac-migration.ts new file mode 100644 index 00000000..3a50ec96 --- /dev/null +++ b/migrations/rbac-migration.ts @@ -0,0 +1,561 @@ +/** + * @bc-forge/rbac-migration — RBAC Storage Migration Script + * + * Migrates legacy admin contracts from the singular AdminKey::Admin state + * to the new RBAC format with AdminKey::SuperAdmin mapping. + * + * This script is designed to be run via the CLI or imported as a library: + * + * ```bash + * # Via CLI (interactive) + * npx ts-node migrations/rbac-migration.ts \ + * --rpc-url https://soroban-testnet.stellar.org \ + * --network-passphrase "Test SDF Network ; September 2015" \ + * --contract-id \ + * --admin-secret + * + * # Or as a library + * import { migrateContract } from '@bc-forge/rbac-migration'; + * const result = await migrateContract(config); + * ``` + * + * ## What it does + * + * 1. **Reads** the current admin address from instance storage (`AdminKey::Admin`) + * 2. **Creates** a new persistent storage entry mapping that address to `true` + * under `AdminKey::SuperAdmin(address)` + * 3. **Extends** the TTL of the new SuperAdmin storage entry + * 4. **Verifies** the migration was successful by querying `has_role(SuperAdmin, admin)` + * + * ## Safety + * + * - The migration is **idempotent**: calling it multiple times is a no-op + * - The original admin entry in instance storage remains unchanged + * - No tokens, balances, or other state is modified + * - The migration only adds a new storage entry; it never removes existing entries + * + * ## Storage Layout Changes + * + * Before migration: + * - `AdminKey::Admin` (instance) → `Address` + * + * After migration: + * - `AdminKey::Admin` (instance) → `Address` (unchanged) + * - `AdminKey::SuperAdmin(address)` (persistent) → `true` (new entry) + * + * @module migrations/rbac-migration + */ + +// Node.js globals +declare const console: Console; +declare const process: { + argv: string[]; + exit(code?: number): never; +}; + +// Suppress unused variable warnings for the CLI part +void console; +void process; + +import { + rpc as SorobanRpc, + Contract, + TransactionBuilder, + Keypair, + nativeToScVal, + scValToNative, + Address, +} from '@stellar/stellar-sdk'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface MigrationConfig { + /** Soroban RPC endpoint URL */ + rpcUrl: string; + /** Stellar network passphrase */ + networkPassphrase: string; + /** Deployed contract ID to migrate */ + contractId: string; + /** Admin keypair for signing the migration transaction */ + adminKeypair: Keypair; +} + +export interface MigrationResult { + /** Whether the migration was successful */ + success: boolean; + /** Transaction hash */ + hash?: string; + /** Error message if migration failed */ + error?: string; + /** Whether migration was a no-op (already migrated) */ + alreadyMigrated?: boolean; + /** Admin address that was migrated */ + adminAddress?: string; +} + +// ─── Constants ─────────────────────────────────────────────────────────────── + +/** The well-known Stellar zero address sentinel */ +const ZERO_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + +/** Maximum number of retries for RPC calls */ +const MAX_RETRIES = 3; + +/** Delay between retries in milliseconds */ +const RETRY_DELAY_MS = 1000; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Sleep for the specified duration. + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Retry an async operation with exponential backoff. + */ +async function withRetry( + fn: () => Promise, + retries: number = MAX_RETRIES, +): Promise { + let lastError: unknown; + for (let i = 0; i < retries; i++) { + try { + return await fn(); + } catch (error) { + lastError = error; + if (i < retries - 1) { + await sleep(RETRY_DELAY_MS * (i + 1)); + } + } + } + throw lastError; +} + +// ─── Migration Functions ───────────────────────────────────────────────────── + +/** + * Check if the contract has an admin set. + * + * @param server - Soroban RPC server instance + * @param networkPassphrase - Network passphrase + * @param contractId - Contract ID to check + * @returns Whether an admin is set + */ +async function hasAdmin( + server: SorobanRpc.Server, + networkPassphrase: string, + contractId: string, +): Promise { + const contract = new Contract(contractId); + + // Create a dummy account for the simulation (zero address) + const account = new (await import('@stellar/stellar-sdk')).Account( + ZERO_ADDRESS, + '0', + ); + + const tx = new TransactionBuilder(account, { + fee: '100', + networkPassphrase, + }) + .addOperation(contract.call('has_admin')) + .setTimeout(30) + .build(); + + const simulated = await server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simulated)) { + throw new Error(`Simulation failed: ${simulated.error}`); + } + + if (!SorobanRpc.Api.isSimulationSuccess(simulated) || !simulated.result) { + throw new Error('has_admin query returned no result'); + } + + return scValToNative(simulated.result.retval) as boolean; +} + +/** + * Check if an address holds a specific role. + * + * @param server - Soroban RPC server instance + * @param networkPassphrase - Network passphrase + * @param contractId - Contract ID to check + * @param role - Role to check (e.g., 'SuperAdmin') + * @param address - Address to check + * @returns Whether the address holds the role + */ +async function hasRole( + server: SorobanRpc.Server, + networkPassphrase: string, + contractId: string, + role: string, + address: string, +): Promise { + const contract = new Contract(contractId); + + const roleScVal = nativeToScVal(role, { type: 'symbol' }); + const addressScVal = new Address(address).toScVal(); + + // Create a dummy account for the simulation (zero address) + const account = new (await import('@stellar/stellar-sdk')).Account( + ZERO_ADDRESS, + '0', + ); + + const tx = new TransactionBuilder(account, { + fee: '100', + networkPassphrase, + }) + .addOperation(contract.call('has_role', roleScVal, addressScVal)) + .setTimeout(30) + .build(); + + const simulated = await server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simulated)) { + throw new Error(`Simulation failed: ${simulated.error}`); + } + + if (!SorobanRpc.Api.isSimulationSuccess(simulated) || !simulated.result) { + throw new Error('has_role query returned no result'); + } + + return scValToNative(simulated.result.retval) as boolean; +} + +/** + * Execute the RBAC storage migration on a deployed contract. + * + * This function copies the singular admin address from `AdminKey::Admin` + * to `AdminKey::SuperAdmin(admin)`, enabling the `require_super_admin` + * guard for legacy contracts without resetting state. + * + * @param config - Migration configuration + * @returns Migration result with status and transaction details + * + * @example + * ```typescript + * import { migrateContract } from '@bc-forge/rbac-migration'; + * import { Keypair } from '@stellar/stellar-sdk'; + * + * const result = await migrateContract({ + * rpcUrl: 'https://soroban-testnet.stellar.org', + * networkPassphrase: 'Test SDF Network ; September 2015', + * contractId: 'CASB...XXXX', + * adminKeypair: Keypair.fromSecret(process.env.ADMIN_SECRET!), + * }); + * + * console.log('Migration result:', result); + * ``` + */ +export async function migrateContract( + config: MigrationConfig, +): Promise { + const { rpcUrl, networkPassphrase, contractId, adminKeypair } = config; + + const server = new SorobanRpc.Server(rpcUrl); + const contract = new Contract(contractId); + + try { + // Step 1: Check if contract has an admin + const hasAdminResult = await withRetry(() => + hasAdmin(server, networkPassphrase, contractId), + ); + + if (!hasAdminResult) { + return { + success: false, + error: 'Contract has no admin set. Migration requires an initialized contract.', + }; + } + + // Step 2: Check if already migrated (admin already has SuperAdmin role) + const adminAddress = adminKeypair.publicKey(); + const alreadyHasSuperAdmin = await withRetry(() => + hasRole( + server, + networkPassphrase, + contractId, + 'SuperAdmin', + adminAddress, + ), + ); + + if (alreadyHasSuperAdmin) { + return { + success: true, + alreadyMigrated: true, + adminAddress, + }; + } + + // Step 3: Build and sign the migration transaction + const sourceAccount = await server.getAccount(adminAddress); + + const tx = new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase, + }) + .addOperation(contract.call('migrate_admin')) + .setTimeout(60) + .build(); + + // Step 4: Simulate to get the assembled transaction + const simulated = await server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simulated)) { + return { + success: false, + error: `Simulation failed: ${simulated.error}`, + }; + } + + // Step 5: Sign the transaction + const assembled = SorobanRpc.assembleTransaction(tx, simulated).build(); + assembled.sign(adminKeypair); + + // Step 6: Submit the transaction + const sendResponse = await server.sendTransaction(assembled); + + if (sendResponse.status === 'ERROR') { + return { + success: false, + error: `Transaction submission failed: ${JSON.stringify(sendResponse.errorResult)}`, + }; + } + + // Step 7: Poll for transaction completion + let getResponse: SorobanRpc.Api.GetTransactionResponse; + let attempts = 0; + const maxAttempts = 30; + + do { + await sleep(1000); + getResponse = await server.getTransaction(sendResponse.hash); + attempts++; + } while ( + getResponse.status === + SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && + attempts < maxAttempts + ); + + if ( + getResponse.status === + SorobanRpc.Api.GetTransactionStatus.NOT_FOUND + ) { + return { + success: false, + hash: sendResponse.hash, + error: 'Transaction not found after maximum polling attempts', + }; + } + + if (getResponse.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + // Step 8: Verify migration was successful + const verifyHasSuperAdmin = await withRetry(() => + hasRole( + server, + networkPassphrase, + contractId, + 'SuperAdmin', + adminAddress, + ), + ); + + if (!verifyHasSuperAdmin) { + return { + success: false, + hash: sendResponse.hash, + error: 'Migration transaction succeeded but verification failed: admin does not have SuperAdmin role', + }; + } + + return { + success: true, + hash: sendResponse.hash, + adminAddress, + }; + } + + return { + success: false, + hash: sendResponse.hash, + error: `Transaction failed with status: ${getResponse.status}`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Dry-run the migration to check if it would succeed without submitting. + * + * @param config - Migration configuration + * @returns Migration result with status (no transaction submitted) + */ +export async function dryRunMigration( + config: MigrationConfig, +): Promise { + const { rpcUrl, networkPassphrase, contractId, adminKeypair } = config; + + const server = new SorobanRpc.Server(rpcUrl); + const adminAddress = adminKeypair.publicKey(); + + try { + // Check if contract has an admin + const hasAdminResult = await withRetry(() => + hasAdmin(server, networkPassphrase, contractId), + ); + + if (!hasAdminResult) { + return { + success: false, + error: 'Contract has no admin set. Migration requires an initialized contract.', + }; + } + + // Check if already migrated + const alreadyHasSuperAdmin = await withRetry(() => + hasRole( + server, + networkPassphrase, + contractId, + 'SuperAdmin', + adminAddress, + ), + ); + + if (alreadyHasSuperAdmin) { + return { + success: true, + alreadyMigrated: true, + adminAddress, + }; + } + + // Simulate the migration transaction + const contract = new Contract(contractId); + const sourceAccount = await server.getAccount(adminAddress); + + const tx = new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase, + }) + .addOperation(contract.call('migrate_admin')) + .setTimeout(60) + .build(); + + const simulated = await server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simulated)) { + return { + success: false, + error: `Dry-run simulation failed: ${simulated.error}`, + }; + } + + return { + success: true, + alreadyMigrated: false, + adminAddress, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +// ─── CLI Entry Point ───────────────────────────────────────────────────────── + +/** + * Parse CLI arguments and run the migration. + */ +async function main() { + const args = process.argv.slice(2); + + const getArg = (name: string): string | undefined => { + const idx = args.indexOf(`--${name}`); + return idx !== -1 ? args[idx + 1] : undefined; + }; + + const rpcUrl = getArg('rpc-url') ?? ''; + const networkPassphrase = getArg('network-passphrase') ?? ''; + const contractId = getArg('contract-id') ?? ''; + const adminSecret = getArg('admin-secret') ?? ''; + const dryRun = args.includes('--dry-run'); + + if (!rpcUrl || !networkPassphrase || !contractId || !adminSecret) { + console.error(` +Usage: npx ts-node migrations/rbac-migration.ts [options] + +Options: + --rpc-url Soroban RPC endpoint URL (required) + --network-passphrase Stellar network passphrase (required) + --contract-id Deployed contract ID (required) + --admin-secret Admin secret key for signing (required) + --dry-run Simulate migration without submitting + --help Show this help message + `); + process.exit(1); + } + + const adminKeypair = Keypair.fromSecret(adminSecret); + + const config: MigrationConfig = { + rpcUrl, + networkPassphrase, + contractId, + adminKeypair, + }; + + console.log('RBAC Storage Migration'); + console.log('======================'); + console.log(`Contract ID: ${contractId}`); + console.log(`Admin: ${adminKeypair.publicKey()}`); + console.log(`Network: ${networkPassphrase}`); + console.log(`Dry run: ${dryRun}`); + console.log(''); + + let result: MigrationResult; + + if (dryRun) { + console.log('Running dry-run (no transaction submitted)...'); + result = await dryRunMigration(config); + } else { + console.log('Executing migration...'); + result = await migrateContract(config); + } + + console.log(''); + console.log('Result:'); + console.log(JSON.stringify(result, null, 2)); + + if (result.success) { + if (result.alreadyMigrated) { + console.log('\n✓ Contract is already migrated. No action needed.'); + } else { + console.log(`\n✓ Migration successful! TX: ${result.hash}`); + } + } else { + console.error(`\n✗ Migration failed: ${result.error}`); + process.exit(1); + } +} + +// Run if executed directly +if (require.main === module) { + main().catch((error) => { + console.error('Unexpected error:', error); + process.exit(1); + }); +} diff --git a/sdk/src/client.test.ts b/sdk/src/client.test.ts index ec953e74..91bd50c5 100644 --- a/sdk/src/client.test.ts +++ b/sdk/src/client.test.ts @@ -3,7 +3,7 @@ */ import { jest } from '@jest/globals'; -import { bcForgeClient, Role } from './client'; +import { bcForgeClient, Role, ZERO_ADDRESS, isZeroAddress } from './client'; import { Keypair, Networks, xdr } from '@stellar/stellar-sdk'; // Mock data for testing @@ -156,6 +156,45 @@ describe('bcForgeClient Offline Transaction Builders', () => { }); }); + describe('migrateAdmin', () => { + it('should have migrateAdmin method', () => { + expect(typeof client.migrateAdmin).toBe('function'); + expect(client.migrateAdmin.length).toBe(1); // 1 optional parameter + }); + + it('should invoke migrate_admin with no arguments', async () => { + const invokeContract = jest.fn( + async (_method: string, _args: unknown[], _source: Keypair) => ({ + success: true, + hash: 'mock-migration-hash', + returnValue: null, + }), + ); + (client as unknown as { invokeContract: typeof invokeContract }).invokeContract = + invokeContract; + + await client.migrateAdmin(adminKeypair); + + expect(invokeContract).toHaveBeenCalledTimes(1); + const [method, args, source] = invokeContract.mock.calls[0] as [string, unknown[], Keypair]; + expect(method).toBe('migrate_admin'); + expect(args).toHaveLength(0); // No arguments for migrate_admin + expect(source).toBe(adminKeypair); + }); + }); + + describe('RBAC role management', () => { + it('should have grantMinter method', () => { + expect(typeof client.grantMinter).toBe('function'); + expect(client.grantMinter.length).toBe(2); // 2 parameters + }); + + it('should have revokeMinter method', () => { + expect(typeof client.revokeMinter).toBe('function'); + expect(client.revokeMinter.length).toBe(2); // 2 parameters + }); + }); + describe('RBAC and Contract Connection Methods', () => { it('should invoke grantRole with correct parameters', async () => { const targetUser = Keypair.random().publicKey(); @@ -246,3 +285,26 @@ describe('bcForgeClient Offline Transaction Builders', () => { }); }); }); + +describe('isZeroAddress', () => { + it('should return true for the zero address', () => { + expect(isZeroAddress(ZERO_ADDRESS)).toBe(true); + }); + + it('should return false for a valid address', () => { + const validAddress = Keypair.random().publicKey(); + expect(isZeroAddress(validAddress)).toBe(false); + }); + + it('should return false for an empty string', () => { + expect(isZeroAddress('')).toBe(false); + }); + + it('should return false for a different address', () => { + expect(isZeroAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toBe(false); + }); + + it('should export the ZERO_ADDRESS constant', () => { + expect(ZERO_ADDRESS).toBe('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'); + }); +}); diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 51aa821e..56466d24 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -5,6 +5,35 @@ * token contracts on the Stellar/Soroban network. */ +/** + * The canonical zero-address sentinel: an ed25519 public key whose 32-byte + * payload is all zeros. No private key can ever produce a signature for it. + * This constant is used for zero-address validation across the SDK. + */ +export const ZERO_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + +/** + * Returns `true` if the given address is the canonical zero-address sentinel. + * + * The zero address ("GAAAA…WHF") is an ed25519 public key whose 32-byte + * payload is all zeros. No private key can ever produce a signature for it, + * so holding a role there would be unrecoverable. + * + * @param address - Stellar public key (G... address) to check + * @returns `true` if the address equals the zero-address sentinel, `false` otherwise + * + * @example + * ```typescript + * if (isZeroAddress(someAddress)) { + * throw new Error('Invalid address: zero address is not allowed'); + * } + * ``` + */ +export function isZeroAddress(address: string): boolean { + return address === ZERO_ADDRESS; +} + import { rpc as SorobanRpc, Contract, @@ -1039,6 +1068,26 @@ export class bcForgeClient { return { migrate, grant }; } + // ─── RBAC Migration ────────────────────────────────────────────────────── + + /** + * Migrate the legacy admin address to the SuperAdmin role mapping. + * + * @remarks + * This is a one-shot, idempotent storage migration that copies the singular + * admin address from `AdminKey::Admin` (instance storage) to + * `AdminKey::SuperAdmin(admin)` (persistent storage). This enables the + * `require_super_admin` guard for legacy contracts without resetting state. + * + * Safe to call multiple times — subsequent calls are no-ops. + * + * @param source - Admin keypair (must be the contract admin to authorize migration) + * @returns TransactionResult with migration status + */ + async migrateAdmin(source?: Keypair): Promise { + return this.invokeContract('migrate_admin', [], source); + } + // ─── Clawback / Regulatory ─────────────────────────────────────────────── /**