diff --git a/ccip-lib/svm/README.md b/ccip-lib/svm/README.md index 78a5b42..fa1a1ea 100644 --- a/ccip-lib/svm/README.md +++ b/ccip-lib/svm/README.md @@ -390,8 +390,80 @@ await pool.setRateLimit( new BN(100000000) // Rate: 0.1 tokens per second ); -// Transfer admin role for a token pool -await pool.transferAdminRole(tokenMint, newAdminPublicKey); +// Transfer admin role for a token pool (two-step process) +// Step 1: Current owner proposes new admin +await pool.transferAdminRole(tokenMint, { newAdmin: newAdminPublicKey }); + +// Step 2: Proposed admin accepts the role (must be signed by newAdminPublicKey) +await pool.acceptAdminRole(tokenMint); +``` + +### Pool Ownership Management + +Token pools use a two-step ownership transfer process for security. This ensures that ownership can only be transferred to a valid recipient who explicitly accepts the role. + +#### Transfer Pool Ownership + +```typescript +import { + TokenPoolManager, + TokenPoolType, + TransferAdminRoleOptions, + AcceptAdminRoleOptions +} from "../path/to/ccip-lib/svm"; + +// Create token pool manager +const poolManager = TokenPoolManager.create( + connection, + currentOwnerKeypair, + { burnMint: burnMintPoolProgramId }, + config +); + +// Get the pool client +const pool = poolManager.getTokenPoolClient(TokenPoolType.BURN_MINT); + +// Step 1: Current owner proposes new administrator +await pool.transferAdminRole(tokenMint, { + newAdmin: newOwnerPublicKey, + skipPreflight: false, // Optional transaction settings +}); +``` + +#### Accept Pool Ownership + +```typescript +// The proposed new owner must sign this transaction +const newOwnerPoolManager = TokenPoolManager.create( + connection, + newOwnerKeypair, // Must be the proposed owner + { burnMint: burnMintPoolProgramId }, + config +); + +const newOwnerPool = newOwnerPoolManager.getTokenPoolClient(TokenPoolType.BURN_MINT); + +// Step 2: Proposed owner accepts the admin role +await newOwnerPool.acceptAdminRole(tokenMint, { + skipPreflight: false, // Optional transaction settings +}); +``` + +#### Pool Ownership Best Practices + +- **Two-Step Process**: Always use the two-step transfer process - never skip the acceptance step +- **Verify Recipients**: Ensure the new owner address is correct before proposing transfer +- **Test First**: Test the ownership transfer process on devnet before mainnet +- **Secure Keys**: The new owner must have secure access to their keypair to accept ownership +- **Monitor State**: Check pool configuration to verify ownership transfer completed successfully + +#### Checking Current Pool Owner + +```typescript +// Get pool information to check current owner +const poolInfo = await pool.getPoolInfo(tokenMint); +console.log(`Current owner: ${poolInfo.config.config.owner.toString()}`); +console.log(`Proposed owner: ${poolInfo.config.config.proposedOwner?.toString() || 'none'}`); ``` ### Token Pool Factory diff --git a/ccip-scripts/svm/pool/accept-ownership.ts b/ccip-scripts/svm/pool/accept-ownership.ts new file mode 100644 index 0000000..e6b4d63 --- /dev/null +++ b/ccip-scripts/svm/pool/accept-ownership.ts @@ -0,0 +1,302 @@ +/** + * Pool Accept Ownership Script (CLI Framework Version) + * + * This script accepts the ownership of a token pool by the proposed owner. + * This is step 2 of a two-step ownership transfer process. + */ + +import { PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js"; +import { TokenPoolManager } from "../../../ccip-lib/svm/core/client/tokenpools"; +import { TokenPoolType, LogLevel, createLogger } from "../../../ccip-lib/svm"; +import { BurnMintTokenPoolInfo } from "../../../ccip-lib/svm/tokenpools/burnmint/accounts"; +import { resolveNetworkConfig, getExplorerUrl } from "../../config"; +import { getKeypairPath, loadKeypair } from "../utils"; +import { CCIPCommand, ArgumentDefinition, CommandMetadata, BaseCommandOptions } from "../utils/cli-framework"; + +/** + * Configuration for accept ownership operations + */ +const ACCEPT_OWNERSHIP_CONFIG = { + minSolRequired: 0.01, + defaultLogLevel: LogLevel.INFO, +}; + +/** + * Options specific to the accept-ownership command + */ +interface AcceptOwnershipOptions extends BaseCommandOptions { + tokenMint: string; + burnMintPoolProgram: string; +} + +/** + * Pool Accept Ownership Command + */ +class AcceptOwnershipCommand extends CCIPCommand { + constructor() { + const metadata: CommandMetadata = { + name: "accept-ownership", + description: "✅ Pool Ownership Acceptance\n\nAccepts the ownership of a token pool by the proposed owner. This is step 2 of a two-step ownership transfer process for security.", + examples: [ + "# Accept pool ownership (you must be the proposed owner)", + "yarn svm:pool:accept-ownership \\", + " --token-mint 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU \\", + " --burn-mint-pool-program 2YzPLhHBpRMwxCN7yLpHJGHg2AXBzQ5VPuKt51BDKxqh", + "", + "# With debug logging", + "yarn svm:pool:accept-ownership \\", + " --token-mint 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU \\", + " --burn-mint-pool-program 2YzPLhHBpRMwxCN7yLpHJGHg2AXBzQ5VPuKt51BDKxqh \\", + " --log-level DEBUG" + ], + notes: [ + "✅ This completes the 2-step ownership transfer process", + "Only callable by the proposed owner (set via transfer-ownership)", + `Minimum ${ACCEPT_OWNERSHIP_CONFIG.minSolRequired} SOL required for transaction fees`, + "You must be the proposed owner to execute this command", + "Once accepted, you become the pool owner with full administrative rights", + "Use 'yarn svm:pool:get-info' to verify ownership after acceptance", + "Ensure you have secure access to this keypair before accepting" + ] + }; + + super(metadata); + } + + protected defineArguments(): ArgumentDefinition[] { + return [ + { + name: "token-mint", + required: true, + type: "string", + description: "Token mint address identifying the pool", + example: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" + }, + { + name: "burn-mint-pool-program", + required: true, + type: "string", + description: "Burn-mint token pool program ID", + example: "2YzPLhHBpRMwxCN7yLpHJGHg2AXBzQ5VPuKt51BDKxqh" + } + ]; + } + + protected async execute(): Promise { + this.logger.info("✅ CCIP Token Pool Accept Ownership"); + this.logger.info("=========================================="); + this.logger.warn("⚠️ Step 2 of 2-step ownership transfer process"); + + // Resolve network configuration + const config = resolveNetworkConfig(this.options); + + // Load wallet (must be proposed owner) + const keypairPath = getKeypairPath(this.options); + const walletKeypair = loadKeypair(keypairPath); + + this.logger.info(`Network: ${config.id}`); + this.logger.info(`Proposed Owner (Wallet): ${walletKeypair.publicKey.toString()}`); + + // Check SOL balance + this.logger.info(""); + this.logger.info("💰 WALLET BALANCE"); + this.logger.info("=========================================="); + const balance = await config.connection.getBalance(walletKeypair.publicKey); + const solBalance = balance / LAMPORTS_PER_SOL; + this.logger.info(`SOL Balance: ${balance} lamports (${solBalance.toFixed(9)} SOL)`); + + if (solBalance < ACCEPT_OWNERSHIP_CONFIG.minSolRequired) { + throw new Error( + `Insufficient balance. Need at least ${ACCEPT_OWNERSHIP_CONFIG.minSolRequired} SOL for transaction fees.\n` + + `Current balance: ${solBalance.toFixed(9)} SOL\n\n` + + `Request airdrop with:\n` + + `solana airdrop 1 ${walletKeypair.publicKey.toString()} --url devnet` + ); + } + + // Parse and validate addresses + let tokenMint: PublicKey; + let burnMintPoolProgramId: PublicKey; + + try { + tokenMint = new PublicKey(this.options.tokenMint); + } catch { + throw new Error(`Invalid token mint address: ${this.options.tokenMint}`); + } + + try { + burnMintPoolProgramId = new PublicKey(this.options.burnMintPoolProgram); + } catch { + throw new Error(`Invalid burn-mint pool program ID: ${this.options.burnMintPoolProgram}`); + } + + // Display configuration + this.logger.info(""); + this.logger.info("📋 OWNERSHIP ACCEPTANCE CONFIGURATION"); + this.logger.info("=========================================="); + this.logger.info(`Token Mint: ${tokenMint.toString()}`); + this.logger.info(`Pool Program: ${burnMintPoolProgramId.toString()}`); + this.logger.info(`Proposed Owner (You): ${walletKeypair.publicKey.toString()}`); + + this.logger.debug("Configuration details:"); + this.logger.debug(` Network: ${config.id}`); + this.logger.debug(` Connection endpoint: ${config.connection.rpcEndpoint}`); + this.logger.debug(` Commitment level: ${config.connection.commitment}`); + this.logger.debug(` Skip preflight: ${this.options.skipPreflight}`); + + try { + // Create token pool manager using SDK + const tokenPoolManager = TokenPoolManager.create( + config.connection, + walletKeypair, + { + burnMint: burnMintPoolProgramId, + }, + { + ccipRouterProgramId: config.routerProgramId.toString(), + feeQuoterProgramId: config.feeQuoterProgramId.toString(), + rmnRemoteProgramId: config.rmnRemoteProgramId.toString(), + linkTokenMint: config.linkTokenMint.toString(), + receiverProgramId: config.receiverProgramId.toString(), + }, + { logLevel: this.options.logLevel ?? LogLevel.INFO } + ); + + const tokenPoolClient = tokenPoolManager.getTokenPoolClient(TokenPoolType.BURN_MINT); + + // Check if pool exists and get current pool info for verification + this.logger.info(""); + this.logger.info("🔍 VERIFYING POOL AND PENDING OWNERSHIP"); + this.logger.info("=========================================="); + this.logger.info("Checking pool exists and verifying pending ownership..."); + + let poolInfo: BurnMintTokenPoolInfo; + try { + poolInfo = await tokenPoolClient.getPoolInfo(tokenMint) as BurnMintTokenPoolInfo; + this.logger.info("✅ Pool exists"); + this.logger.info(`Current Pool Owner: ${poolInfo.config.config.owner.toString()}`); + this.logger.info(`Current Proposed Owner: ${poolInfo.config.config.proposedOwner?.toString() || 'none'}`); + + this.logger.debug("Current pool details:", { + poolType: poolInfo.poolType, + owner: poolInfo.config.config.owner.toString(), + proposedOwner: poolInfo.config.config.proposedOwner?.toString() || 'none', + version: poolInfo.config.version, + decimals: poolInfo.config.config.decimals, + router: poolInfo.config.config.router.toString(), + }); + } catch (error) { + this.logger.error(""); + this.logger.error("❌ POOL NOT FOUND"); + this.logger.error("=========================================="); + this.logger.error("Pool does not exist for this token mint"); + this.logger.error("Initialize the pool first using 'yarn svm:pool:initialize'"); + this.logger.debug( + `To initialize: yarn svm:pool:initialize --token-mint ${tokenMint.toString()} --burn-mint-pool-program ${burnMintPoolProgramId.toString()}` + ); + throw new Error("Pool does not exist for this token mint"); + } + + // Check if there's a pending ownership transfer to this wallet + if (!poolInfo.config.config.proposedOwner || poolInfo.config.config.proposedOwner.equals(PublicKey.default)) { + throw new Error( + `No pending ownership transfer found.\n` + + `Current Owner: ${poolInfo.config.config.owner.toString()}\n` + + `Proposed Owner: none\n\n` + + `The current owner must first propose you as the new owner using 'yarn svm:pool:transfer-ownership'.` + ); + } + + // Verify current wallet is the proposed owner + if (!poolInfo.config.config.proposedOwner.equals(walletKeypair.publicKey)) { + throw new Error( + `Access denied: You are not the proposed owner of this pool.\n` + + `Proposed Owner: ${poolInfo.config.config.proposedOwner.toString()}\n` + + `Your Wallet: ${walletKeypair.publicKey.toString()}\n\n` + + `Only the proposed owner can accept ownership.` + ); + } + + // Check if already the current owner (edge case) + if (poolInfo.config.config.owner.equals(walletKeypair.publicKey)) { + this.logger.info(""); + this.logger.info("ℹ️ ALREADY THE OWNER"); + this.logger.info("=========================================="); + this.logger.info("You are already the current owner of this pool."); + this.logger.info("No action needed - ownership transfer not required."); + return; + } + + this.logger.info("✅ Verified: You are the proposed owner"); + + // Accept ownership + this.logger.info(""); + this.logger.info("🔧 ACCEPTING OWNERSHIP"); + this.logger.info("=========================================="); + this.logger.warn("⚠️ FINALIZING OWNERSHIP TRANSFER"); + this.logger.info("Executing ownership acceptance..."); + + const signature = await tokenPoolClient.acceptAdminRole(tokenMint, { + skipPreflight: this.options.skipPreflight, + }); + + // Display results + this.logger.info(""); + this.logger.info("✅ OWNERSHIP ACCEPTED SUCCESSFULLY"); + this.logger.info("=========================================="); + this.logger.info(`Transaction Signature: ${signature}`); + + // Display explorer URL + this.logger.info(""); + this.logger.info("🔍 EXPLORER URLS"); + this.logger.info("=========================================="); + this.logger.info(`Transaction: ${getExplorerUrl(config.id, signature)}`); + + // Display summary + this.logger.info(""); + this.logger.info("👤 OWNERSHIP TRANSFER COMPLETE"); + this.logger.info("=========================================="); + this.logger.info(`Token Mint: ${tokenMint.toString()}`); + this.logger.info(`Previous Owner: ${poolInfo.config.config.owner.toString()}`); + this.logger.info(`New Owner (You): ${walletKeypair.publicKey.toString()}`); + this.logger.info(`Pool Program: ${burnMintPoolProgramId.toString()}`); + this.logger.info(`Transaction: ${signature}`); + + this.logger.info(""); + this.logger.info("📋 NEXT STEPS"); + this.logger.info("=========================================="); + this.logger.info("1. Verify the ownership transfer completed:"); + this.logger.info(` yarn svm:pool:get-info --token-mint ${tokenMint.toString()}`); + this.logger.info(""); + this.logger.info("2. You can now manage the pool as the owner:"); + this.logger.info(" • Configure remote chains for cross-chain transfers"); + this.logger.info(" • Set rate limits for security"); + this.logger.info(" • Transfer ownership to others if needed"); + this.logger.info(""); + this.logger.info("3. Keep your keypair secure - you are now the pool administrator"); + + this.logger.info(""); + this.logger.info("🎉 Ownership Transfer Complete!"); + this.logger.info("✅ You are now the owner of this token pool"); + this.logger.info("🔧 You have full administrative rights over the pool"); + + } catch (error) { + this.logger.error( + `❌ Failed to accept ownership: ${error instanceof Error ? error.message : String(error)}` + ); + + if (error instanceof Error && error.stack) { + this.logger.debug("\nError stack:"); + this.logger.debug(error.stack); + } + + throw error; + } + } +} + +// Create and run the command +const command = new AcceptOwnershipCommand(); +command.run().catch((error) => { + process.exit(1); +}); diff --git a/ccip-scripts/svm/pool/get-chain-config.ts b/ccip-scripts/svm/pool/get-chain-config.ts index bb6a5ac..718a191 100644 --- a/ccip-scripts/svm/pool/get-chain-config.ts +++ b/ccip-scripts/svm/pool/get-chain-config.ts @@ -109,6 +109,24 @@ class GetChainConfigCommand extends CCIPCommand { }); } + /** + * Format token amount for display + */ + private formatTokenAmount(amount: bigint, decimals: number = 18): string { + if (amount === BigInt(0)) return "0"; + + const divisor = BigInt(10 ** decimals); + const whole = amount / divisor; + const remainder = amount % divisor; + + if (remainder === BigInt(0)) { + return whole.toString(); + } + + const fractional = remainder.toString().padStart(decimals, '0').replace(/0+$/, ''); + return `${whole}.${fractional}`; + } + /** * Display chain configuration details */ @@ -139,9 +157,9 @@ class GetChainConfigCommand extends CCIPCommand { this.logger.info("⬇️ INBOUND RATE LIMIT"); this.logger.info("------------------------------------------"); this.logger.info(`Enabled: ${chainConfig.base.inboundRateLimit.isEnabled}`); - this.logger.info(`Capacity: ${chainConfig.base.inboundRateLimit.capacity.toString()}`); - this.logger.info(`Rate: ${chainConfig.base.inboundRateLimit.rate.toString()} tokens/second`); - this.logger.info(`Current Bucket Value: ${chainConfig.base.inboundRateLimit.currentBucketValue.toString()}`); + this.logger.info(`Capacity: ${chainConfig.base.inboundRateLimit.capacity.toString()} (${this.formatTokenAmount(BigInt(chainConfig.base.inboundRateLimit.capacity), chainConfig.base.decimals)} tokens)`); + this.logger.info(`Rate: ${chainConfig.base.inboundRateLimit.rate.toString()} (${this.formatTokenAmount(BigInt(chainConfig.base.inboundRateLimit.rate), chainConfig.base.decimals)} tokens/second)`); + this.logger.info(`Current Bucket Value: ${chainConfig.base.inboundRateLimit.currentBucketValue.toString()} (${this.formatTokenAmount(BigInt(chainConfig.base.inboundRateLimit.currentBucketValue), chainConfig.base.decimals)} tokens)`); this.logger.info(`Last Updated: ${new Date( Number(chainConfig.base.inboundRateLimit.lastTxTimestamp) * 1000 ).toISOString()}`); @@ -150,9 +168,9 @@ class GetChainConfigCommand extends CCIPCommand { this.logger.info("⬆️ OUTBOUND RATE LIMIT"); this.logger.info("------------------------------------------"); this.logger.info(`Enabled: ${chainConfig.base.outboundRateLimit.isEnabled}`); - this.logger.info(`Capacity: ${chainConfig.base.outboundRateLimit.capacity.toString()}`); - this.logger.info(`Rate: ${chainConfig.base.outboundRateLimit.rate.toString()} tokens/second`); - this.logger.info(`Current Bucket Value: ${chainConfig.base.outboundRateLimit.currentBucketValue.toString()}`); + this.logger.info(`Capacity: ${chainConfig.base.outboundRateLimit.capacity.toString()} (${this.formatTokenAmount(BigInt(chainConfig.base.outboundRateLimit.capacity), chainConfig.base.decimals)} tokens)`); + this.logger.info(`Rate: ${chainConfig.base.outboundRateLimit.rate.toString()} (${this.formatTokenAmount(BigInt(chainConfig.base.outboundRateLimit.rate), chainConfig.base.decimals)} tokens/second)`); + this.logger.info(`Current Bucket Value: ${chainConfig.base.outboundRateLimit.currentBucketValue.toString()} (${this.formatTokenAmount(BigInt(chainConfig.base.outboundRateLimit.currentBucketValue), chainConfig.base.decimals)} tokens)`); this.logger.info(`Last Updated: ${new Date( Number(chainConfig.base.outboundRateLimit.lastTxTimestamp) * 1000 ).toISOString()}`); diff --git a/ccip-scripts/svm/pool/transfer-ownership.ts b/ccip-scripts/svm/pool/transfer-ownership.ts new file mode 100644 index 0000000..83d8fc9 --- /dev/null +++ b/ccip-scripts/svm/pool/transfer-ownership.ts @@ -0,0 +1,324 @@ +/** + * Pool Transfer Ownership Script (CLI Framework Version) + * + * This script transfers the ownership of a token pool to a new administrator. + * This is step 1 of a two-step ownership transfer process. + */ + +import { PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js"; +import { TokenPoolManager } from "../../../ccip-lib/svm/core/client/tokenpools"; +import { TokenPoolType, LogLevel, createLogger } from "../../../ccip-lib/svm"; +import { BurnMintTokenPoolInfo } from "../../../ccip-lib/svm/tokenpools/burnmint/accounts"; +import { resolveNetworkConfig, getExplorerUrl } from "../../config"; +import { getKeypairPath, loadKeypair } from "../utils"; +import { CCIPCommand, ArgumentDefinition, CommandMetadata, BaseCommandOptions } from "../utils/cli-framework"; + +/** + * Configuration for transfer ownership operations + */ +const TRANSFER_OWNERSHIP_CONFIG = { + minSolRequired: 0.01, + defaultLogLevel: LogLevel.INFO, +}; + +/** + * Options specific to the transfer-ownership command + */ +interface TransferOwnershipOptions extends BaseCommandOptions { + tokenMint: string; + burnMintPoolProgram: string; + newOwner: string; +} + +/** + * Pool Transfer Ownership Command + */ +class TransferOwnershipCommand extends CCIPCommand { + constructor() { + const metadata: CommandMetadata = { + name: "transfer-ownership", + description: "👤 Pool Ownership Transfer\n\nTransfers the ownership of a token pool to a new administrator. This is step 1 of a two-step ownership transfer process for security.", + examples: [ + "# Transfer pool ownership to new administrator", + "yarn svm:pool:transfer-ownership \\", + " --token-mint 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU \\", + " --burn-mint-pool-program 2YzPLhHBpRMwxCN7yLpHJGHg2AXBzQ5VPuKt51BDKxqh \\", + " --new-owner 8YHhQnHe4fPvKimt3R4KrvaV9K4d4t1f3KjG2J3RzP8T", + "", + "# With debug logging", + "yarn svm:pool:transfer-ownership \\", + " --token-mint 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU \\", + " --burn-mint-pool-program 2YzPLhHBpRMwxCN7yLpHJGHg2AXBzQ5VPuKt51BDKxqh \\", + " --new-owner 8YHhQnHe4fPvKimt3R4KrvaV9K4d4t1f3KjG2J3RzP8T \\", + " --log-level DEBUG" + ], + notes: [ + "⚠️ SECURITY: This is step 1 of a 2-step process - the new owner must accept", + "Only callable by the current pool owner", + `Minimum ${TRANSFER_OWNERSHIP_CONFIG.minSolRequired} SOL required for transaction fees`, + "The new owner must call 'accept-ownership' to complete the transfer", + "Always verify the new owner address before executing", + "Use 'yarn svm:pool:get-info' to check current ownership status", + "Test ownership transfers on devnet before mainnet" + ] + }; + + super(metadata); + } + + protected defineArguments(): ArgumentDefinition[] { + return [ + { + name: "token-mint", + required: true, + type: "string", + description: "Token mint address identifying the pool", + example: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" + }, + { + name: "burn-mint-pool-program", + required: true, + type: "string", + description: "Burn-mint token pool program ID", + example: "2YzPLhHBpRMwxCN7yLpHJGHg2AXBzQ5VPuKt51BDKxqh" + }, + { + name: "new-owner", + required: true, + type: "string", + description: "PublicKey of the proposed new pool owner", + example: "8YHhQnHe4fPvKimt3R4KrvaV9K4d4t1f3KjG2J3RzP8T" + } + ]; + } + + protected async execute(): Promise { + this.logger.info("👤 CCIP Token Pool Transfer Ownership"); + this.logger.info("=========================================="); + this.logger.warn("⚠️ Step 1 of 2-step ownership transfer process"); + + // Resolve network configuration + const config = resolveNetworkConfig(this.options); + + // Load wallet (must be current owner) + const keypairPath = getKeypairPath(this.options); + const walletKeypair = loadKeypair(keypairPath); + + this.logger.info(`Network: ${config.id}`); + this.logger.info(`Current Owner (Wallet): ${walletKeypair.publicKey.toString()}`); + + // Check SOL balance + this.logger.info(""); + this.logger.info("💰 WALLET BALANCE"); + this.logger.info("=========================================="); + const balance = await config.connection.getBalance(walletKeypair.publicKey); + const solBalance = balance / LAMPORTS_PER_SOL; + this.logger.info(`SOL Balance: ${balance} lamports (${solBalance.toFixed(9)} SOL)`); + + if (solBalance < TRANSFER_OWNERSHIP_CONFIG.minSolRequired) { + throw new Error( + `Insufficient balance. Need at least ${TRANSFER_OWNERSHIP_CONFIG.minSolRequired} SOL for transaction fees.\n` + + `Current balance: ${solBalance.toFixed(9)} SOL\n\n` + + `Request airdrop with:\n` + + `solana airdrop 1 ${walletKeypair.publicKey.toString()} --url devnet` + ); + } + + // Parse and validate addresses + let tokenMint: PublicKey; + let burnMintPoolProgramId: PublicKey; + let newOwner: PublicKey; + + try { + tokenMint = new PublicKey(this.options.tokenMint); + } catch { + throw new Error(`Invalid token mint address: ${this.options.tokenMint}`); + } + + try { + burnMintPoolProgramId = new PublicKey(this.options.burnMintPoolProgram); + } catch { + throw new Error(`Invalid burn-mint pool program ID: ${this.options.burnMintPoolProgram}`); + } + + try { + newOwner = new PublicKey(this.options.newOwner); + } catch { + throw new Error(`Invalid new owner address: ${this.options.newOwner}`); + } + + // Display configuration + this.logger.info(""); + this.logger.info("📋 OWNERSHIP TRANSFER CONFIGURATION"); + this.logger.info("=========================================="); + this.logger.info(`Token Mint: ${tokenMint.toString()}`); + this.logger.info(`Pool Program: ${burnMintPoolProgramId.toString()}`); + this.logger.info(`Current Owner: ${walletKeypair.publicKey.toString()}`); + this.logger.info(`Proposed New Owner: ${newOwner.toString()}`); + + // Validate addresses + if (walletKeypair.publicKey.equals(newOwner)) { + this.logger.warn("⚠️ Warning: Transferring ownership to the same address (current owner)"); + this.logger.warn("This operation will still work but is typically not necessary"); + } + + this.logger.debug("Configuration details:"); + this.logger.debug(` Network: ${config.id}`); + this.logger.debug(` Connection endpoint: ${config.connection.rpcEndpoint}`); + this.logger.debug(` Commitment level: ${config.connection.commitment}`); + this.logger.debug(` Skip preflight: ${this.options.skipPreflight}`); + + try { + // Create token pool manager using SDK + const tokenPoolManager = TokenPoolManager.create( + config.connection, + walletKeypair, + { + burnMint: burnMintPoolProgramId, + }, + { + ccipRouterProgramId: config.routerProgramId.toString(), + feeQuoterProgramId: config.feeQuoterProgramId.toString(), + rmnRemoteProgramId: config.rmnRemoteProgramId.toString(), + linkTokenMint: config.linkTokenMint.toString(), + receiverProgramId: config.receiverProgramId.toString(), + }, + { logLevel: this.options.logLevel ?? LogLevel.INFO } + ); + + const tokenPoolClient = tokenPoolManager.getTokenPoolClient(TokenPoolType.BURN_MINT); + + // Check if pool exists and get current pool info for verification + this.logger.info(""); + this.logger.info("🔍 VERIFYING POOL OWNERSHIP"); + this.logger.info("=========================================="); + this.logger.info("Checking pool exists and verifying current ownership..."); + + let poolInfo: BurnMintTokenPoolInfo; + try { + poolInfo = await tokenPoolClient.getPoolInfo(tokenMint) as BurnMintTokenPoolInfo; + this.logger.info("✅ Pool exists"); + this.logger.info(`Current Pool Owner: ${poolInfo.config.config.owner.toString()}`); + this.logger.info(`Current Proposed Owner: ${poolInfo.config.config.proposedOwner?.toString() || 'none'}`); + + this.logger.debug("Current pool details:", { + poolType: poolInfo.poolType, + owner: poolInfo.config.config.owner.toString(), + proposedOwner: poolInfo.config.config.proposedOwner?.toString() || 'none', + version: poolInfo.config.version, + decimals: poolInfo.config.config.decimals, + router: poolInfo.config.config.router.toString(), + }); + } catch (error) { + this.logger.error(""); + this.logger.error("❌ POOL NOT FOUND"); + this.logger.error("=========================================="); + this.logger.error("Pool does not exist for this token mint"); + this.logger.error("Initialize the pool first using 'yarn svm:pool:initialize'"); + this.logger.debug( + `To initialize: yarn svm:pool:initialize --token-mint ${tokenMint.toString()} --burn-mint-pool-program ${burnMintPoolProgramId.toString()}` + ); + throw new Error("Pool does not exist for this token mint"); + } + + // Verify current wallet is the owner + if (!poolInfo.config.config.owner.equals(walletKeypair.publicKey)) { + throw new Error( + `Access denied: You are not the current owner of this pool.\n` + + `Current Owner: ${poolInfo.config.config.owner.toString()}\n` + + `Your Wallet: ${walletKeypair.publicKey.toString()}\n\n` + + `Only the current owner can transfer ownership.` + ); + } + + // Check if there's already a pending ownership transfer + if (poolInfo.config.config.proposedOwner && !poolInfo.config.config.proposedOwner.equals(PublicKey.default)) { + if (poolInfo.config.config.proposedOwner.equals(newOwner)) { + this.logger.info(""); + this.logger.info("ℹ️ OWNERSHIP ALREADY PROPOSED"); + this.logger.info("=========================================="); + this.logger.info(`${newOwner.toString()} is already the proposed owner.`); + this.logger.info("No action needed - they can accept ownership using 'yarn svm:pool:accept-ownership'"); + return; + } else { + this.logger.warn(""); + this.logger.warn("⚠️ EXISTING PENDING TRANSFER"); + this.logger.warn("=========================================="); + this.logger.warn(`There is already a pending ownership transfer to: ${poolInfo.config.config.proposedOwner.toString()}`); + this.logger.warn("This operation will replace the pending transfer."); + } + } + + // Transfer ownership + this.logger.info(""); + this.logger.info("🔧 TRANSFERRING OWNERSHIP"); + this.logger.info("=========================================="); + this.logger.warn("⚠️ PROPOSING NEW OWNER"); + this.logger.info("Executing ownership transfer proposal..."); + + const signature = await tokenPoolClient.transferAdminRole(tokenMint, { + newAdmin: newOwner, + skipPreflight: this.options.skipPreflight, + }); + + // Display results + this.logger.info(""); + this.logger.info("✅ OWNERSHIP TRANSFER PROPOSED SUCCESSFULLY"); + this.logger.info("=========================================="); + this.logger.info(`Transaction Signature: ${signature}`); + + // Display explorer URL + this.logger.info(""); + this.logger.info("🔍 EXPLORER URLS"); + this.logger.info("=========================================="); + this.logger.info(`Transaction: ${getExplorerUrl(config.id, signature)}`); + + // Display summary + this.logger.info(""); + this.logger.info("👤 OWNERSHIP TRANSFER SUMMARY"); + this.logger.info("=========================================="); + this.logger.info(`Token Mint: ${tokenMint.toString()}`); + this.logger.info(`Current Owner: ${walletKeypair.publicKey.toString()}`); + this.logger.info(`Proposed New Owner: ${newOwner.toString()}`); + this.logger.info(`Pool Program: ${burnMintPoolProgramId.toString()}`); + this.logger.info(`Transaction: ${signature}`); + + this.logger.info(""); + this.logger.info("📋 NEXT STEPS"); + this.logger.info("=========================================="); + this.logger.info("1. The proposed owner must accept ownership:"); + this.logger.info(` yarn svm:pool:accept-ownership \\`); + this.logger.info(` --token-mint ${tokenMint.toString()} \\`); + this.logger.info(` --burn-mint-pool-program ${burnMintPoolProgramId.toString()}`); + this.logger.info(` (Must be run by: ${newOwner.toString()})`); + this.logger.info(""); + this.logger.info("2. Verify the ownership transfer:"); + this.logger.info(` yarn svm:pool:get-info --token-mint ${tokenMint.toString()}`); + this.logger.info(""); + this.logger.info("3. Until accepted, you remain the owner and can cancel by proposing yourself"); + + this.logger.info(""); + this.logger.info("🎉 Ownership Transfer Proposal Complete!"); + this.logger.info("✅ Ownership transfer successfully proposed"); + this.logger.info("⏳ Waiting for new owner to accept the transfer"); + + } catch (error) { + this.logger.error( + `❌ Failed to transfer ownership: ${error instanceof Error ? error.message : String(error)}` + ); + + if (error instanceof Error && error.stack) { + this.logger.debug("\nError stack:"); + this.logger.debug(error.stack); + } + + throw error; + } + } +} + +// Create and run the command +const command = new TransferOwnershipCommand(); +command.run().catch((error) => { + process.exit(1); +}); diff --git a/package.json b/package.json index 1399b14..be61402 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "svm:pool:set-rate-limit": "ts-node ./ccip-scripts/svm/pool/set-rate-limit.ts", "svm:pool:configure-allowlist": "ts-node ./ccip-scripts/svm/pool/configure-allowlist.ts", "svm:pool:transfer-mint-authority-to-multisig": "ts-node ./ccip-scripts/svm/pool/transfer-mint-authority-to-multisig.ts", + "svm:pool:transfer-ownership": "ts-node ./ccip-scripts/svm/pool/transfer-ownership.ts", + "svm:pool:accept-ownership": "ts-node ./ccip-scripts/svm/pool/accept-ownership.ts", "svm:admin:propose-administrator": "ts-node ./ccip-scripts/svm/admin/propose-administrator.ts", "svm:admin:accept-admin-role": "ts-node ./ccip-scripts/svm/admin/accept-admin-role.ts", "svm:admin:create-alt": "ts-node ./ccip-scripts/svm/admin/create-alt.ts",