From 16002baa156d7f180579f944d903dd34c0c1354d Mon Sep 17 00:00:00 2001 From: "freebuff-web[bot]" <224887109+freebuff-web[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:58:04 +0100 Subject: [PATCH 1/2] feat(#748): add RBAC storage migration scripts and tests (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add standalone migration script, SDK method, and comprehensive tests for migrating legacy Admin contracts to the new RBAC format with SuperAdmin role mapping. The migration is idempotent and maps the singular admin address to the SuperAdmin persistent storage entry. - Add `migrateAdmin()` to TypeScript SDK client - Create `migrations/rbac-migration.ts` with CLI and library interfaces - Add 5 new Rust integration tests for migration happy paths and edge cases - Update UPGRADE_GUIDE.md with migration script usage (CLI, SDK, standalone) - Add TypeScript tests for migrateAdmin, grantMinter, revokeMinter 🤖 Generated with Codebuff Co-authored-by: Chris <151883835+VeronicDev@users.noreply.github.com> Co-authored-by: Codebuff --- contracts/admin/tests/upgrade_e2e.rs | 169 ++++++++ docs/UPGRADE_GUIDE.md | 51 ++- migrations/rbac-migration.ts | 561 +++++++++++++++++++++++++++ sdk/src/client.test.ts | 39 ++ sdk/src/client.ts | 20 + 5 files changed, 839 insertions(+), 1 deletion(-) create mode 100644 migrations/rbac-migration.ts diff --git a/contracts/admin/tests/upgrade_e2e.rs b/contracts/admin/tests/upgrade_e2e.rs index 1f982778..c6c6dbb2 100644 --- a/contracts/admin/tests/upgrade_e2e.rs +++ b/contracts/admin/tests/upgrade_e2e.rs @@ -115,6 +115,10 @@ fn test_e2e_v1_to_v2_admin_upgrade_and_rbac_lifecycle() { assert!(client.has_role(&Role::SuperAdmin, &admin)); assert!(client.has_role(&Role::Admin, &admin)); + // Verify the admin can still be retrieved after migration + assert!(client.has_admin()); + assert_eq!(client.get_role_admin(&Role::Admin), admin); + // 5. Verify post-upgrade RBAC enforcement and role-gated actions // Admin (holding SuperAdmin/Admin) grants Minter role to user_a and Pauser role to user_b client.grant_role(&admin, &Role::Minter, &user_a); @@ -132,6 +136,23 @@ fn test_e2e_v1_to_v2_admin_upgrade_and_rbac_lifecycle() { // Assert unauthorized user cannot grant roles post-upgrade let post_upgrade_unauth = client.try_grant_role(&user_a, &Role::Pauser, &user_a); assert!(post_upgrade_unauth.is_err()); + + // 6. Verify that the admin can still grant SuperAdmin to other addresses + let super_admin = Address::generate(&env); + client.grant_role(&admin, &Role::SuperAdmin, &super_admin); + assert!(client.has_role(&Role::SuperAdmin, &super_admin)); + + // 7. Verify that the new SuperAdmin can also grant roles + let new_minter = Address::generate(&env); + client.grant_role(&super_admin, &Role::Minter, &new_minter); + assert!(client.has_role(&Role::Minter, &new_minter)); + assert!(!client.has_role(&Role::Minter, &user_b)); + + // 8. Verify that revoking roles works correctly post-migration + client.revoke_role(&admin, &Role::Minter, &user_a); + assert!(!client.has_role(&Role::Minter, &user_a)); + // Pauser role should be unaffected + assert!(client.has_role(&Role::Pauser, &user_b)); } /// Negative case: upgrading with an unauthorized caller must fail. @@ -183,6 +204,15 @@ fn test_migrate_admin_idempotency() { // Verify SuperAdmin status remains valid and uncorrupted assert!(client.has_role(&Role::SuperAdmin, &admin)); + + // Verify original admin entry is still intact + assert!(client.has_admin()); + assert_eq!(client.get_role_admin(&Role::Admin), admin); + + // Verify that the admin can still perform RBAC operations after multiple migrations + let user = Address::generate(&env); + client.grant_role(&admin, &Role::Minter, &user); + assert!(client.has_role(&Role::Minter, &user)); } /// Boundary case: verify no stale permissions allow ungranted roles post-upgrade. @@ -206,3 +236,142 @@ fn test_unauthorized_user_cannot_grant_roles_post_upgrade() { assert!(res.is_err()); assert!(!client.has_role(&Role::Minter, &user_b)); } + +/// Migration preserves the original admin entry in instance storage. +#[test] +fn test_migrate_admin_preserves_original_admin_entry() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(AdminContract, ()); + let client = AdminContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + + client.set_admin(&admin); + + // Admin should exist before migration + assert!(client.has_admin()); + assert_eq!(client.get_role_admin(&Role::Admin), admin); + + // Run migration + client.migrate_admin(); + + // Original admin entry should be unchanged + assert!(client.has_admin()); + assert_eq!(client.get_role_admin(&Role::Admin), admin); +} + +/// Migration enables the SuperAdmin guard for legacy contracts. +#[test] +fn test_migrate_admin_enables_super_admin_guard() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(AdminContract, ()); + let client = AdminContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + + client.set_admin(&admin); + + // Before migration, require_super_admin should fail for non-admin users + let other_user = Address::generate(&env); + let pre_migrate_result = client.try_require_super_admin(&other_user); + assert!(pre_migrate_result.is_err()); + + // Run migration + client.migrate_admin(); + + // After migration, admin should be able to pass require_super_admin + // (Note: this test uses mock_all_auths, so require_auth passes) + assert!(client.has_role(&Role::SuperAdmin, &admin)); +} + +/// Migration allows the admin to delegate roles after migration. +#[test] +fn test_migrate_admin_allows_role_delegation() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(AdminContract, ()); + let client = AdminContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let user_a = Address::generate(&env); + let user_b = Address::generate(&env); + let user_c = Address::generate(&env); + + client.set_admin(&admin); + client.migrate_admin(); + + // Admin can now grant various roles + client.grant_role(&admin, &Role::Minter, &user_a); + client.grant_role(&admin, &Role::Pauser, &user_b); + client.grant_role(&admin, &Role::SuperAdmin, &user_c); + + // Verify all roles are properly assigned + assert!(client.has_role(&Role::Minter, &user_a)); + assert!(client.has_role(&Role::Pauser, &user_b)); + assert!(client.has_role(&Role::SuperAdmin, &user_c)); + + // Verify role isolation - users don't have each other's roles + assert!(!client.has_role(&Role::Minter, &user_b)); + assert!(!client.has_role(&Role::Minter, &user_c)); + assert!(!client.has_role(&Role::Pauser, &user_a)); + assert!(!client.has_role(&Role::Pauser, &user_c)); +} + +/// Migration is safe to run on an uninitialized contract (no-op). +#[test] +fn test_migrate_admin_uninitialized_contract_is_noop() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(AdminContract, ()); + let client = AdminContractClient::new(&env, &contract_id); + + // migrate_admin on uninitialized contract should not panic + client.migrate_admin(); + + // Contract should still be uninitialized + assert!(!client.has_admin()); +} + +/// Full lifecycle: initialize, migrate, grant roles, then verify RBAC enforcement. +#[test] +fn test_full_lifecycle_init_migrate_rbac_enforcement() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(AdminContract, ()); + let client = AdminContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let minter = Address::generate(&env); + let pauser = Address::generate(&env); + let unauthorized = Address::generate(&env); + + // Initialize with admin + let init_result = client.try_init_storage(&admin); + assert!(init_result.is_ok()); + + // Migrate to RBAC + client.migrate_admin(); + + // Admin has SuperAdmin role + assert!(client.has_role(&Role::SuperAdmin, &admin)); + assert!(client.has_role(&Role::Admin, &admin)); + + // Grant Minter and Pauser roles + client.grant_role(&admin, &Role::Minter, &minter); + client.grant_role(&admin, &Role::Pauser, &pauser); + + // Verify RBAC enforcement: + // - Minter can pass require_minter + client.require_minter(&minter); + // - Pauser can pass require_pauser + client.require_pauser(&pauser); + // - Unauthorized user cannot pass require_minter + let unauth_result = client.try_require_minter(&unauthorized); + assert!(unauth_result.is_err()); + // - Unauthorized user cannot pass require_pauser + let unauth_pauser_result = client.try_require_pauser(&unauthorized); + assert!(unauth_pauser_result.is_err()); +} 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 b7a03592..1d7067c7 100644 --- a/sdk/src/client.test.ts +++ b/sdk/src/client.test.ts @@ -155,4 +155,43 @@ describe('bcForgeClient Offline Transaction Builders', () => { expect(client.simulateBurnFrom.length).toBe(4); // 4 parameters }); }); + + 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 + }); + }); }); diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 923eaaef..4f114198 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -848,6 +848,26 @@ export class bcForgeClient { ); } + // ─── 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 ─────────────────────────────────────────────── /** From 1c8a45ef1d6f7f0515aac2eda7a5634ce4342fdc Mon Sep 17 00:00:00 2001 From: Chris <151883835+VeronicDev@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:08:40 +0000 Subject: [PATCH 2/2] feat(#747): expose zero-address validation helpers as public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make is_zero_address and require_non_zero_address public in the admin module so consuming contracts can import and use them. Add ZERO_ADDRESS_STRKEY constant, isZeroAddress SDK helper, and comprehensive tests. - Make is_zero_address() public with full documentation - Make require_non_zero_address() public with panic documentation - Export ZERO_ADDRESS_STRKEY constant - Add isZeroAddress() and ZERO_ADDRESS to TypeScript SDK - Add Rust test methods for zero-address validation - Add TypeScript tests for isZeroAddress helper - Update ACCESS_CONTROL.md with zero-address validation documentation 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- contracts/admin/src/lib.rs | 75 ++++++++++++++++++++++++++++++++++++-- docs/ACCESS_CONTROL.md | 48 +++++++++++++++++++++++- sdk/src/client.test.ts | 25 ++++++++++++- sdk/src/client.ts | 29 +++++++++++++++ 4 files changed, 172 insertions(+), 5 deletions(-) diff --git a/contracts/admin/src/lib.rs b/contracts/admin/src/lib.rs index e3f32c55..d9362caa 100644 --- a/contracts/admin/src/lib.rs +++ b/contracts/admin/src/lib.rs @@ -554,13 +554,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); } @@ -1744,6 +1805,14 @@ mod tests { pub fn require_pauser(env: Env, address: Address) { 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); + } } fn zero_address(env: &Env) -> Address { diff --git a/docs/ACCESS_CONTROL.md b/docs/ACCESS_CONTROL.md index 3be19b00..3ca1896d 100644 --- a/docs/ACCESS_CONTROL.md +++ b/docs/ACCESS_CONTROL.md @@ -247,7 +247,8 @@ The `source` parameter must be a `Keypair` with the appropriate role. For - **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. @@ -256,6 +257,51 @@ The `source` parameter must be a `Keypair` with the appropriate role. For - **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/sdk/src/client.test.ts b/sdk/src/client.test.ts index 1d7067c7..76e4b20d 100644 --- a/sdk/src/client.test.ts +++ b/sdk/src/client.test.ts @@ -3,7 +3,7 @@ */ import { jest } from '@jest/globals'; -import { bcForgeClient } from './client'; +import { bcForgeClient, ZERO_ADDRESS, isZeroAddress } from './client'; import { Keypair, Networks, xdr } from '@stellar/stellar-sdk'; // Mock data for testing @@ -195,3 +195,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 4f114198..47a863d9 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,