From df46d8dbfe56f68d6fd09a33d1bc601ea31ac18e Mon Sep 17 00:00:00 2001 From: mervin-link Date: Mon, 17 Aug 2026 19:29:16 +0800 Subject: [PATCH] feat: add accept pool ownership op solana --- ccip-sdk/src/cct/solana/index.test.ts | 2 + ccip-sdk/src/cct/solana/index.ts | 66 +++++- .../operations/accept-pool-ownership.test.ts | 203 ++++++++++++++++++ .../operations/accept-pool-ownership.ts | 127 +++++++++++ .../cct/solana/token-pool/operations/index.ts | 1 + 5 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 ccip-sdk/src/cct/solana/token-pool/operations/accept-pool-ownership.test.ts create mode 100644 ccip-sdk/src/cct/solana/token-pool/operations/accept-pool-ownership.ts diff --git a/ccip-sdk/src/cct/solana/index.test.ts b/ccip-sdk/src/cct/solana/index.test.ts index 9e817b37..1ecf1c4e 100644 --- a/ccip-sdk/src/cct/solana/index.test.ts +++ b/ccip-sdk/src/cct/solana/index.test.ts @@ -64,6 +64,8 @@ describe('SolanaTokenManager (cct/solana)', () => { assert.equal(typeof cct.setRateLimitAdmin, 'function') assert.equal(typeof cct.generateUnsignedTransferPoolOwnership, 'function') assert.equal(typeof cct.transferPoolOwnership, 'function') + assert.equal(typeof cct.generateUnsignedAcceptPoolOwnership, 'function') + assert.equal(typeof cct.acceptPoolOwnership, '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 5ab5f61d..067525e0 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 ExecuteAcceptPoolOwnershipParams, + type ExecuteAcceptPoolOwnershipResult, type ExecuteAppendRemotePoolAddressesParams, type ExecuteAppendRemotePoolAddressesResult, type ExecuteApplyChainUpdatesParams, @@ -88,6 +90,8 @@ import { type ExecuteSetRateLimitAdminResult, type ExecuteTransferPoolOwnershipParams, type ExecuteTransferPoolOwnershipResult, + type GenerateAcceptPoolOwnershipParams, + type GenerateAcceptPoolOwnershipResult, type GenerateAppendRemotePoolAddressesParams, type GenerateAppendRemotePoolAddressesResult, type GenerateApplyChainUpdatesParams, @@ -118,6 +122,7 @@ import { type GetTokenPoolStateResult, type LockReleaseGetTokenPoolStateResult, type LockReleasePoolProgramRef, + AcceptPoolOwnership, AppendRemotePoolAddresses, ApplyChainUpdates, ConfigureAllowlist, @@ -151,6 +156,7 @@ export class SolanaTokenManager extends TokenManager readonly #transferAdmin = new TransferAdmin() // Token pool operations + readonly #acceptPoolOwnership = new AcceptPoolOwnership() readonly #appendRemotePoolAddresses = new AppendRemotePoolAddresses() readonly #applyChainUpdates = new ApplyChainUpdates() readonly #configureAllowlist = new ConfigureAllowlist() @@ -905,7 +911,7 @@ export class SolanaTokenManager extends TokenManager * owner must accept ownership separately before the transfer takes effect. * * @see {@link transferPoolOwnership} - * TODO: Add an `@see` link for `generateUnsignedAcceptPoolOwnership` when it is implemented. + * @see {@link generateUnsignedAcceptPoolOwnership} * * @throws {@link CCTParamsInvalidError} If a pool parameter or public key is invalid. * @@ -933,7 +939,7 @@ export class SolanaTokenManager extends TokenManager * separately before the transfer takes effect. * * @see {@link generateUnsignedTransferPoolOwnership} - * TODO: Add an `@see` link for `acceptPoolOwnership` when it is implemented. + * @see {@link acceptPoolOwnership} * * @throws {@link CCIPWalletInvalidError} If `wallet` cannot sign Solana transactions. * @throws {@link CCTParamsInvalidError} If a pool parameter is invalid or the authority differs @@ -958,6 +964,62 @@ export class SolanaTokenManager extends TokenManager return this.#transferPoolOwnership.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 acceptPoolOwnership} + * @see {@link generateUnsignedTransferPoolOwnership} + * + * @throws {@link CCTParamsInvalidError} If a pool parameter or public key is invalid. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * const unsigned = await cct.generateUnsignedAcceptPoolOwnership({ + * tokenAddress: mint, + * poolType: 'burn-mint', + * payer, + * authority, + * }) + * ``` + */ + generateUnsignedAcceptPoolOwnership( + opts: GenerateAcceptPoolOwnershipParams, + ): Promise { + return this.#acceptPoolOwnership.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 generateUnsignedAcceptPoolOwnership} + * @see {@link transferPoolOwnership} + * + * @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 CCTTxFailedError} If the pool does not exist, the wallet is not the proposed + * owner, or simulation/submission fails. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * await cct.acceptPoolOwnership({ + * tokenAddress: mint, + * poolType: 'burn-mint', + * wallet, + * }) + * ``` + */ + acceptPoolOwnership( + opts: ExecuteAcceptPoolOwnershipParams, + ): Promise { + return this.#acceptPoolOwnership.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-pool-ownership.test.ts b/ccip-sdk/src/cct/solana/token-pool/operations/accept-pool-ownership.test.ts new file mode 100644 index 00000000..855b642e --- /dev/null +++ b/ccip-sdk/src/cct/solana/token-pool/operations/accept-pool-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()).generateUnsignedAcceptPoolOwnership({ + tokenAddress: TOKEN, + poolType: 'burn-mint', + payer: PAYER, + authority: AUTHORITY, + ...opts, + }) +} + +describe('AcceptPoolOwnership (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), + ).generateUnsignedAcceptPoolOwnership({ + 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 no pool owner is pending', async () => { + const cct = SolanaTokenManager.fromChain(chain(PublicKey.default.toBase58())) + + await assert.rejects( + () => + cct.generateUnsignedAcceptPoolOwnership({ + 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()).acceptPoolOwnership({ + 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()).acceptPoolOwnership({ + tokenAddress: TOKEN, + poolType: 'burn-mint', + authority: AUTHORITY, + wallet: WALLET, + }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'acceptPoolOwnership' && + err.context.param === 'authority', + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/solana/token-pool/operations/accept-pool-ownership.ts b/ccip-sdk/src/cct/solana/token-pool/operations/accept-pool-ownership.ts new file mode 100644 index 00000000..67239439 --- /dev/null +++ b/ccip-sdk/src/cct/solana/token-pool/operations/accept-pool-ownership.ts @@ -0,0 +1,127 @@ +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 AcceptPoolOwnershipParams = 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 ParsedAcceptPoolOwnershipParams = { + tokenAddress: PublicKey + poolProgram: PublicKey + payer: PublicKey + authority: PublicKey +} + +/** Parameters for unsigned Solana token pool ownership acceptance. */ +export type GenerateAcceptPoolOwnershipParams = SolanaGenerateParams + +/** Unsigned Solana token pool ownership acceptance result. */ +export type GenerateAcceptPoolOwnershipResult = UnsignedSolanaTx + +/** Parameters for executing Solana token pool ownership acceptance. */ +export type ExecuteAcceptPoolOwnershipParams = SolanaExecuteParams + +/** Result of executing Solana token pool ownership acceptance. */ +export type ExecuteAcceptPoolOwnershipResult = TransactionResult + +/** Accepts pending ownership of a Solana token pool. */ +export class AcceptPoolOwnership extends SolanaOperation< + AcceptPoolOwnershipParams, + UnsignedSolanaTx, + ParsedAcceptPoolOwnershipParams +> { + readonly name = 'acceptPoolOwnership' + + /** Parses public keys and defaults authority to payer without mutating caller params. */ + protected override parse( + params: GenerateAcceptPoolOwnershipParams, + ): ParsedAcceptPoolOwnershipParams { + 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: ParsedAcceptPoolOwnershipParams, + ): 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: ExecuteAcceptPoolOwnershipParams, + ): Promise { + const { wallet, computeUnits, parsed } = this.prepareWalletExecution(params) + + if (params.authority !== undefined) { + validateAuthorityMatchesWallet( + this.name, + parsed.authority, + wallet.publicKey, + 'acceptPoolOwnership requires authority to be the executing wallet. Use generateUnsignedAcceptPoolOwnership 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 71175299..d8bfc0b1 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-pool-ownership.ts' export * from './append-remote-pool-addresses.ts' export * from './apply-chain-updates.ts' export * from './configure-allowlist.ts'