From 11a2621a152d9b7010638fb43bc2eba9b1b669b5 Mon Sep 17 00:00:00 2001 From: Chris <151883835+VeronicDev@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:53:10 +0000 Subject: [PATCH] feat(#748): add RBAC storage migration scripts and tests 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: 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 ─────────────────────────────────────────────── /**