diff --git a/contracts/admin/tests/upgrade_e2e.rs b/contracts/admin/tests/upgrade_e2e.rs index d080c674..0b4bc802 100644 --- a/contracts/admin/tests/upgrade_e2e.rs +++ b/contracts/admin/tests/upgrade_e2e.rs @@ -138,6 +138,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); @@ -155,6 +159,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. @@ -206,6 +227,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. @@ -238,6 +268,37 @@ fn test_double_vote_reverts() { 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()); let admin = Address::generate(&env); let member = Address::generate(&env); 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..61778854 100644 --- a/sdk/src/client.test.ts +++ b/sdk/src/client.test.ts @@ -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(); diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 51aa821e..86039b08 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -1039,6 +1039,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 ─────────────────────────────────────────────── /**