diff --git a/ccip-sdk/src/cct/solana/index.test.ts b/ccip-sdk/src/cct/solana/index.test.ts index 5ab95cd3..f4c454a6 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.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 b86e428c..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, @@ -88,6 +90,8 @@ import { type ExecuteSetRateLimitAdminResult, type ExecuteTransferOwnershipParams, type ExecuteTransferOwnershipResult, + type GenerateAcceptOwnershipParams, + type GenerateAcceptOwnershipResult, type GenerateAppendRemotePoolAddressesParams, type GenerateAppendRemotePoolAddressesResult, type GenerateApplyChainUpdatesParams, @@ -118,6 +122,7 @@ import { type GetTokenPoolStateResult, type LockReleaseGetTokenPoolStateResult, type LockReleasePoolProgramRef, + AcceptOwnership, AppendRemotePoolAddresses, ApplyChainUpdates, ConfigureAllowlist, @@ -151,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() @@ -905,7 +911,7 @@ export class SolanaTokenManager extends TokenManager * owner must accept ownership separately before the transfer takes effect. * * @see {@link transferOwnership} - * TODO: Add an `@see` link for `generateUnsignedAcceptOwnership` when it is implemented. + * @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. @@ -934,7 +940,7 @@ export class SolanaTokenManager extends TokenManager * separately before the transfer takes effect. * * @see {@link generateUnsignedTransferOwnership} - * TODO: Add an `@see` link for `acceptOwnership` when it is implemented. + * @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 @@ -957,6 +963,62 @@ export class SolanaTokenManager extends TokenManager 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 b60fd9f2..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'