diff --git a/ccip-sdk/src/cct/solana/index.test.ts b/ccip-sdk/src/cct/solana/index.test.ts index 47df0877..f4c454a6 100644 --- a/ccip-sdk/src/cct/solana/index.test.ts +++ b/ccip-sdk/src/cct/solana/index.test.ts @@ -62,6 +62,10 @@ describe('SolanaTokenManager (cct/solana)', () => { assert.equal(typeof cct.setChainRateLimit, 'function') assert.equal(typeof cct.generateUnsignedSetRateLimitAdmin, 'function') assert.equal(typeof cct.setRateLimitAdmin, 'function') + assert.equal(typeof cct.generateUnsignedTransferOwnership, 'function') + assert.equal(typeof cct.transferOwnership, 'function') + assert.equal(typeof cct.generateUnsignedAcceptOwnership, 'function') + assert.equal(typeof cct.acceptOwnership, 'function') assert.equal(typeof cct.generateUnsignedEditChainRemoteConfig, 'function') assert.equal(typeof cct.editChainRemoteConfig, 'function') assert.equal(typeof cct.generateUnsignedRemoveFromAllowlist, 'function') diff --git a/ccip-sdk/src/cct/solana/index.ts b/ccip-sdk/src/cct/solana/index.ts index 16945084..b1c4aac6 100644 --- a/ccip-sdk/src/cct/solana/index.ts +++ b/ccip-sdk/src/cct/solana/index.ts @@ -64,6 +64,8 @@ import { type BaseGetTokenPoolStateResult, type BurnMintPoolProgramRef, type CustomPoolProgramRef, + type ExecuteAcceptOwnershipParams, + type ExecuteAcceptOwnershipResult, type ExecuteAppendRemotePoolAddressesParams, type ExecuteAppendRemotePoolAddressesResult, type ExecuteApplyChainUpdatesParams, @@ -86,6 +88,10 @@ import { type ExecuteSetChainRateLimitResult, type ExecuteSetRateLimitAdminParams, type ExecuteSetRateLimitAdminResult, + type ExecuteTransferOwnershipParams, + type ExecuteTransferOwnershipResult, + type GenerateAcceptOwnershipParams, + type GenerateAcceptOwnershipResult, type GenerateAppendRemotePoolAddressesParams, type GenerateAppendRemotePoolAddressesResult, type GenerateApplyChainUpdatesParams, @@ -108,12 +114,15 @@ import { type GenerateSetChainRateLimitResult, type GenerateSetRateLimitAdminParams, type GenerateSetRateLimitAdminResult, + type GenerateTransferOwnershipParams, + type GenerateTransferOwnershipResult, type GetTokenPoolRemotesParams, type GetTokenPoolRemotesResult, type GetTokenPoolStateParams, type GetTokenPoolStateResult, type LockReleaseGetTokenPoolStateResult, type LockReleasePoolProgramRef, + AcceptOwnership, AppendRemotePoolAddresses, ApplyChainUpdates, ConfigureAllowlist, @@ -127,6 +136,7 @@ import { RemoveFromAllowlist, SetChainRateLimit, SetRateLimitAdmin, + TransferOwnership, } from './token-pool/operations/index.ts' /** CCT admin facade for Solana. */ @@ -146,6 +156,7 @@ export class SolanaTokenManager extends TokenManager readonly #transferAdmin = new TransferAdmin() // Token pool operations + readonly #acceptOwnership = new AcceptOwnership() readonly #appendRemotePoolAddresses = new AppendRemotePoolAddresses() readonly #applyChainUpdates = new ApplyChainUpdates() readonly #configureAllowlist = new ConfigureAllowlist() @@ -159,6 +170,7 @@ export class SolanaTokenManager extends TokenManager readonly #removeFromAllowlist = new RemoveFromAllowlist() readonly #setChainRateLimit = new SetChainRateLimit() readonly #setRateLimitAdmin = new SetRateLimitAdmin() + readonly #transferOwnership = new TransferOwnership() /** Creates a Solana CCT manager for an existing chain. */ constructor(chain: SolanaChain) { @@ -892,6 +904,121 @@ export class SolanaTokenManager extends TokenManager return this.#setRateLimitAdmin.execute(this.chain, opts) } + /** + * Builds an unsigned instruction that proposes a new owner for an initialized Solana token pool. + * Pass canonical `poolType` or a compatible `poolProgramAddress`; `authority` defaults to `payer`. + * The operation reads pool state and rejects the current owner or default public key. The proposed + * owner must accept ownership separately before the transfer takes effect. + * + * @see {@link transferOwnership} + * @see {@link generateUnsignedAcceptOwnership} + * + * @throws {@link CCTParamsInvalidError} If a pool parameter or public key is invalid. + * @throws {@link CCIPTokenPoolStateNotFoundError} If the token pool account does not exist. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * const unsigned = await cct.generateUnsignedTransferOwnership({ + * tokenAddress: mint, + * poolType: 'burn-mint', + * newOwner, + * payer, + * authority, + * }) + * ``` + */ + generateUnsignedTransferOwnership( + opts: GenerateTransferOwnershipParams, + ): Promise { + return this.#transferOwnership.generate(this.chain, opts) + } + + /** + * Proposes a new owner for an initialized Solana token pool using the current owner wallet. + * It rejects the current owner or default public key. The proposed owner must accept ownership + * separately before the transfer takes effect. + * + * @see {@link generateUnsignedTransferOwnership} + * @see {@link acceptOwnership} + * + * @throws {@link CCIPWalletInvalidError} If `wallet` cannot sign Solana transactions. + * @throws {@link CCTParamsInvalidError} If a pool parameter is invalid or the authority differs + * from the executing wallet. + * @throws {@link CCIPTokenPoolStateNotFoundError} If the token pool account does not exist. + * @throws {@link CCTTxFailedError} If the wallet is not the pool owner or simulation/submission fails. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * await cct.transferOwnership({ + * tokenAddress: mint, + * poolType: 'burn-mint', + * newOwner, + * wallet, + * }) + * ``` + */ + transferOwnership(opts: ExecuteTransferOwnershipParams): Promise { + return this.#transferOwnership.execute(this.chain, opts) + } + + /** + * Builds an unsigned instruction that accepts pending ownership of an initialized Solana token + * pool. Pass canonical `poolType` or a compatible `poolProgramAddress`; `authority` defaults to + * `payer`. The operation reads pool state and requires it to be the proposed owner. + * + * @see {@link acceptOwnership} + * @see {@link generateUnsignedTransferOwnership} + * + * @throws {@link CCTParamsInvalidError} If a pool parameter or public key is invalid. + * @throws {@link CCIPTokenPoolStateNotFoundError} If the token pool account does not exist. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * const unsigned = await cct.generateUnsignedAcceptOwnership({ + * tokenAddress: mint, + * poolType: 'burn-mint', + * payer, + * authority, + * }) + * ``` + */ + generateUnsignedAcceptOwnership( + opts: GenerateAcceptOwnershipParams, + ): Promise { + return this.#acceptOwnership.generate(this.chain, opts) + } + + /** + * Accepts pending ownership of an initialized Solana token pool using the proposed owner wallet. + * It verifies the wallet is the proposed owner before submitting. + * + * @see {@link generateUnsignedAcceptOwnership} + * @see {@link transferOwnership} + * + * @throws {@link CCIPWalletInvalidError} If `wallet` cannot sign Solana transactions. + * @throws {@link CCTParamsInvalidError} If a pool parameter is invalid or the authority differs + * from the executing wallet. + * @throws {@link CCIPTokenPoolStateNotFoundError} If the token pool account does not exist. + * @throws {@link CCTTxFailedError} If the wallet is not the proposed owner or + * simulation/submission fails. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * await cct.acceptOwnership({ + * tokenAddress: mint, + * poolType: 'burn-mint', + * wallet, + * }) + * ``` + */ + acceptOwnership(opts: ExecuteAcceptOwnershipParams): Promise { + return this.#acceptOwnership.execute(this.chain, opts) + } + /** * Builds an unsigned instruction that sets inbound and outbound rate limits for an initialized * Solana token pool remote-chain config. Pass canonical `poolType` or a compatible diff --git a/ccip-sdk/src/cct/solana/token-pool/operations/accept-ownership.test.ts b/ccip-sdk/src/cct/solana/token-pool/operations/accept-ownership.test.ts new file mode 100644 index 00000000..f1c7b0a6 --- /dev/null +++ b/ccip-sdk/src/cct/solana/token-pool/operations/accept-ownership.test.ts @@ -0,0 +1,203 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { BorshAccountsCoder } from '@coral-xyz/anchor' +import { Keypair, PublicKey } from '@solana/web3.js' + +import { ChainFamily } from '../../../../networks.ts' +import { tokenPoolCoder } from '../../../../solana/idl/token-pool-coder.ts' +import type { SolanaChain } from '../../../../solana/index.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import { SolanaTokenManager } from '../../index.ts' +import { deriveTokenPoolConfigPda, resolveTokenPoolProgram } from '../../programs/token-pool.ts' + +const TOKEN = Keypair.generate().publicKey.toBase58() +const PAYER = Keypair.generate().publicKey.toBase58() +const AUTHORITY = Keypair.generate().publicKey.toBase58() +const HASH = Keypair.generate().publicKey.toBase58() +const WALLET = { + publicKey: Keypair.generate().publicKey, + signTransaction: async (tx: T) => tx, +} + +function stateData(proposedOwner = AUTHORITY): Buffer { + const key = PublicKey.default.toBuffer() + return Buffer.concat([ + BorshAccountsCoder.accountDiscriminator('State'), + Buffer.from([1]), + key, + new PublicKey(TOKEN).toBuffer(), + Buffer.from([6]), + key, + key, + key, + new PublicKey(proposedOwner).toBuffer(), + key, + key, + key, + key, + key, + Buffer.from([0, 0]), + Buffer.alloc(4), + key, + ]) +} + +function chain(proposedOwner = AUTHORITY): SolanaChain { + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + connection: { + getAccountInfo: async () => ({ owner: PublicKey.default, data: stateData(proposedOwner) }), + }, + } as unknown as SolanaChain +} + +function submitChain(): SolanaChain { + return { + ...chain(WALLET.publicKey.toBase58()), + connection: { + simulateTransaction: async () => ({ value: { err: null, logs: [], unitsConsumed: 1 } }), + getLatestBlockhash: async () => ({ + blockhash: PublicKey.default.toBase58(), + lastValidBlockHeight: 1, + }), + sendTransaction: async () => HASH, + confirmTransaction: async () => ({ value: { err: null } }), + getAccountInfo: async () => ({ + owner: PublicKey.default, + data: stateData(WALLET.publicKey.toBase58()), + }), + }, + } as unknown as SolanaChain +} + +function generate(opts = {}) { + return SolanaTokenManager.fromChain(chain()).generateUnsignedAcceptOwnership({ + tokenAddress: TOKEN, + poolType: 'burn-mint', + payer: PAYER, + authority: AUTHORITY, + ...opts, + }) +} + +describe('AcceptOwnership (cct/solana)', () => { + describe('generate', () => { + it('builds the ownership-acceptance instruction', async () => { + const unsigned = await generate() + const [instruction] = unsigned.instructions + const poolProgram = resolveTokenPoolProgram('burn-mint') + const decoded = tokenPoolCoder.instruction.decode(instruction!.data) + + assert.equal(unsigned.family, ChainFamily.Solana) + assert.equal(unsigned.mainIndex, 0) + assert.equal(instruction!.programId.toBase58(), poolProgram.toBase58()) + assert.deepEqual( + instruction!.keys.map(({ pubkey, isSigner, isWritable }) => ({ + pubkey: pubkey.toBase58(), + isSigner, + isWritable, + })), + [ + { + pubkey: deriveTokenPoolConfigPda(poolProgram, new PublicKey(TOKEN)).toBase58(), + isSigner: false, + isWritable: true, + }, + { pubkey: TOKEN, isSigner: false, isWritable: false }, + { pubkey: AUTHORITY, isSigner: true, isWritable: false }, + ], + ) + assert.ok(decoded) + assert.equal(decoded.name, 'acceptOwnership') + }) + + it('defaults authority to payer', async () => { + const unsigned = await SolanaTokenManager.fromChain( + chain(PAYER), + ).generateUnsignedAcceptOwnership({ + tokenAddress: TOKEN, + poolType: 'burn-mint', + payer: PAYER, + }) + + assert.equal(unsigned.instructions[0]!.keys[2]!.pubkey.toBase58(), PAYER) + }) + + it('supports a compatible custom pool program', async () => { + const poolProgramAddress = Keypair.generate().publicKey.toBase58() + const unsigned = await generate({ poolType: undefined, poolProgramAddress }) + + assert.equal(unsigned.instructions[0]?.programId.toBase58(), poolProgramAddress) + }) + }) + + describe('validation', () => { + it('rejects an authority that is not the proposed owner', async () => { + await assert.rejects( + () => generate({ authority: PAYER }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.param === 'authority' && + err.message.includes('must be the proposed owner'), + ) + }) + + it('rejects when there is no proposed owner', async () => { + const cct = SolanaTokenManager.fromChain(chain(PublicKey.default.toBase58())) + + await assert.rejects( + () => + cct.generateUnsignedAcceptOwnership({ + tokenAddress: TOKEN, + poolType: 'burn-mint', + payer: PAYER, + }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.param === 'authority' && + err.message.includes('no proposed owner'), + ) + }) + + it('rejects invalid public keys', async () => { + for (const [opts, param] of [ + [{ tokenAddress: 'invalid' }, 'tokenAddress'], + [{ authority: 'invalid' }, 'authority'], + ]) { + await assert.rejects( + () => generate(opts), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === param, + ) + } + }) + }) + + describe('execute', () => { + it('signs, submits, and returns the tx hash', async () => { + const result = await SolanaTokenManager.fromChain(submitChain()).acceptOwnership({ + tokenAddress: TOKEN, + poolType: 'burn-mint', + wallet: WALLET, + }) + + assert.deepEqual(result, { hash: HASH }) + }) + + it('rejects a non-wallet authority for signed acceptance', async () => { + await assert.rejects( + () => + SolanaTokenManager.fromChain(chain()).acceptOwnership({ + tokenAddress: TOKEN, + poolType: 'burn-mint', + authority: AUTHORITY, + wallet: WALLET, + }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'acceptOwnership' && + err.context.param === 'authority', + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/solana/token-pool/operations/accept-ownership.ts b/ccip-sdk/src/cct/solana/token-pool/operations/accept-ownership.ts new file mode 100644 index 00000000..f0fc111c --- /dev/null +++ b/ccip-sdk/src/cct/solana/token-pool/operations/accept-ownership.ts @@ -0,0 +1,125 @@ +import { PublicKey } from '@solana/web3.js' + +import { GetTokenPoolState } from './get-token-pool-state.ts' +import { ChainFamily } from '../../../../networks.ts' +import type { SolanaChain } from '../../../../solana/index.ts' +import type { UnsignedSolanaTx } from '../../../../solana/types.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import type { TransactionResult } from '../../../operation.ts' +import { + type SolanaExecuteParams, + type SolanaGenerateParams, + SolanaOperation, +} from '../../operation.ts' +import { + type PoolProgramRef, + createTokenPoolProgram, + deriveTokenPoolConfigPda, +} from '../../programs/token-pool.ts' +import { submit } from '../../submit.ts' +import { + parsePublicKey, + resolvePoolProgram, + validateAuthorityMatchesWallet, +} from '../../validate.ts' + +/** Parameters shared by Solana token pool ownership-acceptance generation and execution. */ +type AcceptOwnershipParams = PoolProgramRef & { + /** Token mint address managed by the pool. */ + tokenAddress: string + /** Proposed pool owner accepting ownership. Defaults to `payer` for single-signer transactions. */ + authority?: string +} + +type ParsedAcceptOwnershipParams = { + tokenAddress: PublicKey + poolProgram: PublicKey + payer: PublicKey + authority: PublicKey +} + +/** Parameters for unsigned Solana token pool ownership acceptance. */ +export type GenerateAcceptOwnershipParams = SolanaGenerateParams + +/** Unsigned Solana token pool ownership acceptance result. */ +export type GenerateAcceptOwnershipResult = UnsignedSolanaTx + +/** Parameters for executing Solana token pool ownership acceptance. */ +export type ExecuteAcceptOwnershipParams = SolanaExecuteParams + +/** Result of executing Solana token pool ownership acceptance. */ +export type ExecuteAcceptOwnershipResult = TransactionResult + +/** Accepts pending ownership of a Solana token pool. */ +export class AcceptOwnership extends SolanaOperation< + AcceptOwnershipParams, + UnsignedSolanaTx, + ParsedAcceptOwnershipParams +> { + readonly name = 'acceptOwnership' + + /** Parses public keys and defaults authority to payer without mutating caller params. */ + protected override parse(params: GenerateAcceptOwnershipParams): ParsedAcceptOwnershipParams { + const payer = parsePublicKey(this.name, 'payer', params.payer) + return { + tokenAddress: parsePublicKey(this.name, 'tokenAddress', params.tokenAddress), + poolProgram: resolvePoolProgram(this.name, params), + payer, + authority: + params.authority === undefined + ? payer + : parsePublicKey(this.name, 'authority', params.authority), + } + } + + /** Confirms the authority is the proposed owner, then builds the unsigned `acceptOwnership` instruction. */ + protected async buildUnsigned( + chain: SolanaChain, + opts: ParsedAcceptOwnershipParams, + ): Promise { + const { config } = await new GetTokenPoolState().query(chain, { + tokenAddress: opts.tokenAddress.toBase58(), + poolProgramAddress: opts.poolProgram.toBase58(), + }) + const proposedOwner = new PublicKey(config.proposedOwner) + if (proposedOwner.equals(PublicKey.default)) { + throw new CCTParamsInvalidError(this.name, 'authority', 'no proposed owner') + } + if (!proposedOwner.equals(opts.authority)) { + throw new CCTParamsInvalidError(this.name, 'authority', 'must be the proposed owner') + } + + const instruction = await createTokenPoolProgram(chain, opts.poolProgram, opts.payer) + .methods.acceptOwnership() + .accountsStrict({ + state: deriveTokenPoolConfigPda(opts.poolProgram, opts.tokenAddress), + mint: opts.tokenAddress, + authority: opts.authority, + }) + .instruction() + + chain.logger.debug( + `${this.name}: token = ${opts.tokenAddress.toBase58()}, poolProgram = ${opts.poolProgram.toBase58()}`, + ) + return { family: ChainFamily.Solana, instructions: [instruction], mainIndex: 0 } + } + + /** Generate, sign, simulate, send, and confirm with the proposed owner wallet. */ + override async execute( + chain: SolanaChain, + params: ExecuteAcceptOwnershipParams, + ): Promise { + const { wallet, computeUnits, parsed } = this.prepareWalletExecution(params) + + if (params.authority !== undefined) { + validateAuthorityMatchesWallet( + this.name, + parsed.authority, + wallet.publicKey, + 'acceptOwnership requires authority to be the executing wallet. Use generateUnsignedAcceptOwnership for externally signed transactions.', + ) + } + + return submit(chain, wallet, await this.buildUnsigned(chain, parsed), this.name, computeUnits) + } +} diff --git a/ccip-sdk/src/cct/solana/token-pool/operations/index.ts b/ccip-sdk/src/cct/solana/token-pool/operations/index.ts index be3e686e..c01db641 100644 --- a/ccip-sdk/src/cct/solana/token-pool/operations/index.ts +++ b/ccip-sdk/src/cct/solana/token-pool/operations/index.ts @@ -1,3 +1,4 @@ +export * from './accept-ownership.ts' export * from './append-remote-pool-addresses.ts' export * from './apply-chain-updates.ts' export * from './configure-allowlist.ts' @@ -11,3 +12,4 @@ export * from './init-chain-remote-config.ts' export * from './remove-from-allowlist.ts' export * from './set-chain-rate-limit.ts' export * from './set-rate-limit-admin.ts' +export * from './transfer-ownership.ts' diff --git a/ccip-sdk/src/cct/solana/token-pool/operations/transfer-ownership.test.ts b/ccip-sdk/src/cct/solana/token-pool/operations/transfer-ownership.test.ts new file mode 100644 index 00000000..612381bc --- /dev/null +++ b/ccip-sdk/src/cct/solana/token-pool/operations/transfer-ownership.test.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { BorshAccountsCoder } from '@coral-xyz/anchor' +import { Keypair, PublicKey } from '@solana/web3.js' + +import { ChainFamily } from '../../../../networks.ts' +import { tokenPoolCoder } from '../../../../solana/idl/token-pool-coder.ts' +import type { SolanaChain } from '../../../../solana/index.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import { SolanaTokenManager } from '../../index.ts' +import { deriveTokenPoolConfigPda, resolveTokenPoolProgram } from '../../programs/token-pool.ts' + +const TOKEN = Keypair.generate().publicKey.toBase58() +const PAYER = Keypair.generate().publicKey.toBase58() +const AUTHORITY = Keypair.generate().publicKey.toBase58() +const NEW_OWNER = Keypair.generate().publicKey.toBase58() +const OWNER = Keypair.generate().publicKey +const HASH = Keypair.generate().publicKey.toBase58() +const WALLET = { + publicKey: Keypair.generate().publicKey, + signTransaction: async (tx: T) => tx, +} + +function stateData(owner = OWNER): Buffer { + const key = PublicKey.default.toBuffer() + return Buffer.concat([ + BorshAccountsCoder.accountDiscriminator('State'), + Buffer.from([1]), + key, + new PublicKey(TOKEN).toBuffer(), + Buffer.from([6]), + key, + key, + owner.toBuffer(), + key, + key, + key, + key, + key, + Buffer.from([0, 0]), + Buffer.alloc(4), + key, + ]) +} + +function chain(owner = OWNER): SolanaChain { + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + connection: { + getAccountInfo: async () => ({ owner: PublicKey.default, data: stateData(owner) }), + }, + } as unknown as SolanaChain +} + +function submitChain(): SolanaChain { + return { + ...chain(), + connection: { + simulateTransaction: async () => ({ value: { err: null, logs: [], unitsConsumed: 1 } }), + getLatestBlockhash: async () => ({ + blockhash: PublicKey.default.toBase58(), + lastValidBlockHeight: 1, + }), + sendTransaction: async () => HASH, + confirmTransaction: async () => ({ value: { err: null } }), + getAccountInfo: async () => ({ owner: PublicKey.default, data: stateData() }), + }, + } as unknown as SolanaChain +} + +function generate(opts = {}) { + return SolanaTokenManager.fromChain(chain()).generateUnsignedTransferOwnership({ + tokenAddress: TOKEN, + poolType: 'burn-mint', + payer: PAYER, + authority: AUTHORITY, + newOwner: NEW_OWNER, + ...opts, + }) +} + +describe('TransferOwnership (cct/solana)', () => { + describe('generate', () => { + it('builds the ownership-transfer instruction', async () => { + const unsigned = await generate() + const [instruction] = unsigned.instructions + const poolProgram = resolveTokenPoolProgram('burn-mint') + const decoded = tokenPoolCoder.instruction.decode(instruction!.data) + + assert.equal(unsigned.family, ChainFamily.Solana) + assert.equal(unsigned.mainIndex, 0) + assert.equal(instruction!.programId.toBase58(), poolProgram.toBase58()) + assert.deepEqual( + instruction!.keys.map(({ pubkey, isSigner, isWritable }) => ({ + pubkey: pubkey.toBase58(), + isSigner, + isWritable, + })), + [ + { + pubkey: deriveTokenPoolConfigPda(poolProgram, new PublicKey(TOKEN)).toBase58(), + isSigner: false, + isWritable: true, + }, + { pubkey: TOKEN, isSigner: false, isWritable: false }, + { pubkey: AUTHORITY, isSigner: true, isWritable: false }, + ], + ) + assert.ok(decoded) + assert.equal(decoded.name, 'transferOwnership') + assert.equal( + (decoded.data as { proposedOwner: PublicKey }).proposedOwner.toBase58(), + NEW_OWNER, + ) + }) + + it('defaults authority to payer', async () => { + const unsigned = await generate({ authority: undefined }) + + assert.equal(unsigned.instructions[0]!.keys[2]!.pubkey.toBase58(), PAYER) + }) + + it('supports a compatible custom pool program', async () => { + const poolProgramAddress = Keypair.generate().publicKey.toBase58() + const unsigned = await generate({ poolType: undefined, poolProgramAddress }) + + assert.equal(unsigned.instructions[0]?.programId.toBase58(), poolProgramAddress) + }) + }) + + describe('validation', () => { + it('rejects invalid public keys', async () => { + for (const [opts, param] of [ + [{ tokenAddress: 'invalid' }, 'tokenAddress'], + [{ newOwner: 'invalid' }, 'newOwner'], + [{ newOwner: PublicKey.default.toBase58() }, 'newOwner'], + ]) { + await assert.rejects( + () => generate(opts), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === param, + ) + } + }) + + it('rejects the current pool owner', async () => { + await assert.rejects( + () => generate({ newOwner: OWNER.toBase58() }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'transferOwnership' && + err.context.param === 'newOwner' && + err.message.includes('must not be the current pool owner'), + ) + }) + }) + + describe('execute', () => { + it('signs, submits, and returns the tx hash', async () => { + const result = await SolanaTokenManager.fromChain(submitChain()).transferOwnership({ + tokenAddress: TOKEN, + poolType: 'burn-mint', + newOwner: NEW_OWNER, + wallet: WALLET, + }) + + assert.deepEqual(result, { hash: HASH }) + }) + + it('rejects a non-wallet authority for signed transfer', async () => { + await assert.rejects( + () => + SolanaTokenManager.fromChain(chain()).transferOwnership({ + tokenAddress: TOKEN, + poolType: 'burn-mint', + newOwner: NEW_OWNER, + authority: AUTHORITY, + wallet: WALLET, + }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'transferOwnership' && + err.context.param === 'authority', + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/solana/token-pool/operations/transfer-ownership.ts b/ccip-sdk/src/cct/solana/token-pool/operations/transfer-ownership.ts new file mode 100644 index 00000000..3273b78d --- /dev/null +++ b/ccip-sdk/src/cct/solana/token-pool/operations/transfer-ownership.ts @@ -0,0 +1,136 @@ +import { PublicKey } from '@solana/web3.js' + +import { GetTokenPoolState } from './get-token-pool-state.ts' +import { ChainFamily } from '../../../../networks.ts' +import type { SolanaChain } from '../../../../solana/index.ts' +import type { UnsignedSolanaTx } from '../../../../solana/types.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import type { TransactionResult } from '../../../operation.ts' +import { + type SolanaExecuteParams, + type SolanaGenerateParams, + SolanaOperation, +} from '../../operation.ts' +import { + type PoolProgramRef, + createTokenPoolProgram, + deriveTokenPoolConfigPda, +} from '../../programs/token-pool.ts' +import { submit } from '../../submit.ts' +import { + parsePublicKey, + resolvePoolProgram, + validateAuthorityMatchesWallet, +} from '../../validate.ts' + +/** Parameters shared by Solana token pool ownership-transfer generation and execution. */ +type TransferOwnershipParams = PoolProgramRef & { + /** Token mint address managed by the pool. */ + tokenAddress: string + /** Address proposed as the next pool owner. It must accept ownership separately. */ + newOwner: string + /** Current pool owner. Defaults to `payer` for single-signer transactions. */ + authority?: string +} + +type ParsedTransferOwnershipParams = { + tokenAddress: PublicKey + newOwner: PublicKey + poolProgram: PublicKey + payer: PublicKey + authority: PublicKey +} + +/** Parameters for unsigned Solana token pool ownership transfer. */ +export type GenerateTransferOwnershipParams = SolanaGenerateParams + +/** Unsigned Solana token pool ownership transfer result. */ +export type GenerateTransferOwnershipResult = UnsignedSolanaTx + +/** Parameters for executing Solana token pool ownership transfer. */ +export type ExecuteTransferOwnershipParams = SolanaExecuteParams + +/** Result of executing Solana token pool ownership transfer. */ +export type ExecuteTransferOwnershipResult = TransactionResult + +/** Proposes a new owner for a Solana token pool. The proposed owner must accept separately. */ +export class TransferOwnership extends SolanaOperation< + TransferOwnershipParams, + UnsignedSolanaTx, + ParsedTransferOwnershipParams +> { + readonly name = 'transferOwnership' + + /** Parses public keys and defaults authority to payer without mutating caller params. */ + protected override parse(params: GenerateTransferOwnershipParams): ParsedTransferOwnershipParams { + const payer = parsePublicKey(this.name, 'payer', params.payer) + const newOwner = parsePublicKey(this.name, 'newOwner', params.newOwner) + if (newOwner.equals(PublicKey.default)) { + throw new CCTParamsInvalidError( + this.name, + 'newOwner', + 'must not be the default public key or zero address', + ) + } + + return { + tokenAddress: parsePublicKey(this.name, 'tokenAddress', params.tokenAddress), + newOwner, + poolProgram: resolvePoolProgram(this.name, params), + payer, + authority: + params.authority === undefined + ? payer + : parsePublicKey(this.name, 'authority', params.authority), + } + } + + /** Reads the pool state to reject self-transfer, then builds the unsigned Solana `transferOwnership` instruction. */ + protected async buildUnsigned( + chain: SolanaChain, + opts: ParsedTransferOwnershipParams, + ): Promise { + const { config } = await new GetTokenPoolState().query(chain, { + tokenAddress: opts.tokenAddress.toBase58(), + poolProgramAddress: opts.poolProgram.toBase58(), + }) + + if (opts.newOwner.equals(new PublicKey(config.owner))) { + throw new CCTParamsInvalidError(this.name, 'newOwner', 'must not be the current pool owner') + } + + const program = createTokenPoolProgram(chain, opts.poolProgram, opts.payer) + const instruction = await program.methods + .transferOwnership(opts.newOwner) + .accountsStrict({ + state: deriveTokenPoolConfigPda(opts.poolProgram, opts.tokenAddress), + mint: opts.tokenAddress, + authority: opts.authority, + }) + .instruction() + + chain.logger.debug( + `${this.name}: token = ${opts.tokenAddress.toBase58()}, poolProgram = ${opts.poolProgram.toBase58()}`, + ) + return { family: ChainFamily.Solana, instructions: [instruction], mainIndex: 0 } + } + + /** Generate, sign, simulate, send, and confirm with the current pool owner wallet. */ + override async execute( + chain: SolanaChain, + params: ExecuteTransferOwnershipParams, + ): Promise { + const { wallet, computeUnits, parsed } = this.prepareWalletExecution(params) + + if (params.authority !== undefined) { + validateAuthorityMatchesWallet( + this.name, + parsed.authority, + wallet.publicKey, + 'transferOwnership requires authority to be the executing wallet. Use generateUnsignedTransferOwnership for externally signed transactions.', + ) + } + + return submit(chain, wallet, await this.buildUnsigned(chain, parsed), this.name, computeUnits) + } +}