From 39983bd6ab383752c6ca28d5c39c372c3911beb6 Mon Sep 17 00:00:00 2001 From: mervin-link Date: Mon, 17 Aug 2026 17:23:31 +0800 Subject: [PATCH 01/12] feat: add transfer pool ownership op solana --- ccip-sdk/src/cct/solana/index.test.ts | 2 + ccip-sdk/src/cct/solana/index.ts | 66 +++++++ .../cct/solana/token-pool/operations/index.ts | 1 + .../transfer-pool-ownership.test.ts | 187 ++++++++++++++++++ .../operations/transfer-pool-ownership.ts | 142 +++++++++++++ 5 files changed, 398 insertions(+) create mode 100644 ccip-sdk/src/cct/solana/token-pool/operations/transfer-pool-ownership.test.ts create mode 100644 ccip-sdk/src/cct/solana/token-pool/operations/transfer-pool-ownership.ts diff --git a/ccip-sdk/src/cct/solana/index.test.ts b/ccip-sdk/src/cct/solana/index.test.ts index 47df0877..9e817b37 100644 --- a/ccip-sdk/src/cct/solana/index.test.ts +++ b/ccip-sdk/src/cct/solana/index.test.ts @@ -62,6 +62,8 @@ 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.generateUnsignedTransferPoolOwnership, 'function') + assert.equal(typeof cct.transferPoolOwnership, '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..5ab5f61d 100644 --- a/ccip-sdk/src/cct/solana/index.ts +++ b/ccip-sdk/src/cct/solana/index.ts @@ -86,6 +86,8 @@ import { type ExecuteSetChainRateLimitResult, type ExecuteSetRateLimitAdminParams, type ExecuteSetRateLimitAdminResult, + type ExecuteTransferPoolOwnershipParams, + type ExecuteTransferPoolOwnershipResult, type GenerateAppendRemotePoolAddressesParams, type GenerateAppendRemotePoolAddressesResult, type GenerateApplyChainUpdatesParams, @@ -108,6 +110,8 @@ import { type GenerateSetChainRateLimitResult, type GenerateSetRateLimitAdminParams, type GenerateSetRateLimitAdminResult, + type GenerateTransferPoolOwnershipParams, + type GenerateTransferPoolOwnershipResult, type GetTokenPoolRemotesParams, type GetTokenPoolRemotesResult, type GetTokenPoolStateParams, @@ -127,6 +131,7 @@ import { RemoveFromAllowlist, SetChainRateLimit, SetRateLimitAdmin, + TransferPoolOwnership, } from './token-pool/operations/index.ts' /** CCT admin facade for Solana. */ @@ -159,6 +164,7 @@ export class SolanaTokenManager extends TokenManager readonly #removeFromAllowlist = new RemoveFromAllowlist() readonly #setChainRateLimit = new SetChainRateLimit() readonly #setRateLimitAdmin = new SetRateLimitAdmin() + readonly #transferPoolOwnership = new TransferPoolOwnership() /** Creates a Solana CCT manager for an existing chain. */ constructor(chain: SolanaChain) { @@ -892,6 +898,66 @@ 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 transferPoolOwnership} + * TODO: Add an `@see` link for `generateUnsignedAcceptPoolOwnership` when it is implemented. + * + * @throws {@link CCTParamsInvalidError} If a pool parameter or public key is invalid. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * const unsigned = await cct.generateUnsignedTransferPoolOwnership({ + * tokenAddress: mint, + * poolType: 'burn-mint', + * proposedOwner, + * payer, + * authority, + * }) + * ``` + */ + generateUnsignedTransferPoolOwnership( + opts: GenerateTransferPoolOwnershipParams, + ): Promise { + return this.#transferPoolOwnership.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 generateUnsignedTransferPoolOwnership} + * TODO: Add an `@see` link for `acceptPoolOwnership` when it is implemented. + * + * @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 pool owner, + * or simulation/submission fails. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * await cct.transferPoolOwnership({ + * tokenAddress: mint, + * poolType: 'burn-mint', + * proposedOwner, + * wallet, + * }) + * ``` + */ + transferPoolOwnership( + opts: ExecuteTransferPoolOwnershipParams, + ): Promise { + return this.#transferPoolOwnership.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/index.ts b/ccip-sdk/src/cct/solana/token-pool/operations/index.ts index be3e686e..71175299 100644 --- a/ccip-sdk/src/cct/solana/token-pool/operations/index.ts +++ b/ccip-sdk/src/cct/solana/token-pool/operations/index.ts @@ -11,3 +11,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-pool-ownership.ts' diff --git a/ccip-sdk/src/cct/solana/token-pool/operations/transfer-pool-ownership.test.ts b/ccip-sdk/src/cct/solana/token-pool/operations/transfer-pool-ownership.test.ts new file mode 100644 index 00000000..1c439d28 --- /dev/null +++ b/ccip-sdk/src/cct/solana/token-pool/operations/transfer-pool-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 PROPOSED_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()).generateUnsignedTransferPoolOwnership({ + tokenAddress: TOKEN, + poolType: 'burn-mint', + payer: PAYER, + authority: AUTHORITY, + proposedOwner: PROPOSED_OWNER, + ...opts, + }) +} + +describe('TransferPoolOwnership (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(), + PROPOSED_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'], + [{ proposedOwner: 'invalid' }, 'proposedOwner'], + [{ proposedOwner: PublicKey.default.toBase58() }, 'proposedOwner'], + ]) { + 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({ proposedOwner: OWNER.toBase58() }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'transferPoolOwnership' && + err.context.param === 'proposedOwner' && + 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()).transferPoolOwnership({ + tokenAddress: TOKEN, + poolType: 'burn-mint', + proposedOwner: PROPOSED_OWNER, + wallet: WALLET, + }) + + assert.deepEqual(result, { hash: HASH }) + }) + + it('rejects a non-wallet authority for signed transfer', async () => { + await assert.rejects( + () => + SolanaTokenManager.fromChain(chain()).transferPoolOwnership({ + tokenAddress: TOKEN, + poolType: 'burn-mint', + proposedOwner: PROPOSED_OWNER, + authority: AUTHORITY, + wallet: WALLET, + }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'transferPoolOwnership' && + err.context.param === 'authority', + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/solana/token-pool/operations/transfer-pool-ownership.ts b/ccip-sdk/src/cct/solana/token-pool/operations/transfer-pool-ownership.ts new file mode 100644 index 00000000..80eef432 --- /dev/null +++ b/ccip-sdk/src/cct/solana/token-pool/operations/transfer-pool-ownership.ts @@ -0,0 +1,142 @@ +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 TransferPoolOwnershipParams = PoolProgramRef & { + /** Token mint address managed by the pool. */ + tokenAddress: string + /** Address proposed as the next pool owner. It must accept ownership separately. */ + proposedOwner: string + /** Current pool owner. Defaults to `payer` for single-signer transactions. */ + authority?: string +} + +type ParsedTransferPoolOwnershipParams = { + tokenAddress: PublicKey + proposedOwner: PublicKey + poolProgram: PublicKey + payer: PublicKey + authority: PublicKey +} + +/** Parameters for unsigned Solana token pool ownership transfer. */ +export type GenerateTransferPoolOwnershipParams = SolanaGenerateParams + +/** Unsigned Solana token pool ownership transfer result. */ +export type GenerateTransferPoolOwnershipResult = UnsignedSolanaTx + +/** Parameters for executing Solana token pool ownership transfer. */ +export type ExecuteTransferPoolOwnershipParams = SolanaExecuteParams + +/** Result of executing Solana token pool ownership transfer. */ +export type ExecuteTransferPoolOwnershipResult = TransactionResult + +/** Proposes a new owner for a Solana token pool. The proposed owner must accept separately. */ +export class TransferPoolOwnership extends SolanaOperation< + TransferPoolOwnershipParams, + UnsignedSolanaTx, + ParsedTransferPoolOwnershipParams +> { + readonly name = 'transferPoolOwnership' + + /** Parses public keys and defaults authority to payer without mutating caller params. */ + protected override parse( + params: GenerateTransferPoolOwnershipParams, + ): ParsedTransferPoolOwnershipParams { + const payer = parsePublicKey(this.name, 'payer', params.payer) + const proposedOwner = parsePublicKey(this.name, 'proposedOwner', params.proposedOwner) + if (proposedOwner.equals(PublicKey.default)) { + throw new CCTParamsInvalidError( + this.name, + 'proposedOwner', + 'must not be the default public key or zero address', + ) + } + + return { + tokenAddress: parsePublicKey(this.name, 'tokenAddress', params.tokenAddress), + proposedOwner, + 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: ParsedTransferPoolOwnershipParams, + ): Promise { + const { config } = await new GetTokenPoolState().query(chain, { + tokenAddress: opts.tokenAddress.toBase58(), + poolProgramAddress: opts.poolProgram.toBase58(), + }) + + if (opts.proposedOwner.equals(new PublicKey(config.owner))) { + throw new CCTParamsInvalidError( + 'transferPoolOwnership', + 'proposedOwner', + 'must not be the current pool owner', + ) + } + + const program = createTokenPoolProgram(chain, opts.poolProgram, opts.payer) + const instruction = await program.methods + .transferOwnership(opts.proposedOwner) + .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: ExecuteTransferPoolOwnershipParams, + ): Promise { + const { wallet, computeUnits, parsed } = this.prepareWalletExecution(params) + + if (params.authority !== undefined) { + validateAuthorityMatchesWallet( + this.name, + parsed.authority, + wallet.publicKey, + 'transferPoolOwnership requires authority to be the executing wallet. Use generateUnsignedTransferPoolOwnership for externally signed transactions.', + ) + } + + return submit(chain, wallet, await this.buildUnsigned(chain, parsed), this.name, computeUnits) + } +} From df46d8dbfe56f68d6fd09a33d1bc601ea31ac18e Mon Sep 17 00:00:00 2001 From: mervin-link Date: Mon, 17 Aug 2026 19:29:16 +0800 Subject: [PATCH 02/12] 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' From d296442df8683775283c3e130487879624bf347e Mon Sep 17 00:00:00 2001 From: mervin-link Date: Tue, 18 Aug 2026 01:29:18 +0800 Subject: [PATCH 03/12] feat: add transfer authority op solana --- ccip-sdk/src/cct/solana/index.test.ts | 20 +- ccip-sdk/src/cct/solana/index.ts | 70 +++++ .../token-admin-registry/operations/index.ts | 8 +- .../operations/register-admin.ts | 2 +- .../src/cct/solana/token/operations/index.ts | 7 + .../operations/transfer-authority.test.ts | 242 ++++++++++++++++++ .../token/operations/transfer-authority.ts | 174 +++++++++++++ 7 files changed, 520 insertions(+), 3 deletions(-) create mode 100644 ccip-sdk/src/cct/solana/token/operations/transfer-authority.test.ts create mode 100644 ccip-sdk/src/cct/solana/token/operations/transfer-authority.ts diff --git a/ccip-sdk/src/cct/solana/index.test.ts b/ccip-sdk/src/cct/solana/index.test.ts index 1ecf1c4e..fa2f77d1 100644 --- a/ccip-sdk/src/cct/solana/index.test.ts +++ b/ccip-sdk/src/cct/solana/index.test.ts @@ -3,7 +3,13 @@ import { describe, it } from 'node:test' import { Connection } from '@solana/web3.js' -import { SolanaTokenManager } from './index.ts' +import { + type RegisterAdminMethod, + type TokenAuthorityType, + REGISTER_ADMIN_METHODS, + SolanaTokenManager, + TOKEN_AUTHORITY_TYPES, +} from './index.ts' import type { GetTokenPoolStateParams, GetTokenPoolStateResult, @@ -28,6 +34,8 @@ describe('SolanaTokenManager (cct/solana)', () => { assert.equal(typeof cct.deployToken, 'function') assert.equal(typeof cct.generateUnsignedCreateTokenAccount, 'function') assert.equal(typeof cct.createTokenAccount, 'function') + assert.equal(typeof cct.generateUnsignedTransferAuthority, 'function') + assert.equal(typeof cct.transferAuthority, 'function') // Token admin registry operations assert.equal(typeof cct.generateUnsignedAcceptAdmin, 'function') @@ -74,6 +82,16 @@ describe('SolanaTokenManager (cct/solana)', () => { assert.equal(typeof cct.getTokenPoolState, 'function') }) + it('exports public operation constants', () => { + const authorityType: TokenAuthorityType = TOKEN_AUTHORITY_TYPES.MINT + const registrationMethod: RegisterAdminMethod = REGISTER_ADMIN_METHODS.OWNER + + assert.equal(authorityType, 'mint') + assert.equal(TOKEN_AUTHORITY_TYPES.FREEZE, 'freeze') + assert.equal(registrationMethod, 'owner') + assert.equal(REGISTER_ADMIN_METHODS.CCIP_ADMIN, 'ccip-admin') + }) + it('creates from a connection provider', async (t) => { const chain = stubChain() const connection = new Connection('http://localhost:8899') diff --git a/ccip-sdk/src/cct/solana/index.ts b/ccip-sdk/src/cct/solana/index.ts index 067525e0..9e92022b 100644 --- a/ccip-sdk/src/cct/solana/index.ts +++ b/ccip-sdk/src/cct/solana/index.ts @@ -17,11 +17,16 @@ import { type ExecuteCreateTokenAccountResult, type ExecuteDeployTokenParams, type ExecuteDeployTokenResult, + type ExecuteTransferAuthorityParams, + type ExecuteTransferAuthorityResult, type GenerateCreateTokenAccountParams, type GenerateCreateTokenAccountResult, type GenerateDeployTokenParams, type GenerateDeployTokenResult, + type GenerateTransferAuthorityParams, + type GenerateTransferAuthorityResult, CreateTokenAccount, + TransferAuthority, } from './token/operations/index.ts' import { type ExecuteAcceptAdminParams, @@ -144,6 +149,7 @@ export class SolanaTokenManager extends TokenManager readonly chain: SolanaChain // Token operations readonly #createTokenAccount = new CreateTokenAccount() + readonly #transferAuthority = new TransferAuthority() // Token admin registry operations readonly #acceptAdmin = new AcceptAdmin() @@ -309,6 +315,66 @@ export class SolanaTokenManager extends TokenManager return this.#createTokenAccount.execute(this.chain, opts) } + /** + * Builds unsigned instructions for an immediate SPL Token mint and/or freeze authority transfer. + * + * @remarks + * Once confirmed, the current authority loses the selected roles. Set `authorityTypes` to + * `['mint']`, `['freeze']`, or both. Set `newAuthority` to null to permanently revoke the selected + * roles; a revoked role cannot be transferred or restored. All selected roles must have the same + * current authority. The instructions are atomic: no role changes if any selected transfer fails. + * `authority` defaults to `payer`. For an SPL Token multisig authority, provide `multisigSigners` + * and collect member signatures externally. + * + * @throws {@link CCTParamsInvalidError} If an address or authority role selection is invalid. + * @throws {@link CCIPTokenMintNotFoundError} If the mint does not exist. + * @throws {@link CCIPTokenMintInvalidError} If the mint is not owned by an SPL Token program. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * const unsigned = await cct.generateUnsignedTransferAuthority({ + * payer: currentAuthority, + * tokenAddress: mint, + * newAuthority, + * authorityTypes: ['mint'], + * }) + * ``` + */ + generateUnsignedTransferAuthority( + opts: GenerateTransferAuthorityParams, + ): Promise { + return this.#transferAuthority.generate(this.chain, opts) + } + + /** + * Immediately transfers SPL Token mint and/or freeze authority using the executing wallet. + * + * @remarks + * Once confirmed, the current authority loses the selected roles. Set `authorityTypes` to + * `['mint']`, `['freeze']`, or both. Set `newAuthority` to null to permanently revoke the selected + * roles; a revoked role cannot be transferred or restored. All selected roles must have the same + * current authority. The transaction is atomic: no role changes if any selected transfer fails. + * SPL Token multisig authorities require `multisigSigners` and external member signatures; use + * {@link generateUnsignedTransferAuthority}. + * + * @throws {@link CCIPWalletInvalidError} If `wallet` cannot sign Solana transactions. + * @throws {@link CCTParamsInvalidError} If an address or authority role selection is invalid, or + * `authority` does not match the executing wallet. + * @throws {@link CCIPTokenMintNotFoundError} If the mint does not exist. + * @throws {@link CCIPTokenMintInvalidError} If the mint is not owned by an SPL Token program. + * @throws {@link CCTTxFailedError} If simulation or the SPL Token program rejects the transaction. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * await cct.transferAuthority({ wallet, tokenAddress: mint, newAuthority, authorityTypes: ['mint'] }) + * ``` + */ + transferAuthority(opts: ExecuteTransferAuthorityParams): Promise { + return this.#transferAuthority.execute(this.chain, opts) + } + /** * Builds unsigned SPL Token multisig creation instructions. * The pool signer PDA occupies `threshold` slots; non-pool signers must meet the threshold independently. @@ -1674,6 +1740,10 @@ export { } from './programs/token-pool.ts' export type { TransactionResult } from '../operation.ts' export type { SerializedSolanaTxEncoding } from './serialize.ts' +export { TOKEN_AUTHORITY_TYPES } from './token/operations/transfer-authority.ts' +export { REGISTER_ADMIN_METHODS } from './token-admin-registry/operations/register-admin.ts' +export type { TokenAuthorityType } from './token/operations/transfer-authority.ts' +export type { RegisterAdminMethod } from './token-admin-registry/operations/register-admin.ts' export type * from './token/operations/index.ts' export type * from './token-pool/operations/index.ts' export type * from './token-admin-registry/operations/index.ts' diff --git a/ccip-sdk/src/cct/solana/token-admin-registry/operations/index.ts b/ccip-sdk/src/cct/solana/token-admin-registry/operations/index.ts index 0437d089..67164ffe 100644 --- a/ccip-sdk/src/cct/solana/token-admin-registry/operations/index.ts +++ b/ccip-sdk/src/cct/solana/token-admin-registry/operations/index.ts @@ -3,6 +3,12 @@ export * from './append-to-lookup-table.ts' export * from './create-lookup-table.ts' export * from './get-supported-tokens.ts' export * from './get-token-admin-registry.ts' -export * from './register-admin.ts' +export { RegisterAdmin } from './register-admin.ts' +export type { + ExecuteRegisterAdminParams, + ExecuteRegisterAdminResult, + GenerateRegisterAdminParams, + GenerateRegisterAdminResult, +} from './register-admin.ts' export * from './set-pool.ts' export * from './transfer-admin.ts' diff --git a/ccip-sdk/src/cct/solana/token-admin-registry/operations/register-admin.ts b/ccip-sdk/src/cct/solana/token-admin-registry/operations/register-admin.ts index 3df93173..d77c6892 100644 --- a/ccip-sdk/src/cct/solana/token-admin-registry/operations/register-admin.ts +++ b/ccip-sdk/src/cct/solana/token-admin-registry/operations/register-admin.ts @@ -21,7 +21,7 @@ import { submit } from '../../submit.ts' import { parsePublicKey, validateAuthorityMatchesWallet } from '../../validate.ts' /** Authorization paths used to register a token in the TokenAdminRegistry. */ -const REGISTER_ADMIN_METHODS = { +export const REGISTER_ADMIN_METHODS = { OWNER: 'owner', CCIP_ADMIN: 'ccip-admin', } as const diff --git a/ccip-sdk/src/cct/solana/token/operations/index.ts b/ccip-sdk/src/cct/solana/token/operations/index.ts index e397c117..1e31568a 100644 --- a/ccip-sdk/src/cct/solana/token/operations/index.ts +++ b/ccip-sdk/src/cct/solana/token/operations/index.ts @@ -1,2 +1,9 @@ export * from './create-token-account.ts' export * from './deploy-token.ts' +export { TransferAuthority } from './transfer-authority.ts' +export type { + ExecuteTransferAuthorityParams, + ExecuteTransferAuthorityResult, + GenerateTransferAuthorityParams, + GenerateTransferAuthorityResult, +} from './transfer-authority.ts' diff --git a/ccip-sdk/src/cct/solana/token/operations/transfer-authority.test.ts b/ccip-sdk/src/cct/solana/token/operations/transfer-authority.test.ts new file mode 100644 index 00000000..838f6503 --- /dev/null +++ b/ccip-sdk/src/cct/solana/token/operations/transfer-authority.test.ts @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { Keypair, PublicKey } from '@solana/web3.js' + +import { CCIPTokenMintInvalidError, CCIPTokenMintNotFoundError } from '../../../../errors/index.ts' +import { ChainFamily } from '../../../../networks.ts' +import type { SolanaChain } from '../../../../solana/index.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import { SolanaTokenManager } from '../../index.ts' + +const TOKEN = Keypair.generate().publicKey.toBase58() +const PAYER = Keypair.generate().publicKey.toBase58() +const AUTHORITY = Keypair.generate().publicKey.toBase58() +const NEW_AUTHORITY = Keypair.generate().publicKey.toBase58() +const MULTISIG = Keypair.generate().publicKey.toBase58() +const MULTISIG_SIGNER_1 = Keypair.generate().publicKey.toBase58() +const MULTISIG_SIGNER_2 = Keypair.generate().publicKey.toBase58() +const HASH = Keypair.generate().publicKey.toBase58() +const WALLET = { + publicKey: Keypair.generate().publicKey, + signTransaction: async (tx: T) => tx, +} + +function chain(mintOwner: PublicKey | null = TOKEN_PROGRAM_ID): SolanaChain { + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + connection: { + getAccountInfo: async () => (mintOwner ? { owner: mintOwner } : null), + }, + } as unknown as SolanaChain +} + +function submitChain(): SolanaChain { + return { + ...chain(), + connection: { + getAccountInfo: async () => ({ owner: TOKEN_PROGRAM_ID }), + simulateTransaction: async () => ({ value: { err: null, logs: [], unitsConsumed: 1 } }), + getLatestBlockhash: async () => ({ + blockhash: PublicKey.default.toBase58(), + lastValidBlockHeight: 1, + }), + sendTransaction: async () => HASH, + confirmTransaction: async () => ({ value: { err: null } }), + }, + } as unknown as SolanaChain +} + +function generate(opts: Record = {}, mintOwner?: PublicKey | null) { + return SolanaTokenManager.fromChain(chain(mintOwner)).generateUnsignedTransferAuthority({ + tokenAddress: TOKEN, + payer: PAYER, + authority: AUTHORITY, + newAuthority: NEW_AUTHORITY, + authorityTypes: ['mint', 'freeze'], + ...opts, + }) +} + +describe('TransferAuthority (cct/solana)', () => { + describe('generate', () => { + it('builds selected mint and freeze authority transfers', async () => { + const unsigned = await generate() + + assert.equal(unsigned.family, ChainFamily.Solana) + assert.equal(unsigned.mainIndex, 0) + assert.equal(unsigned.instructions.length, 2) + assert.deepEqual( + unsigned.instructions.map((instruction) => ({ + programId: instruction.programId.toBase58(), + authorityType: instruction.data[1], + mint: instruction.keys[0]!.pubkey.toBase58(), + authority: instruction.keys[1]!.pubkey.toBase58(), + newAuthority: instruction.data.subarray(3).toString('hex'), + })), + [ + { + programId: TOKEN_PROGRAM_ID.toBase58(), + authorityType: 0, // MintTokens + mint: TOKEN, + authority: AUTHORITY, + newAuthority: new PublicKey(NEW_AUTHORITY).toBuffer().toString('hex'), + }, + { + programId: TOKEN_PROGRAM_ID.toBase58(), + authorityType: 1, // FreezeAccount + mint: TOKEN, + authority: AUTHORITY, + newAuthority: new PublicKey(NEW_AUTHORITY).toBuffer().toString('hex'), + }, + ], + ) + }) + + it('builds only the selected authority transfer for Token-2022', async () => { + const unsigned = await generate({ authorityTypes: ['freeze'] }, TOKEN_2022_PROGRAM_ID) + + assert.equal(unsigned.instructions.length, 1) + assert.equal(unsigned.instructions[0]!.programId.toBase58(), TOKEN_2022_PROGRAM_ID.toBase58()) + assert.equal(unsigned.instructions[0]!.data[1], 1) // FreezeAccount + }) + + it('includes SPL multisig member signers', async () => { + const unsigned = await generate({ + authority: MULTISIG, + authorityTypes: ['mint'], + multisigSigners: [MULTISIG_SIGNER_1, MULTISIG_SIGNER_2], + }) + + assert.deepEqual( + unsigned.instructions[0]!.keys.map(({ pubkey, isSigner, isWritable }) => ({ + pubkey: pubkey.toBase58(), + isSigner, + isWritable, + })), + [ + { pubkey: TOKEN, isSigner: false, isWritable: true }, + { pubkey: MULTISIG, isSigner: false, isWritable: false }, + { pubkey: MULTISIG_SIGNER_1, isSigner: true, isWritable: false }, + { pubkey: MULTISIG_SIGNER_2, isSigner: true, isWritable: false }, + ], + ) + }) + + it('builds authority revocation with a null new authority', async () => { + const unsigned = await generate({ authorityTypes: ['mint'], newAuthority: null }) + const [instruction] = unsigned.instructions + + assert.ok(instruction) + assert.equal(instruction.data[0], 6) // SetAuthority + assert.equal(instruction.data[1], 0) // MintTokens + assert.equal(instruction.data[2], 0) // COption::None + assert.equal(instruction.data.length, 3) + }) + + it('defaults authority to payer', async () => { + const unsigned = await generate({ authority: undefined }) + + assert.equal(unsigned.instructions[0]!.keys[1]!.pubkey.toBase58(), PAYER) + }) + }) + + describe('validation', () => { + it('rejects invalid public keys', async () => { + for (const param of ['tokenAddress', 'newAuthority', 'authority']) { + await assert.rejects( + () => generate({ [param]: 'invalid' }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === param, + ) + } + }) + + it('rejects invalid multisig signers', async () => { + for (const [multisigSigners, param] of [ + ['invalid', 'multisigSigners'], + [['invalid'], 'multisigSigners[0]'], + ]) { + await assert.rejects( + () => generate({ multisigSigners }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === param, + ) + } + }) + + it('reports invalid authority role selections', async () => { + const cases: [unknown, string][] = [ + [undefined, 'must be an array'], + [[], 'must not be empty'], + [['mint', 'mint'], 'must not contain duplicates'], + [['close'], 'must contain only mint and/or freeze'], + ] + for (const [authorityTypes, message] of cases) { + await assert.rejects( + () => generate({ authorityTypes }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.param === 'authorityTypes' && + err.message.includes(message), + ) + } + }) + + it('rejects missing and non-token mints', async () => { + await assert.rejects( + () => generate({}, null), + (err: unknown) => err instanceof CCIPTokenMintNotFoundError, + ) + await assert.rejects( + () => generate({}, Keypair.generate().publicKey), + (err: unknown) => err instanceof CCIPTokenMintInvalidError, + ) + }) + }) + + describe('execute', () => { + it('signs, submits, and returns the tx hash', async () => { + const result = await SolanaTokenManager.fromChain(submitChain()).transferAuthority({ + tokenAddress: TOKEN, + newAuthority: NEW_AUTHORITY, + authorityTypes: ['mint'], + wallet: WALLET, + }) + + assert.deepEqual(result, { hash: HASH }) + }) + + it('requires unsigned generation for SPL multisig authorities', async () => { + await assert.rejects( + () => + SolanaTokenManager.fromChain(chain()).transferAuthority({ + tokenAddress: TOKEN, + newAuthority: NEW_AUTHORITY, + authority: MULTISIG, + authorityTypes: ['mint'], + multisigSigners: [MULTISIG_SIGNER_1], + wallet: WALLET, + }), + (err: unknown) => + err instanceof CCTParamsInvalidError && err.context.param === 'multisigSigners', + ) + }) + + it('rejects a non-wallet authority for signed transfer', async () => { + await assert.rejects( + () => + SolanaTokenManager.fromChain(chain()).transferAuthority({ + tokenAddress: TOKEN, + newAuthority: NEW_AUTHORITY, + authority: AUTHORITY, + authorityTypes: ['mint'], + wallet: WALLET, + }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'transferAuthority' && + err.context.param === 'authority', + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/solana/token/operations/transfer-authority.ts b/ccip-sdk/src/cct/solana/token/operations/transfer-authority.ts new file mode 100644 index 00000000..77683d0d --- /dev/null +++ b/ccip-sdk/src/cct/solana/token/operations/transfer-authority.ts @@ -0,0 +1,174 @@ +import { AuthorityType, createSetAuthorityInstruction } from '@solana/spl-token' +import type { PublicKey, TransactionInstruction } from '@solana/web3.js' + +import { ChainFamily } from '../../../../networks.ts' +import type { SolanaChain } from '../../../../solana/index.ts' +import type { UnsignedSolanaTx } from '../../../../solana/types.ts' +import { resolveTokenProgram } from '../../../../solana/utils.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import type { TransactionResult } from '../../../operation.ts' +import { + type SolanaExecuteParams, + type SolanaGenerateParams, + SolanaOperation, +} from '../../operation.ts' +import { submit } from '../../submit.ts' +import { parsePublicKey, validateAuthorityMatchesWallet } from '../../validate.ts' + +/** SPL Token authority roles that can be transferred. */ +export const TOKEN_AUTHORITY_TYPES = { + MINT: 'mint', + FREEZE: 'freeze', +} as const + +/** SPL Token authority role that can be transferred. */ +export type TokenAuthorityType = (typeof TOKEN_AUTHORITY_TYPES)[keyof typeof TOKEN_AUTHORITY_TYPES] + +type TransferAuthorityParams = { + /** SPL token mint address. */ + tokenAddress: string + /** Address to receive the selected authority roles, or null to revoke them permanently. */ + newAuthority: string | null + /** Current authority. Defaults to `payer` for single-signer transactions. */ + authority?: string + /** SPL Token multisig member addresses. Required when authority is an SPL Token multisig. */ + multisigSigners?: string[] + /** Authority roles to transfer. */ + authorityTypes: TokenAuthorityType[] +} + +type ParsedTransferAuthorityParams = { + tokenAddress: PublicKey + newAuthority: PublicKey | null + authority: PublicKey + multisigSigners: PublicKey[] + authorityTypes: TokenAuthorityType[] +} + +/** Parameters for unsigned Solana SPL Token authority transfer. */ +export type GenerateTransferAuthorityParams = SolanaGenerateParams + +/** Unsigned Solana SPL Token authority transfer result. */ +export type GenerateTransferAuthorityResult = UnsignedSolanaTx + +/** Parameters for executing Solana SPL Token authority transfer. */ +export type ExecuteTransferAuthorityParams = SolanaExecuteParams + +/** Result of executing Solana SPL Token authority transfer. */ +export type ExecuteTransferAuthorityResult = TransactionResult + +const SPL_AUTHORITY_TYPES: Record = { + mint: AuthorityType.MintTokens, + freeze: AuthorityType.FreezeAccount, +} + +/** + * Immediately transfers mint authority, freeze authority, or both for an SPL Token mint; there is + * no propose-and-accept step. + * + * @remarks + * Once confirmed, the current authority loses the selected roles. All selected roles must have the + * same current authority. Supply `multisigSigners` when that authority is an SPL Token multisig. Set + * `newAuthority` to null to revoke the selected roles permanently; a revoked mint or freeze + * authority cannot be transferred. The instructions share one atomic Solana transaction, so no role + * changes if any selected transfer fails. + */ +export class TransferAuthority extends SolanaOperation< + TransferAuthorityParams, + UnsignedSolanaTx, + ParsedTransferAuthorityParams +> { + readonly name = 'transferAuthority' + + /** Parses public keys and validates the selected authority roles. */ + protected override parse(params: GenerateTransferAuthorityParams): ParsedTransferAuthorityParams { + const authorityTypes = params.authorityTypes + if (!Array.isArray(authorityTypes)) { + throw new CCTParamsInvalidError(this.name, 'authorityTypes', 'must be an array') + } + if (authorityTypes.length === 0) { + throw new CCTParamsInvalidError(this.name, 'authorityTypes', 'must not be empty') + } + if (new Set(authorityTypes).size !== authorityTypes.length) { + throw new CCTParamsInvalidError(this.name, 'authorityTypes', 'must not contain duplicates') + } + if (authorityTypes.some((type) => !Object.values(TOKEN_AUTHORITY_TYPES).includes(type))) { + throw new CCTParamsInvalidError( + this.name, + 'authorityTypes', + 'must contain only mint and/or freeze', + ) + } + + if (params.multisigSigners !== undefined && !Array.isArray(params.multisigSigners)) { + throw new CCTParamsInvalidError(this.name, 'multisigSigners', 'must be an array') + } + + const payer = parsePublicKey(this.name, 'payer', params.payer) + return { + tokenAddress: parsePublicKey(this.name, 'tokenAddress', params.tokenAddress), + newAuthority: + params.newAuthority === null + ? null + : parsePublicKey(this.name, 'newAuthority', params.newAuthority), + authority: + params.authority === undefined + ? payer + : parsePublicKey(this.name, 'authority', params.authority), + multisigSigners: (params.multisigSigners ?? []).map((signer, i) => + parsePublicKey(this.name, `multisigSigners[${i}]`, signer), + ), + authorityTypes, + } + } + + /** Builds one SPL Token `SetAuthority` instruction for each selected authority role. */ + protected async buildUnsigned( + chain: SolanaChain, + opts: ParsedTransferAuthorityParams, + ): Promise { + const tokenProgram = await resolveTokenProgram(chain.connection, opts.tokenAddress) + const instructions: TransactionInstruction[] = opts.authorityTypes.map((authorityType) => + createSetAuthorityInstruction( + opts.tokenAddress, + opts.authority, + SPL_AUTHORITY_TYPES[authorityType], + opts.newAuthority, + opts.multisigSigners, + tokenProgram, + ), + ) + + chain.logger.debug( + `${this.name}: token = ${opts.tokenAddress.toBase58()}, authorityTypes = ${opts.authorityTypes.join(',')}, newAuthority = ${opts.newAuthority?.toBase58() ?? 'revoked'}`, + ) + return { family: ChainFamily.Solana, instructions, mainIndex: 0 } + } + + /** Generate, sign, simulate, send, and confirm with the current authority wallet. */ + override async execute( + chain: SolanaChain, + params: ExecuteTransferAuthorityParams, + ): Promise { + const { wallet, computeUnits, parsed } = this.prepareWalletExecution(params) + + if (parsed.multisigSigners.length > 0) { + throw new CCTParamsInvalidError( + this.name, + 'multisigSigners', + 'requires externally signed transactions; use generateUnsignedTransferAuthority', + ) + } + + if (params.authority !== undefined) { + validateAuthorityMatchesWallet( + this.name, + parsed.authority, + wallet.publicKey, + 'transferAuthority requires authority to be the executing wallet. Use generateUnsignedTransferAuthority for externally signed transactions.', + ) + } + + return submit(chain, wallet, await this.buildUnsigned(chain, parsed), this.name, computeUnits) + } +} From 5547e60f14038413dd436781ea86660cc17a9b20 Mon Sep 17 00:00:00 2001 From: mervin-link Date: Tue, 18 Aug 2026 15:46:48 +0800 Subject: [PATCH 04/12] fix: update export barrel --- ccip-sdk/src/cct/solana/index.ts | 6 ++---- .../src/cct/solana/token-admin-registry/operations/index.ts | 1 + ccip-sdk/src/cct/solana/token/operations/index.ts | 1 + 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ccip-sdk/src/cct/solana/index.ts b/ccip-sdk/src/cct/solana/index.ts index 9e92022b..d2a88cb2 100644 --- a/ccip-sdk/src/cct/solana/index.ts +++ b/ccip-sdk/src/cct/solana/index.ts @@ -1738,12 +1738,10 @@ export { deriveTokenPoolSignerPda, resolveTokenPoolProgram, } from './programs/token-pool.ts' -export type { TransactionResult } from '../operation.ts' -export type { SerializedSolanaTxEncoding } from './serialize.ts' export { TOKEN_AUTHORITY_TYPES } from './token/operations/transfer-authority.ts' export { REGISTER_ADMIN_METHODS } from './token-admin-registry/operations/register-admin.ts' -export type { TokenAuthorityType } from './token/operations/transfer-authority.ts' -export type { RegisterAdminMethod } from './token-admin-registry/operations/register-admin.ts' +export type { TransactionResult } from '../operation.ts' +export type { SerializedSolanaTxEncoding } from './serialize.ts' export type * from './token/operations/index.ts' export type * from './token-pool/operations/index.ts' export type * from './token-admin-registry/operations/index.ts' diff --git a/ccip-sdk/src/cct/solana/token-admin-registry/operations/index.ts b/ccip-sdk/src/cct/solana/token-admin-registry/operations/index.ts index 67164ffe..303f5e54 100644 --- a/ccip-sdk/src/cct/solana/token-admin-registry/operations/index.ts +++ b/ccip-sdk/src/cct/solana/token-admin-registry/operations/index.ts @@ -9,6 +9,7 @@ export type { ExecuteRegisterAdminResult, GenerateRegisterAdminParams, GenerateRegisterAdminResult, + RegisterAdminMethod, } from './register-admin.ts' export * from './set-pool.ts' export * from './transfer-admin.ts' diff --git a/ccip-sdk/src/cct/solana/token/operations/index.ts b/ccip-sdk/src/cct/solana/token/operations/index.ts index 1e31568a..255c8d3f 100644 --- a/ccip-sdk/src/cct/solana/token/operations/index.ts +++ b/ccip-sdk/src/cct/solana/token/operations/index.ts @@ -6,4 +6,5 @@ export type { ExecuteTransferAuthorityResult, GenerateTransferAuthorityParams, GenerateTransferAuthorityResult, + TokenAuthorityType, } from './transfer-authority.ts' From 126ec98649a35c94f633435d92d1fa44c1a07e25 Mon Sep 17 00:00:00 2001 From: mervin-link Date: Wed, 19 Aug 2026 00:53:06 +0800 Subject: [PATCH 05/12] feat: add mint tokens op solana --- ccip-sdk/src/cct/solana/index.test.ts | 2 + ccip-sdk/src/cct/solana/index.ts | 63 +++++ .../src/cct/solana/token/operations/index.ts | 1 + .../token/operations/mint-tokens.test.ts | 216 ++++++++++++++++++ .../solana/token/operations/mint-tokens.ts | 149 ++++++++++++ 5 files changed, 431 insertions(+) create mode 100644 ccip-sdk/src/cct/solana/token/operations/mint-tokens.test.ts create mode 100644 ccip-sdk/src/cct/solana/token/operations/mint-tokens.ts diff --git a/ccip-sdk/src/cct/solana/index.test.ts b/ccip-sdk/src/cct/solana/index.test.ts index fa2f77d1..cccd73d6 100644 --- a/ccip-sdk/src/cct/solana/index.test.ts +++ b/ccip-sdk/src/cct/solana/index.test.ts @@ -34,6 +34,8 @@ describe('SolanaTokenManager (cct/solana)', () => { assert.equal(typeof cct.deployToken, 'function') assert.equal(typeof cct.generateUnsignedCreateTokenAccount, 'function') assert.equal(typeof cct.createTokenAccount, 'function') + assert.equal(typeof cct.generateUnsignedMintTokens, 'function') + assert.equal(typeof cct.mintTokens, 'function') assert.equal(typeof cct.generateUnsignedTransferAuthority, 'function') assert.equal(typeof cct.transferAuthority, 'function') diff --git a/ccip-sdk/src/cct/solana/index.ts b/ccip-sdk/src/cct/solana/index.ts index 9e92022b..c1527f3a 100644 --- a/ccip-sdk/src/cct/solana/index.ts +++ b/ccip-sdk/src/cct/solana/index.ts @@ -17,15 +17,20 @@ import { type ExecuteCreateTokenAccountResult, type ExecuteDeployTokenParams, type ExecuteDeployTokenResult, + type ExecuteMintTokensParams, + type ExecuteMintTokensResult, type ExecuteTransferAuthorityParams, type ExecuteTransferAuthorityResult, type GenerateCreateTokenAccountParams, type GenerateCreateTokenAccountResult, type GenerateDeployTokenParams, type GenerateDeployTokenResult, + type GenerateMintTokensParams, + type GenerateMintTokensResult, type GenerateTransferAuthorityParams, type GenerateTransferAuthorityResult, CreateTokenAccount, + MintTokens, TransferAuthority, } from './token/operations/index.ts' import { @@ -149,6 +154,7 @@ export class SolanaTokenManager extends TokenManager readonly chain: SolanaChain // Token operations readonly #createTokenAccount = new CreateTokenAccount() + readonly #mintTokens = new MintTokens() readonly #transferAuthority = new TransferAuthority() // Token admin registry operations @@ -315,6 +321,63 @@ export class SolanaTokenManager extends TokenManager return this.#createTokenAccount.execute(this.chain, opts) } + /** + * Builds unsigned instructions to mint SPL tokens to a recipient's existing associated token account. + * + * @remarks + * `amount` is in base units. The recipient ATA must already exist; use + * {@link generateUnsignedCreateTokenAccount} to create it. `authority` defaults to `payer`. For + * an SPL Token multisig authority, provide `multisigSigners` and collect member signatures + * externally. + * + * @throws {@link CCTParamsInvalidError} If an address, amount, or multisig signer is invalid. + * @throws {@link CCIPTokenMintNotFoundError} If the mint does not exist. + * @throws {@link CCIPTokenMintInvalidError} If the mint is not owned by an SPL Token program. + * @throws {@link CCIPTokenAccountNotFoundError} If the recipient ATA is missing; create it first + * with {@link generateUnsignedCreateTokenAccount}. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * const unsigned = await cct.generateUnsignedMintTokens({ + * payer: mintAuthority, + * tokenAddress: mint, + * recipient, + * amount: 1_000_000n, + * }) + * ``` + */ + generateUnsignedMintTokens(opts: GenerateMintTokensParams): Promise { + return this.#mintTokens.generate(this.chain, opts) + } + + /** + * Mints SPL tokens to a recipient's existing associated token account using the executing wallet. + * + * @remarks + * `amount` is in base units. The recipient ATA must already exist; use {@link createTokenAccount} + * to create it. SPL Token multisig authorities require `multisigSigners` and external member + * signatures; use {@link generateUnsignedMintTokens}. + * + * @throws {@link CCIPWalletInvalidError} If `wallet` cannot sign Solana transactions. + * @throws {@link CCTParamsInvalidError} If an address, amount, or multisig signer is invalid, or + * `authority` does not match the executing wallet. + * @throws {@link CCIPTokenMintNotFoundError} If the mint does not exist. + * @throws {@link CCIPTokenMintInvalidError} If the mint is not owned by an SPL Token program. + * @throws {@link CCIPTokenAccountNotFoundError} If the recipient ATA is missing; create it first + * with {@link createTokenAccount}. + * @throws {@link CCTTxFailedError} If simulation or the SPL Token program rejects the transaction. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * await cct.mintTokens({ wallet, tokenAddress: mint, recipient, amount: 1_000_000n }) + * ``` + */ + mintTokens(opts: ExecuteMintTokensParams): Promise { + return this.#mintTokens.execute(this.chain, opts) + } + /** * Builds unsigned instructions for an immediate SPL Token mint and/or freeze authority transfer. * diff --git a/ccip-sdk/src/cct/solana/token/operations/index.ts b/ccip-sdk/src/cct/solana/token/operations/index.ts index 1e31568a..cdcde4d3 100644 --- a/ccip-sdk/src/cct/solana/token/operations/index.ts +++ b/ccip-sdk/src/cct/solana/token/operations/index.ts @@ -1,5 +1,6 @@ export * from './create-token-account.ts' export * from './deploy-token.ts' +export * from './mint-tokens.ts' export { TransferAuthority } from './transfer-authority.ts' export type { ExecuteTransferAuthorityParams, diff --git a/ccip-sdk/src/cct/solana/token/operations/mint-tokens.test.ts b/ccip-sdk/src/cct/solana/token/operations/mint-tokens.test.ts new file mode 100644 index 00000000..39b77187 --- /dev/null +++ b/ccip-sdk/src/cct/solana/token/operations/mint-tokens.test.ts @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, + getAssociatedTokenAddressSync, +} from '@solana/spl-token' +import { Keypair, PublicKey } from '@solana/web3.js' + +import { + CCIPTokenAccountNotFoundError, + CCIPTokenMintInvalidError, + CCIPTokenMintNotFoundError, +} from '../../../../errors/index.ts' +import { ChainFamily } from '../../../../networks.ts' +import type { SolanaChain } from '../../../../solana/index.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import { SolanaTokenManager } from '../../index.ts' + +const TOKEN = Keypair.generate().publicKey +const PAYER = Keypair.generate().publicKey.toBase58() +const AUTHORITY = Keypair.generate().publicKey.toBase58() +const RECIPIENT = Keypair.generate().publicKey +const MULTISIG = Keypair.generate().publicKey.toBase58() +const MULTISIG_SIGNER_1 = Keypair.generate().publicKey.toBase58() +const MULTISIG_SIGNER_2 = Keypair.generate().publicKey.toBase58() +const HASH = Keypair.generate().publicKey.toBase58() +const WALLET = { + publicKey: Keypair.generate().publicKey, + signTransaction: async (tx: T) => tx, +} + +function chain(mintOwner: PublicKey | null = TOKEN_PROGRAM_ID): SolanaChain { + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + connection: { + getAccountInfo: async () => + mintOwner ? { owner: mintOwner, data: Buffer.alloc(165) } : null, + }, + } as unknown as SolanaChain +} + +function submitChain(): SolanaChain { + return { + ...chain(), + connection: { + getAccountInfo: async () => ({ owner: TOKEN_PROGRAM_ID, data: Buffer.alloc(165) }), + simulateTransaction: async () => ({ value: { err: null, logs: [], unitsConsumed: 1 } }), + getLatestBlockhash: async () => ({ + blockhash: PublicKey.default.toBase58(), + lastValidBlockHeight: 1, + }), + sendTransaction: async () => HASH, + confirmTransaction: async () => ({ value: { err: null } }), + }, + } as unknown as SolanaChain +} + +function generate(opts: Record = {}, mintOwner?: PublicKey | null) { + return SolanaTokenManager.fromChain(chain(mintOwner)).generateUnsignedMintTokens({ + payer: PAYER, + tokenAddress: TOKEN.toBase58(), + recipient: RECIPIENT.toBase58(), + amount: 1_000_000n, + authority: AUTHORITY, + ...opts, + }) +} + +describe('MintTokens (cct/solana)', () => { + describe('generate', () => { + it('mints to the recipient ATA using the detected token program', async () => { + const unsigned = await generate() + const [instruction] = unsigned.instructions + const ata = getAssociatedTokenAddressSync(TOKEN, RECIPIENT, true, TOKEN_PROGRAM_ID) + + assert.ok(instruction) + assert.equal(unsigned.family, ChainFamily.Solana) + assert.equal(unsigned.mainIndex, 0) + assert.equal(instruction.programId.toBase58(), TOKEN_PROGRAM_ID.toBase58()) + assert.equal(instruction.data[0], 7) // MintTo + assert.equal(instruction.keys[0]!.pubkey.toBase58(), TOKEN.toBase58()) + assert.equal(instruction.keys[1]!.pubkey.toBase58(), ata.toBase58()) + assert.equal(instruction.keys[2]!.pubkey.toBase58(), AUTHORITY) + assert.equal(instruction.data.readBigUInt64LE(1), 1_000_000n) + }) + + it('supports Token-2022 and SPL Token multisig authorities', async () => { + const unsigned = await generate( + { + authority: MULTISIG, + multisigSigners: [MULTISIG_SIGNER_1, MULTISIG_SIGNER_2], + }, + TOKEN_2022_PROGRAM_ID, + ) + + assert.equal(unsigned.instructions[0]!.programId.toBase58(), TOKEN_2022_PROGRAM_ID.toBase58()) + assert.deepEqual( + unsigned.instructions[0]!.keys.slice(2).map(({ pubkey, isSigner }) => ({ + pubkey: pubkey.toBase58(), + isSigner, + })), + [ + { pubkey: MULTISIG, isSigner: false }, + { pubkey: MULTISIG_SIGNER_1, isSigner: true }, + { pubkey: MULTISIG_SIGNER_2, isSigner: true }, + ], + ) + }) + + it('rejects a missing recipient ATA before simulation', async () => { + const missingAtaChain = { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + connection: { + getAccountInfo: async (address: PublicKey) => + address.equals(TOKEN) ? { owner: TOKEN_PROGRAM_ID } : null, + }, + } as unknown as SolanaChain + + await assert.rejects( + () => + SolanaTokenManager.fromChain(missingAtaChain).generateUnsignedMintTokens({ + payer: PAYER, + tokenAddress: TOKEN.toBase58(), + recipient: RECIPIENT.toBase58(), + amount: 1n, + }), + (error: unknown) => + error instanceof CCIPTokenAccountNotFoundError && + error.context.token === TOKEN.toBase58() && + error.context.holder === RECIPIENT.toBase58(), + ) + }) + + it('defaults authority to payer', async () => { + const unsigned = await generate({ authority: undefined }) + assert.equal(unsigned.instructions[0]!.keys[2]!.pubkey.toBase58(), PAYER) + }) + }) + + describe('validation', () => { + it('rejects invalid parameters', async () => { + for (const [opts, param] of [ + [{ tokenAddress: 'invalid' }, 'tokenAddress'], + [{ recipient: 'invalid' }, 'recipient'], + [{ authority: 'invalid' }, 'authority'], + [{ amount: 0n }, 'amount'], + [{ amount: 1 }, 'amount'], + [{ multisigSigners: 'invalid' }, 'multisigSigners'], + [{ multisigSigners: ['invalid'] }, 'multisigSigners[0]'], + ] as const) { + await assert.rejects( + () => generate(opts), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === param, + ) + } + }) + + it('rejects missing and non-token mints', async () => { + await assert.rejects( + () => generate({}, null), + (err: unknown) => err instanceof CCIPTokenMintNotFoundError, + ) + await assert.rejects( + () => generate({}, Keypair.generate().publicKey), + (err: unknown) => err instanceof CCIPTokenMintInvalidError, + ) + }) + }) + + describe('execute', () => { + it('signs, submits, and returns the tx hash', async () => { + const result = await SolanaTokenManager.fromChain(submitChain()).mintTokens({ + tokenAddress: TOKEN.toBase58(), + recipient: RECIPIENT.toBase58(), + amount: 1n, + wallet: WALLET, + }) + assert.deepEqual(result, { hash: HASH }) + }) + + it('requires unsigned generation for SPL multisig authorities', async () => { + await assert.rejects( + () => + SolanaTokenManager.fromChain(chain()).mintTokens({ + tokenAddress: TOKEN.toBase58(), + recipient: RECIPIENT.toBase58(), + amount: 1n, + authority: MULTISIG, + multisigSigners: [MULTISIG_SIGNER_1], + wallet: WALLET, + }), + (err: unknown) => + err instanceof CCTParamsInvalidError && err.context.param === 'multisigSigners', + ) + }) + + it('rejects a non-wallet authority for signed minting', async () => { + await assert.rejects( + () => + SolanaTokenManager.fromChain(chain()).mintTokens({ + tokenAddress: TOKEN.toBase58(), + recipient: RECIPIENT.toBase58(), + amount: 1n, + authority: AUTHORITY, + wallet: WALLET, + }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'mintTokens' && + err.context.param === 'authority', + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/solana/token/operations/mint-tokens.ts b/ccip-sdk/src/cct/solana/token/operations/mint-tokens.ts new file mode 100644 index 00000000..636f1a64 --- /dev/null +++ b/ccip-sdk/src/cct/solana/token/operations/mint-tokens.ts @@ -0,0 +1,149 @@ +import { TokenAccountNotFoundError, createMintToInstruction, getAccount } from '@solana/spl-token' +import type { PublicKey, TransactionInstruction } from '@solana/web3.js' + +import { CCIPTokenAccountNotFoundError } from '../../../../errors/index.ts' +import { ChainFamily } from '../../../../networks.ts' +import type { SolanaChain } from '../../../../solana/index.ts' +import type { UnsignedSolanaTx } from '../../../../solana/types.ts' +import { resolveATA } from '../../../../solana/utils.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import type { TransactionResult } from '../../../operation.ts' +import { + type SolanaExecuteParams, + type SolanaGenerateParams, + SolanaOperation, +} from '../../operation.ts' +import { submit } from '../../submit.ts' +import { parsePublicKey, validateAuthorityMatchesWallet } from '../../validate.ts' + +type MintTokensParams = { + /** SPL token mint address. */ + tokenAddress: string + /** Wallet or PDA owner of the recipient associated token account. */ + recipient: string + /** Amount to mint in base units. Must be a positive bigint. */ + amount: bigint + /** Mint authority. Defaults to `payer` for single-signer transactions. */ + authority?: string + /** SPL Token multisig member addresses. Required when authority is an SPL Token multisig. */ + multisigSigners?: string[] +} + +type ParsedMintTokensParams = { + tokenAddress: PublicKey + recipient: PublicKey + amount: bigint + authority: PublicKey + multisigSigners: PublicKey[] +} + +/** Parameters for unsigned Solana SPL token minting. */ +export type GenerateMintTokensParams = SolanaGenerateParams + +/** Unsigned Solana SPL token minting result. */ +export type GenerateMintTokensResult = UnsignedSolanaTx + +/** Parameters for executing Solana SPL token minting. */ +export type ExecuteMintTokensParams = SolanaExecuteParams + +/** Result of executing Solana SPL token minting. */ +export type ExecuteMintTokensResult = TransactionResult + +/** Mints SPL tokens to a recipient's existing associated token account. */ +export class MintTokens extends SolanaOperation< + MintTokensParams, + UnsignedSolanaTx, + ParsedMintTokensParams +> { + readonly name = 'mintTokens' + + /** Parses public keys, amount, and optional SPL Token multisig signers. */ + protected override parse(params: GenerateMintTokensParams): ParsedMintTokensParams { + if (typeof params.amount !== 'bigint' || params.amount <= 0n) { + throw new CCTParamsInvalidError(this.name, 'amount', 'must be a positive bigint') + } + if (params.multisigSigners !== undefined && !Array.isArray(params.multisigSigners)) { + throw new CCTParamsInvalidError(this.name, 'multisigSigners', 'must be an array') + } + + const payer = parsePublicKey(this.name, 'payer', params.payer) + return { + tokenAddress: parsePublicKey(this.name, 'tokenAddress', params.tokenAddress), + recipient: parsePublicKey(this.name, 'recipient', params.recipient), + amount: params.amount, + authority: + params.authority === undefined + ? payer + : parsePublicKey(this.name, 'authority', params.authority), + multisigSigners: (params.multisigSigners ?? []).map((signer, i) => + parsePublicKey(this.name, `multisigSigners[${i}]`, signer), + ), + } + } + + /** Builds an SPL Token `MintTo` instruction for the recipient's associated token account. */ + protected async buildUnsigned( + chain: SolanaChain, + opts: ParsedMintTokensParams, + ): Promise { + const { ata, tokenProgram } = await resolveATA( + chain.connection, + opts.tokenAddress, + opts.recipient, + ) + try { + await getAccount(chain.connection, ata, undefined, tokenProgram) + } catch (error) { + if (error instanceof TokenAccountNotFoundError) { + throw new CCIPTokenAccountNotFoundError( + opts.tokenAddress.toBase58(), + opts.recipient.toBase58(), + ) + } + throw error + } + + const instructions: TransactionInstruction[] = [ + createMintToInstruction( + opts.tokenAddress, + ata, + opts.authority, + opts.amount, + opts.multisigSigners, + tokenProgram, + ), + ] + + chain.logger.debug( + `${this.name}: token = ${opts.tokenAddress.toBase58()}, recipient = ${opts.recipient.toBase58()}, amount = ${opts.amount}`, + ) + return { family: ChainFamily.Solana, instructions, mainIndex: 0 } + } + + /** Generate, sign, simulate, send, and confirm with the mint authority wallet. */ + override async execute( + chain: SolanaChain, + params: ExecuteMintTokensParams, + ): Promise { + const { wallet, computeUnits, parsed } = this.prepareWalletExecution(params) + + if (parsed.multisigSigners.length > 0) { + throw new CCTParamsInvalidError( + this.name, + 'multisigSigners', + 'requires externally signed transactions; use generateUnsignedMintTokens', + ) + } + + if (params.authority !== undefined) { + validateAuthorityMatchesWallet( + this.name, + parsed.authority, + wallet.publicKey, + 'mintTokens requires authority to be the executing wallet. Use generateUnsignedMintTokens for externally signed transactions.', + ) + } + + return submit(chain, wallet, await this.buildUnsigned(chain, parsed), this.name, computeUnits) + } +} From 2120d95621f5adeb90ff7dd04fee4af04993a1a6 Mon Sep 17 00:00:00 2001 From: mervin-link Date: Mon, 24 Aug 2026 13:17:10 +0800 Subject: [PATCH 06/12] fix: address comments --- ccip-sdk/src/cct/solana/index.test.ts | 17 ++--- ccip-sdk/src/cct/solana/index.ts | 69 ++++++++++++------- .../operations/register-admin.ts | 2 +- .../src/cct/solana/token/operations/index.ts | 12 ++-- ...ty.test.ts => set-token-authority.test.ts} | 18 ++--- ...er-authority.ts => set-token-authority.ts} | 66 ++++++++++-------- 6 files changed, 100 insertions(+), 84 deletions(-) rename ccip-sdk/src/cct/solana/token/operations/{transfer-authority.test.ts => set-token-authority.test.ts} (93%) rename ccip-sdk/src/cct/solana/token/operations/{transfer-authority.ts => set-token-authority.ts} (70%) diff --git a/ccip-sdk/src/cct/solana/index.test.ts b/ccip-sdk/src/cct/solana/index.test.ts index c2256fe3..068debdb 100644 --- a/ccip-sdk/src/cct/solana/index.test.ts +++ b/ccip-sdk/src/cct/solana/index.test.ts @@ -3,13 +3,7 @@ import { describe, it } from 'node:test' import { Connection } from '@solana/web3.js' -import { - type RegisterAdminMethod, - type TokenAuthorityType, - REGISTER_ADMIN_METHODS, - SolanaTokenManager, - TOKEN_AUTHORITY_TYPES, -} from './index.ts' +import { type TokenAuthorityType, SolanaTokenManager, TOKEN_AUTHORITY_TYPES } from './index.ts' import type { GetTokenPoolStateParams, GetTokenPoolStateResult, @@ -34,8 +28,8 @@ describe('SolanaTokenManager (cct/solana)', () => { assert.equal(typeof cct.deployToken, 'function') assert.equal(typeof cct.generateUnsignedCreateTokenAccount, 'function') assert.equal(typeof cct.createTokenAccount, 'function') - assert.equal(typeof cct.generateUnsignedTransferAuthority, 'function') - assert.equal(typeof cct.transferAuthority, 'function') + assert.equal(typeof cct.generateUnsignedSetTokenAuthority, 'function') + assert.equal(typeof cct.setTokenAuthority, 'function') // Token admin registry operations assert.equal(typeof cct.generateUnsignedAcceptAdmin, 'function') @@ -82,14 +76,11 @@ describe('SolanaTokenManager (cct/solana)', () => { assert.equal(typeof cct.getTokenPoolState, 'function') }) - it('exports public operation constants', () => { + it('exports public token authority constants', () => { const authorityType: TokenAuthorityType = TOKEN_AUTHORITY_TYPES.MINT - const registrationMethod: RegisterAdminMethod = REGISTER_ADMIN_METHODS.OWNER assert.equal(authorityType, 'mint') assert.equal(TOKEN_AUTHORITY_TYPES.FREEZE, 'freeze') - assert.equal(registrationMethod, 'owner') - assert.equal(REGISTER_ADMIN_METHODS.CCIP_ADMIN, 'ccip-admin') }) it('creates from a connection provider', async (t) => { diff --git a/ccip-sdk/src/cct/solana/index.ts b/ccip-sdk/src/cct/solana/index.ts index 0a4c9abc..aee19f81 100644 --- a/ccip-sdk/src/cct/solana/index.ts +++ b/ccip-sdk/src/cct/solana/index.ts @@ -17,16 +17,16 @@ import { type ExecuteCreateTokenAccountResult, type ExecuteDeployTokenParams, type ExecuteDeployTokenResult, - type ExecuteTransferAuthorityParams, - type ExecuteTransferAuthorityResult, + type ExecuteSetTokenAuthorityParams, + type ExecuteSetTokenAuthorityResult, type GenerateCreateTokenAccountParams, type GenerateCreateTokenAccountResult, type GenerateDeployTokenParams, type GenerateDeployTokenResult, - type GenerateTransferAuthorityParams, - type GenerateTransferAuthorityResult, + type GenerateSetTokenAuthorityParams, + type GenerateSetTokenAuthorityResult, CreateTokenAccount, - TransferAuthority, + SetTokenAuthority, } from './token/operations/index.ts' import { type ExecuteAcceptAdminParams, @@ -149,7 +149,7 @@ export class SolanaTokenManager extends TokenManager readonly chain: SolanaChain // Token operations readonly #createTokenAccount = new CreateTokenAccount() - readonly #transferAuthority = new TransferAuthority() + readonly #setTokenAuthority = new SetTokenAuthority() // Token admin registry operations readonly #acceptAdmin = new AcceptAdmin() @@ -316,13 +316,18 @@ export class SolanaTokenManager extends TokenManager } /** - * Builds unsigned instructions for an immediate SPL Token mint and/or freeze authority transfer. + * Builds unsigned instructions for an immediate SPL Token mint and/or freeze authority update. + * + * @see {@link setTokenAuthority} For wallet-based execution. * * @remarks + * ⚠️ **IRREVERSIBLE:** Setting `newAuthority` to null **permanently revokes** the selected authority + * roles for the SPL Token. Once revoked, the authority cannot be recovered or transferred. + * Example: revoked mint authority prevents anyone from minting tokens. Use with extreme caution. + * * Once confirmed, the current authority loses the selected roles. Set `authorityTypes` to - * `['mint']`, `['freeze']`, or both. Set `newAuthority` to null to permanently revoke the selected - * roles; a revoked role cannot be transferred or restored. All selected roles must have the same - * current authority. The instructions are atomic: no role changes if any selected transfer fails. + * `['mint']`, `['freeze']`, or both. All selected roles must have the same current authority. The + * instructions are atomic: no role changes if any selected update fails. * `authority` defaults to `payer`. For an SPL Token multisig authority, provide `multisigSigners` * and collect member signatures externally. * @@ -333,30 +338,45 @@ export class SolanaTokenManager extends TokenManager * @example * ```ts * const cct = SolanaTokenManager.fromChain(chain) - * const unsigned = await cct.generateUnsignedTransferAuthority({ + * const unsigned = await cct.generateUnsignedSetTokenAuthority({ * payer: currentAuthority, * tokenAddress: mint, * newAuthority, * authorityTypes: ['mint'], * }) * ``` + * + * @example Permanently revoke mint authority + * ```ts + * const revokeUnsigned = await cct.generateUnsignedSetTokenAuthority({ + * payer: currentAuthority, + * tokenAddress: mint, + * newAuthority: null, // ⚠️ PERMANENT + * authorityTypes: ['mint'], + * }) + * ``` */ - generateUnsignedTransferAuthority( - opts: GenerateTransferAuthorityParams, - ): Promise { - return this.#transferAuthority.generate(this.chain, opts) + generateUnsignedSetTokenAuthority( + opts: GenerateSetTokenAuthorityParams, + ): Promise { + return this.#setTokenAuthority.generate(this.chain, opts) } /** - * Immediately transfers SPL Token mint and/or freeze authority using the executing wallet. + * Immediately sets SPL Token mint and/or freeze authority using the executing wallet. + * + * @see {@link generateUnsignedSetTokenAuthority} For externally signed transactions. * * @remarks + * ⚠️ **IRREVERSIBLE:** Setting `newAuthority` to null **permanently revokes** the selected authority + * roles for the SPL Token. Once revoked, the authority cannot be recovered or transferred. + * Example: revoked mint authority prevents anyone from minting tokens. Use with extreme caution. + * * Once confirmed, the current authority loses the selected roles. Set `authorityTypes` to - * `['mint']`, `['freeze']`, or both. Set `newAuthority` to null to permanently revoke the selected - * roles; a revoked role cannot be transferred or restored. All selected roles must have the same - * current authority. The transaction is atomic: no role changes if any selected transfer fails. + * `['mint']`, `['freeze']`, or both. All selected roles must have the same current authority. The + * transaction is atomic: no role changes if any selected update fails. * SPL Token multisig authorities require `multisigSigners` and external member signatures; use - * {@link generateUnsignedTransferAuthority}. + * {@link generateUnsignedSetTokenAuthority}. * * @throws {@link CCIPWalletInvalidError} If `wallet` cannot sign Solana transactions. * @throws {@link CCTParamsInvalidError} If an address or authority role selection is invalid, or @@ -368,11 +388,11 @@ export class SolanaTokenManager extends TokenManager * @example * ```ts * const cct = SolanaTokenManager.fromChain(chain) - * await cct.transferAuthority({ wallet, tokenAddress: mint, newAuthority, authorityTypes: ['mint'] }) + * await cct.setTokenAuthority({ wallet, tokenAddress: mint, newAuthority, authorityTypes: ['mint'] }) * ``` */ - transferAuthority(opts: ExecuteTransferAuthorityParams): Promise { - return this.#transferAuthority.execute(this.chain, opts) + setTokenAuthority(opts: ExecuteSetTokenAuthorityParams): Promise { + return this.#setTokenAuthority.execute(this.chain, opts) } /** @@ -1737,8 +1757,7 @@ export { deriveTokenPoolSignerPda, resolveTokenPoolProgram, } from './programs/token-pool.ts' -export { TOKEN_AUTHORITY_TYPES } from './token/operations/transfer-authority.ts' -export { REGISTER_ADMIN_METHODS } from './token-admin-registry/operations/register-admin.ts' +export { TOKEN_AUTHORITY_TYPES } from './token/operations/set-token-authority.ts' export type { TransactionResult } from '../operation.ts' export type { SerializedSolanaTxEncoding } from './serialize.ts' export type * from './token/operations/index.ts' diff --git a/ccip-sdk/src/cct/solana/token-admin-registry/operations/register-admin.ts b/ccip-sdk/src/cct/solana/token-admin-registry/operations/register-admin.ts index d77c6892..3df93173 100644 --- a/ccip-sdk/src/cct/solana/token-admin-registry/operations/register-admin.ts +++ b/ccip-sdk/src/cct/solana/token-admin-registry/operations/register-admin.ts @@ -21,7 +21,7 @@ import { submit } from '../../submit.ts' import { parsePublicKey, validateAuthorityMatchesWallet } from '../../validate.ts' /** Authorization paths used to register a token in the TokenAdminRegistry. */ -export const REGISTER_ADMIN_METHODS = { +const REGISTER_ADMIN_METHODS = { OWNER: 'owner', CCIP_ADMIN: 'ccip-admin', } as const diff --git a/ccip-sdk/src/cct/solana/token/operations/index.ts b/ccip-sdk/src/cct/solana/token/operations/index.ts index 255c8d3f..41e0ca76 100644 --- a/ccip-sdk/src/cct/solana/token/operations/index.ts +++ b/ccip-sdk/src/cct/solana/token/operations/index.ts @@ -1,10 +1,10 @@ export * from './create-token-account.ts' export * from './deploy-token.ts' -export { TransferAuthority } from './transfer-authority.ts' +export { SetTokenAuthority } from './set-token-authority.ts' export type { - ExecuteTransferAuthorityParams, - ExecuteTransferAuthorityResult, - GenerateTransferAuthorityParams, - GenerateTransferAuthorityResult, + ExecuteSetTokenAuthorityParams, + ExecuteSetTokenAuthorityResult, + GenerateSetTokenAuthorityParams, + GenerateSetTokenAuthorityResult, TokenAuthorityType, -} from './transfer-authority.ts' +} from './set-token-authority.ts' diff --git a/ccip-sdk/src/cct/solana/token/operations/transfer-authority.test.ts b/ccip-sdk/src/cct/solana/token/operations/set-token-authority.test.ts similarity index 93% rename from ccip-sdk/src/cct/solana/token/operations/transfer-authority.test.ts rename to ccip-sdk/src/cct/solana/token/operations/set-token-authority.test.ts index 838f6503..d61fae1b 100644 --- a/ccip-sdk/src/cct/solana/token/operations/transfer-authority.test.ts +++ b/ccip-sdk/src/cct/solana/token/operations/set-token-authority.test.ts @@ -49,7 +49,7 @@ function submitChain(): SolanaChain { } function generate(opts: Record = {}, mintOwner?: PublicKey | null) { - return SolanaTokenManager.fromChain(chain(mintOwner)).generateUnsignedTransferAuthority({ + return SolanaTokenManager.fromChain(chain(mintOwner)).generateUnsignedSetTokenAuthority({ tokenAddress: TOKEN, payer: PAYER, authority: AUTHORITY, @@ -59,9 +59,9 @@ function generate(opts: Record = {}, mintOwner?: PublicKey | nu }) } -describe('TransferAuthority (cct/solana)', () => { +describe('SetTokenAuthority (cct/solana)', () => { describe('generate', () => { - it('builds selected mint and freeze authority transfers', async () => { + it('builds selected mint and freeze authority updates', async () => { const unsigned = await generate() assert.equal(unsigned.family, ChainFamily.Solana) @@ -94,7 +94,7 @@ describe('TransferAuthority (cct/solana)', () => { ) }) - it('builds only the selected authority transfer for Token-2022', async () => { + it('builds only the selected authority update for Token-2022', async () => { const unsigned = await generate({ authorityTypes: ['freeze'] }, TOKEN_2022_PROGRAM_ID) assert.equal(unsigned.instructions.length, 1) @@ -196,7 +196,7 @@ describe('TransferAuthority (cct/solana)', () => { describe('execute', () => { it('signs, submits, and returns the tx hash', async () => { - const result = await SolanaTokenManager.fromChain(submitChain()).transferAuthority({ + const result = await SolanaTokenManager.fromChain(submitChain()).setTokenAuthority({ tokenAddress: TOKEN, newAuthority: NEW_AUTHORITY, authorityTypes: ['mint'], @@ -209,7 +209,7 @@ describe('TransferAuthority (cct/solana)', () => { it('requires unsigned generation for SPL multisig authorities', async () => { await assert.rejects( () => - SolanaTokenManager.fromChain(chain()).transferAuthority({ + SolanaTokenManager.fromChain(chain()).setTokenAuthority({ tokenAddress: TOKEN, newAuthority: NEW_AUTHORITY, authority: MULTISIG, @@ -222,10 +222,10 @@ describe('TransferAuthority (cct/solana)', () => { ) }) - it('rejects a non-wallet authority for signed transfer', async () => { + it('rejects a non-wallet authority for signed updates', async () => { await assert.rejects( () => - SolanaTokenManager.fromChain(chain()).transferAuthority({ + SolanaTokenManager.fromChain(chain()).setTokenAuthority({ tokenAddress: TOKEN, newAuthority: NEW_AUTHORITY, authority: AUTHORITY, @@ -234,7 +234,7 @@ describe('TransferAuthority (cct/solana)', () => { }), (err: unknown) => err instanceof CCTParamsInvalidError && - err.context.operation === 'transferAuthority' && + err.context.operation === 'setTokenAuthority' && err.context.param === 'authority', ) }) diff --git a/ccip-sdk/src/cct/solana/token/operations/transfer-authority.ts b/ccip-sdk/src/cct/solana/token/operations/set-token-authority.ts similarity index 70% rename from ccip-sdk/src/cct/solana/token/operations/transfer-authority.ts rename to ccip-sdk/src/cct/solana/token/operations/set-token-authority.ts index 77683d0d..e7e1e6ee 100644 --- a/ccip-sdk/src/cct/solana/token/operations/transfer-authority.ts +++ b/ccip-sdk/src/cct/solana/token/operations/set-token-authority.ts @@ -15,29 +15,33 @@ import { import { submit } from '../../submit.ts' import { parsePublicKey, validateAuthorityMatchesWallet } from '../../validate.ts' -/** SPL Token authority roles that can be transferred. */ +/** SPL Token authority roles that can be set. */ export const TOKEN_AUTHORITY_TYPES = { MINT: 'mint', FREEZE: 'freeze', } as const -/** SPL Token authority role that can be transferred. */ +/** SPL Token authority role that can be set. */ export type TokenAuthorityType = (typeof TOKEN_AUTHORITY_TYPES)[keyof typeof TOKEN_AUTHORITY_TYPES] -type TransferAuthorityParams = { +type SetTokenAuthorityParams = { /** SPL token mint address. */ tokenAddress: string - /** Address to receive the selected authority roles, or null to revoke them permanently. */ + /** Address to receive the selected authority roles, or **null to permanently revoke** them. ⚠️ Revocation is irreversible. */ newAuthority: string | null /** Current authority. Defaults to `payer` for single-signer transactions. */ authority?: string /** SPL Token multisig member addresses. Required when authority is an SPL Token multisig. */ multisigSigners?: string[] - /** Authority roles to transfer. */ + /** + * Authority roles to set. Specify `['mint']`, `['freeze']`, or `['mint', 'freeze']`. + * The same new authority or revocation applies to every selected role. To set roles to different + * authorities, make separate calls. + */ authorityTypes: TokenAuthorityType[] } -type ParsedTransferAuthorityParams = { +type ParsedSetTokenAuthorityParams = { tokenAddress: PublicKey newAuthority: PublicKey | null authority: PublicKey @@ -45,17 +49,17 @@ type ParsedTransferAuthorityParams = { authorityTypes: TokenAuthorityType[] } -/** Parameters for unsigned Solana SPL Token authority transfer. */ -export type GenerateTransferAuthorityParams = SolanaGenerateParams +/** Parameters for unsigned Solana SPL Token authority update. */ +export type GenerateSetTokenAuthorityParams = SolanaGenerateParams -/** Unsigned Solana SPL Token authority transfer result. */ -export type GenerateTransferAuthorityResult = UnsignedSolanaTx +/** Unsigned Solana SPL Token authority update result. */ +export type GenerateSetTokenAuthorityResult = UnsignedSolanaTx -/** Parameters for executing Solana SPL Token authority transfer. */ -export type ExecuteTransferAuthorityParams = SolanaExecuteParams +/** Parameters for executing Solana SPL Token authority update. */ +export type ExecuteSetTokenAuthorityParams = SolanaExecuteParams -/** Result of executing Solana SPL Token authority transfer. */ -export type ExecuteTransferAuthorityResult = TransactionResult +/** Result of executing Solana SPL Token authority update. */ +export type ExecuteSetTokenAuthorityResult = TransactionResult const SPL_AUTHORITY_TYPES: Record = { mint: AuthorityType.MintTokens, @@ -63,25 +67,27 @@ const SPL_AUTHORITY_TYPES: Record = { } /** - * Immediately transfers mint authority, freeze authority, or both for an SPL Token mint; there is - * no propose-and-accept step. + * Immediately sets mint authority, freeze authority, or both for an SPL Token mint; there is no + * propose-and-accept step. * * @remarks + * ⚠️ **IRREVERSIBLE:** Setting `newAuthority` to null permanently revokes the selected roles. + * Once revoked, a revoked mint or freeze authority **cannot be recovered, transferred, or restored**. + * * Once confirmed, the current authority loses the selected roles. All selected roles must have the - * same current authority. Supply `multisigSigners` when that authority is an SPL Token multisig. Set - * `newAuthority` to null to revoke the selected roles permanently; a revoked mint or freeze - * authority cannot be transferred. The instructions share one atomic Solana transaction, so no role - * changes if any selected transfer fails. + * same current authority. Supply `multisigSigners` when that authority is an SPL Token multisig. + * **Atomic:** All selected roles update in one transaction. If any selected update fails, none are + * committed. */ -export class TransferAuthority extends SolanaOperation< - TransferAuthorityParams, +export class SetTokenAuthority extends SolanaOperation< + SetTokenAuthorityParams, UnsignedSolanaTx, - ParsedTransferAuthorityParams + ParsedSetTokenAuthorityParams > { - readonly name = 'transferAuthority' + readonly name = 'setTokenAuthority' /** Parses public keys and validates the selected authority roles. */ - protected override parse(params: GenerateTransferAuthorityParams): ParsedTransferAuthorityParams { + protected override parse(params: GenerateSetTokenAuthorityParams): ParsedSetTokenAuthorityParams { const authorityTypes = params.authorityTypes if (!Array.isArray(authorityTypes)) { throw new CCTParamsInvalidError(this.name, 'authorityTypes', 'must be an array') @@ -125,7 +131,7 @@ export class TransferAuthority extends SolanaOperation< /** Builds one SPL Token `SetAuthority` instruction for each selected authority role. */ protected async buildUnsigned( chain: SolanaChain, - opts: ParsedTransferAuthorityParams, + opts: ParsedSetTokenAuthorityParams, ): Promise { const tokenProgram = await resolveTokenProgram(chain.connection, opts.tokenAddress) const instructions: TransactionInstruction[] = opts.authorityTypes.map((authorityType) => @@ -148,15 +154,15 @@ export class TransferAuthority extends SolanaOperation< /** Generate, sign, simulate, send, and confirm with the current authority wallet. */ override async execute( chain: SolanaChain, - params: ExecuteTransferAuthorityParams, - ): Promise { + params: ExecuteSetTokenAuthorityParams, + ): Promise { const { wallet, computeUnits, parsed } = this.prepareWalletExecution(params) if (parsed.multisigSigners.length > 0) { throw new CCTParamsInvalidError( this.name, 'multisigSigners', - 'requires externally signed transactions; use generateUnsignedTransferAuthority', + 'requires externally signed transactions; use generateUnsignedSetTokenAuthority', ) } @@ -165,7 +171,7 @@ export class TransferAuthority extends SolanaOperation< this.name, parsed.authority, wallet.publicKey, - 'transferAuthority requires authority to be the executing wallet. Use generateUnsignedTransferAuthority for externally signed transactions.', + 'setTokenAuthority requires authority to be the executing wallet. Use generateUnsignedSetTokenAuthority for externally signed transactions.', ) } From cbeffafcfdd9325ed89a8fdb7302f31bb766e8ba Mon Sep 17 00:00:00 2001 From: mervin-link Date: Mon, 24 Aug 2026 14:18:37 +0800 Subject: [PATCH 07/12] fix: address comments --- ccip-sdk/src/cct/solana/index.ts | 9 ++++++-- .../token/operations/mint-tokens.test.ts | 7 ++++++ .../solana/token/operations/mint-tokens.ts | 22 ++++++++++++++----- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/ccip-sdk/src/cct/solana/index.ts b/ccip-sdk/src/cct/solana/index.ts index 45ae0c2b..33c4db74 100644 --- a/ccip-sdk/src/cct/solana/index.ts +++ b/ccip-sdk/src/cct/solana/index.ts @@ -343,7 +343,7 @@ export class SolanaTokenManager extends TokenManager * payer: mintAuthority, * tokenAddress: mint, * recipient, - * amount: 1_000_000n, + * amount: 1_000_000n, // One token for a mint with six decimals * }) * ``` */ @@ -371,7 +371,12 @@ export class SolanaTokenManager extends TokenManager * @example * ```ts * const cct = SolanaTokenManager.fromChain(chain) - * await cct.mintTokens({ wallet, tokenAddress: mint, recipient, amount: 1_000_000n }) + * await cct.mintTokens({ + * wallet, + * tokenAddress: mint, + * recipient, + * amount: 1_000_000n, // One token for a mint with six decimals + * }) * ``` */ mintTokens(opts: ExecuteMintTokensParams): Promise { diff --git a/ccip-sdk/src/cct/solana/token/operations/mint-tokens.test.ts b/ccip-sdk/src/cct/solana/token/operations/mint-tokens.test.ts index 39b77187..076fce00 100644 --- a/ccip-sdk/src/cct/solana/token/operations/mint-tokens.test.ts +++ b/ccip-sdk/src/cct/solana/token/operations/mint-tokens.test.ts @@ -17,6 +17,7 @@ import { ChainFamily } from '../../../../networks.ts' import type { SolanaChain } from '../../../../solana/index.ts' import { CCTParamsInvalidError } from '../../../errors.ts' import { SolanaTokenManager } from '../../index.ts' +import { U64_MAX } from '../../validate.ts' const TOKEN = Keypair.generate().publicKey const PAYER = Keypair.generate().publicKey.toBase58() @@ -133,6 +134,11 @@ describe('MintTokens (cct/solana)', () => { ) }) + it('encodes the maximum u64 amount', async () => { + const unsigned = await generate({ amount: U64_MAX }) + assert.equal(unsigned.instructions[0]!.data.readBigUInt64LE(1), U64_MAX) + }) + it('defaults authority to payer', async () => { const unsigned = await generate({ authority: undefined }) assert.equal(unsigned.instructions[0]!.keys[2]!.pubkey.toBase58(), PAYER) @@ -147,6 +153,7 @@ describe('MintTokens (cct/solana)', () => { [{ authority: 'invalid' }, 'authority'], [{ amount: 0n }, 'amount'], [{ amount: 1 }, 'amount'], + [{ amount: U64_MAX + 1n }, 'amount'], [{ multisigSigners: 'invalid' }, 'multisigSigners'], [{ multisigSigners: ['invalid'] }, 'multisigSigners[0]'], ] as const) { diff --git a/ccip-sdk/src/cct/solana/token/operations/mint-tokens.ts b/ccip-sdk/src/cct/solana/token/operations/mint-tokens.ts index 636f1a64..d33be6e4 100644 --- a/ccip-sdk/src/cct/solana/token/operations/mint-tokens.ts +++ b/ccip-sdk/src/cct/solana/token/operations/mint-tokens.ts @@ -14,14 +14,26 @@ import { SolanaOperation, } from '../../operation.ts' import { submit } from '../../submit.ts' -import { parsePublicKey, validateAuthorityMatchesWallet } from '../../validate.ts' +import { + U64_MAX, + parsePublicKey, + validateAuthorityMatchesWallet, + validateBigInt, +} from '../../validate.ts' type MintTokensParams = { /** SPL token mint address. */ tokenAddress: string - /** Wallet or PDA owner of the recipient associated token account. */ + /** + * Associated Token Account (ATA) address for the recipient on this token mint. + * ⚠️ ATA must already exist; use `createTokenAccount` if needed. + */ recipient: string - /** Amount to mint in base units. Must be a positive bigint. */ + /** + * Amount to mint in base units (not human-readable tokens). + * E.g., 1_000_000n with 6 decimals = 1 token. + * Maximum u64: 2^64 - 1. + */ amount: bigint /** Mint authority. Defaults to `payer` for single-signer transactions. */ authority?: string @@ -59,9 +71,7 @@ export class MintTokens extends SolanaOperation< /** Parses public keys, amount, and optional SPL Token multisig signers. */ protected override parse(params: GenerateMintTokensParams): ParsedMintTokensParams { - if (typeof params.amount !== 'bigint' || params.amount <= 0n) { - throw new CCTParamsInvalidError(this.name, 'amount', 'must be a positive bigint') - } + validateBigInt(this.name, 'amount', params.amount, 1n, U64_MAX) if (params.multisigSigners !== undefined && !Array.isArray(params.multisigSigners)) { throw new CCTParamsInvalidError(this.name, 'multisigSigners', 'must be an array') } From 2c3c86fc95d4267494bd18bca2d43ecec6f606c1 Mon Sep 17 00:00:00 2001 From: mervin-link Date: Mon, 24 Aug 2026 15:56:54 +0800 Subject: [PATCH 08/12] feat: add set can accept liquidity op solana --- ccip-sdk/src/cct/solana/index.test.ts | 2 + ccip-sdk/src/cct/solana/index.ts | 61 +++++++ .../src/cct/solana/programs/token-pool.ts | 12 +- .../cct/solana/token-pool/operations/index.ts | 1 + .../set-can-accept-liquidity.test.ts | 153 ++++++++++++++++++ .../operations/set-can-accept-liquidity.ts | 125 ++++++++++++++ ccip-sdk/src/cct/solana/validate.ts | 16 ++ 7 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 ccip-sdk/src/cct/solana/token-pool/operations/set-can-accept-liquidity.test.ts create mode 100644 ccip-sdk/src/cct/solana/token-pool/operations/set-can-accept-liquidity.ts diff --git a/ccip-sdk/src/cct/solana/index.test.ts b/ccip-sdk/src/cct/solana/index.test.ts index ddbb6de3..7a17349c 100644 --- a/ccip-sdk/src/cct/solana/index.test.ts +++ b/ccip-sdk/src/cct/solana/index.test.ts @@ -62,6 +62,8 @@ describe('SolanaTokenManager (cct/solana)', () => { assert.equal(typeof cct.deployTokenPool, 'function') assert.equal(typeof cct.generateUnsignedDeleteChainRemoteConfig, 'function') assert.equal(typeof cct.deleteChainRemoteConfig, 'function') + assert.equal(typeof cct.generateUnsignedSetCanAcceptLiquidity, 'function') + assert.equal(typeof cct.setCanAcceptLiquidity, 'function') assert.equal(typeof cct.generateUnsignedSetChainRateLimit, 'function') assert.equal(typeof cct.setChainRateLimit, 'function') assert.equal(typeof cct.generateUnsignedSetRateLimitAdmin, 'function') diff --git a/ccip-sdk/src/cct/solana/index.ts b/ccip-sdk/src/cct/solana/index.ts index 33c4db74..de55f3d7 100644 --- a/ccip-sdk/src/cct/solana/index.ts +++ b/ccip-sdk/src/cct/solana/index.ts @@ -94,6 +94,8 @@ import { type ExecuteInitChainRemoteConfigResult, type ExecuteRemoveFromAllowlistParams, type ExecuteRemoveFromAllowlistResult, + type ExecuteSetCanAcceptLiquidityParams, + type ExecuteSetCanAcceptLiquidityResult, type ExecuteSetChainRateLimitParams, type ExecuteSetChainRateLimitResult, type ExecuteSetRateLimitAdminParams, @@ -120,6 +122,8 @@ import { type GenerateInitChainRemoteConfigResult, type GenerateRemoveFromAllowlistParams, type GenerateRemoveFromAllowlistResult, + type GenerateSetCanAcceptLiquidityParams, + type GenerateSetCanAcceptLiquidityResult, type GenerateSetChainRateLimitParams, type GenerateSetChainRateLimitResult, type GenerateSetRateLimitAdminParams, @@ -144,6 +148,7 @@ import { GetTokenPoolState, InitChainRemoteConfig, RemoveFromAllowlist, + SetCanAcceptLiquidity, SetChainRateLimit, SetRateLimitAdmin, TransferOwnership, @@ -180,6 +185,7 @@ export class SolanaTokenManager extends TokenManager readonly #getTokenPoolState = new GetTokenPoolState() readonly #initChainRemoteConfig = new InitChainRemoteConfig() readonly #removeFromAllowlist = new RemoveFromAllowlist() + readonly #setCanAcceptLiquidity = new SetCanAcceptLiquidity() readonly #setChainRateLimit = new SetChainRateLimit() readonly #setRateLimitAdmin = new SetRateLimitAdmin() readonly #transferOwnership = new TransferOwnership() @@ -1058,6 +1064,61 @@ export class SolanaTokenManager extends TokenManager return this.#setRateLimitAdmin.execute(this.chain, opts) } + /** + * Builds an unsigned instruction that sets whether an initialized Solana lock-release token pool + * accepts liquidity. Pass canonical `poolType: 'lock-release'` or a compatible + * `poolProgramAddress`; `authority` defaults to `payer`. + * + * @see {@link setCanAcceptLiquidity} + * + * @throws {@link CCTParamsInvalidError} If a pool parameter or public key is invalid. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * const unsigned = await cct.generateUnsignedSetCanAcceptLiquidity({ + * tokenAddress: mint, + * poolType: 'lock-release', + * allow: true, + * payer, + * authority, + * }) + * ``` + */ + generateUnsignedSetCanAcceptLiquidity( + opts: GenerateSetCanAcceptLiquidityParams, + ): Promise { + return this.#setCanAcceptLiquidity.generate(this.chain, opts) + } + + /** + * Sets whether an initialized Solana lock-release token pool accepts liquidity using the pool + * owner wallet. + * + * @see {@link generateUnsignedSetCanAcceptLiquidity} + * + * @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 wallet is not the pool owner or simulation/submission fails. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * await cct.setCanAcceptLiquidity({ + * tokenAddress: mint, + * poolType: 'lock-release', + * allow: true, + * wallet, + * }) + * ``` + */ + setCanAcceptLiquidity( + opts: ExecuteSetCanAcceptLiquidityParams, + ): Promise { + return this.#setCanAcceptLiquidity.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`. diff --git a/ccip-sdk/src/cct/solana/programs/token-pool.ts b/ccip-sdk/src/cct/solana/programs/token-pool.ts index 9cad1176..9437c2ff 100644 --- a/ccip-sdk/src/cct/solana/programs/token-pool.ts +++ b/ccip-sdk/src/cct/solana/programs/token-pool.ts @@ -6,6 +6,7 @@ import { PublicKey } from '@solana/web3.js' import { CCIPError } from '../../../errors/index.ts' import { type TokenPoolConfig, + LOCK_RELEASE_TOKEN_POOL_IDL, TOKEN_POOL_IDL, tokenPoolCoder, } from '../../../solana/idl/token-pool-coder.ts' @@ -64,7 +65,7 @@ export function resolveTokenPoolProgram(poolType: TokenPoolType): PublicKey { return new PublicKey(TOKEN_POOL_PROGRAMS[poolType]) } -/** Creates an Anchor Program client for a token pool program. */ +/** Creates an Anchor Program client for a burn-mint token pool program. */ export function createTokenPoolProgram( chain: SolanaChain, poolProgram: PublicKey, @@ -73,6 +74,15 @@ export function createTokenPoolProgram( return new Program(TOKEN_POOL_IDL, poolProgram, simulationProvider(chain, payer)) } +/** Creates an Anchor Program client for a lock-release token pool program. */ +export function createLockReleaseTokenPoolProgram( + chain: SolanaChain, + poolProgram: PublicKey, + payer: PublicKey, +) { + return new Program(LOCK_RELEASE_TOKEN_POOL_IDL, poolProgram, simulationProvider(chain, payer)) +} + /** Decodes a canonical token pool state account. */ export function decodeTokenPoolState( data: Buffer, 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 c01db641..021035e3 100644 --- a/ccip-sdk/src/cct/solana/token-pool/operations/index.ts +++ b/ccip-sdk/src/cct/solana/token-pool/operations/index.ts @@ -10,6 +10,7 @@ export * from './get-token-pool-remotes.ts' export * from './get-token-pool-state.ts' export * from './init-chain-remote-config.ts' export * from './remove-from-allowlist.ts' +export * from './set-can-accept-liquidity.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/set-can-accept-liquidity.test.ts b/ccip-sdk/src/cct/solana/token-pool/operations/set-can-accept-liquidity.test.ts new file mode 100644 index 00000000..0d58b62a --- /dev/null +++ b/ccip-sdk/src/cct/solana/token-pool/operations/set-can-accept-liquidity.test.ts @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Keypair, PublicKey } from '@solana/web3.js' + +import { ChainFamily } from '../../../../networks.ts' +import { lockReleaseTokenPoolCoder } 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 ALLOW = true +const HASH = Keypair.generate().publicKey.toBase58() +const WALLET = { + publicKey: Keypair.generate().publicKey, + signTransaction: async (tx: T) => tx, +} + +function chain(): SolanaChain { + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + connection: {}, + } 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 } }), + }, + } as unknown as SolanaChain +} + +function generate(opts = {}) { + return SolanaTokenManager.fromChain(chain()).generateUnsignedSetCanAcceptLiquidity({ + tokenAddress: TOKEN, + poolType: 'lock-release', + payer: PAYER, + authority: AUTHORITY, + allow: ALLOW, + ...opts, + }) +} + +describe('SetCanAcceptLiquidity (cct/solana)', () => { + describe('generate', () => { + it('builds the set-can-accept-liquidity instruction', async () => { + const unsigned = await generate() + const [instruction] = unsigned.instructions + const poolProgram = resolveTokenPoolProgram('lock-release') + const decoded = lockReleaseTokenPoolCoder.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, 'setCanAcceptLiquidity') + assert.equal((decoded.data as { allow: boolean }).allow, ALLOW) + }) + + 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, non-boolean values, and burn-mint pools', async () => { + for (const [opts, param] of [ + [{ tokenAddress: 'invalid' }, 'tokenAddress'], + [{ allow: 'true' }, 'allow'], + [{ poolType: 'burn-mint' as const }, 'poolType'], + [ + { + poolType: undefined, + poolProgramAddress: resolveTokenPoolProgram('burn-mint').toBase58(), + }, + 'poolProgramAddress', + ], + ]) { + 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()).setCanAcceptLiquidity({ + tokenAddress: TOKEN, + poolType: 'lock-release', + allow: ALLOW, + wallet: WALLET, + }) + + assert.deepEqual(result, { hash: HASH }) + }) + + it('rejects a non-wallet authority for signed configuration', async () => { + await assert.rejects( + () => + SolanaTokenManager.fromChain(chain()).setCanAcceptLiquidity({ + tokenAddress: TOKEN, + poolType: 'lock-release', + allow: ALLOW, + authority: AUTHORITY, + wallet: WALLET, + }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'setCanAcceptLiquidity' && + err.context.param === 'authority', + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/solana/token-pool/operations/set-can-accept-liquidity.ts b/ccip-sdk/src/cct/solana/token-pool/operations/set-can-accept-liquidity.ts new file mode 100644 index 00000000..7bc4b058 --- /dev/null +++ b/ccip-sdk/src/cct/solana/token-pool/operations/set-can-accept-liquidity.ts @@ -0,0 +1,125 @@ +import type { PublicKey } from '@solana/web3.js' + +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 CustomPoolProgramRef, + type LockReleasePoolProgramRef, + createLockReleaseTokenPoolProgram, + deriveTokenPoolConfigPda, +} from '../../programs/token-pool.ts' +import { submit } from '../../submit.ts' +import { + parsePublicKey, + resolveLockReleasePoolProgram, + validateAuthorityMatchesWallet, +} from '../../validate.ts' + +/** Parameters shared by Solana lock-release pool liquidity-acceptance generation and execution. */ +type SetCanAcceptLiquidityParams = (LockReleasePoolProgramRef | CustomPoolProgramRef) & { + /** Token mint address managed by the pool. */ + tokenAddress: string + /** Whether the pool accepts liquidity. */ + allow: boolean + /** Pool owner. Defaults to `payer` for single-signer transactions. */ + authority?: string +} + +type ParsedSetCanAcceptLiquidityParams = { + tokenAddress: PublicKey + poolProgram: PublicKey + allow: boolean + payer: PublicKey + authority: PublicKey +} + +/** Parameters for unsigned Solana lock-release pool liquidity-acceptance configuration. */ +export type GenerateSetCanAcceptLiquidityParams = SolanaGenerateParams + +/** Unsigned Solana lock-release pool liquidity-acceptance configuration result. */ +export type GenerateSetCanAcceptLiquidityResult = UnsignedSolanaTx + +/** Parameters for executing Solana lock-release pool liquidity-acceptance configuration. */ +export type ExecuteSetCanAcceptLiquidityParams = SolanaExecuteParams + +/** Result of executing Solana lock-release pool liquidity-acceptance configuration. */ +export type ExecuteSetCanAcceptLiquidityResult = TransactionResult + +/** Sets whether a Solana lock-release token pool accepts liquidity. */ +export class SetCanAcceptLiquidity extends SolanaOperation< + SetCanAcceptLiquidityParams, + UnsignedSolanaTx, + ParsedSetCanAcceptLiquidityParams +> { + readonly name = 'setCanAcceptLiquidity' + + /** Parses public keys, validates `allow`, and defaults authority to payer. */ + protected override parse( + params: GenerateSetCanAcceptLiquidityParams, + ): ParsedSetCanAcceptLiquidityParams { + if (typeof params.allow !== 'boolean') { + throw new CCTParamsInvalidError(this.name, 'allow', 'must be a boolean') + } + + const poolProgram = resolveLockReleasePoolProgram(this.name, params) + const payer = parsePublicKey(this.name, 'payer', params.payer) + + return { + tokenAddress: parsePublicKey(this.name, 'tokenAddress', params.tokenAddress), + poolProgram, + allow: params.allow, + payer, + authority: + params.authority === undefined + ? payer + : parsePublicKey(this.name, 'authority', params.authority), + } + } + + /** Builds the unsigned Solana `setCanAcceptLiquidity` instruction. */ + protected async buildUnsigned( + chain: SolanaChain, + opts: ParsedSetCanAcceptLiquidityParams, + ): Promise { + const instruction = await createLockReleaseTokenPoolProgram(chain, opts.poolProgram, opts.payer) + .methods.setCanAcceptLiquidity(opts.allow) + .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 pool owner wallet. */ + override async execute( + chain: SolanaChain, + params: ExecuteSetCanAcceptLiquidityParams, + ): Promise { + const { wallet, computeUnits, parsed } = this.prepareWalletExecution(params) + + if (params.authority !== undefined) { + validateAuthorityMatchesWallet( + this.name, + parsed.authority, + wallet.publicKey, + 'setCanAcceptLiquidity requires authority to be the executing wallet. Use generateUnsignedSetCanAcceptLiquidity for externally signed transactions.', + ) + } + + return submit(chain, wallet, await this.buildUnsigned(chain, parsed), this.name, computeUnits) + } +} diff --git a/ccip-sdk/src/cct/solana/validate.ts b/ccip-sdk/src/cct/solana/validate.ts index f51836d7..49cb7ef2 100644 --- a/ccip-sdk/src/cct/solana/validate.ts +++ b/ccip-sdk/src/cct/solana/validate.ts @@ -135,6 +135,22 @@ export function resolvePoolProgram(operation: string, params: PoolProgramRef): P return parsePublicKey(operation, 'poolProgramAddress', params.poolProgramAddress) } +/** Resolves a lock-release token pool program and rejects the canonical burn-mint program. */ +export function resolveLockReleasePoolProgram( + operation: string, + params: PoolProgramRef, +): PublicKey { + const poolProgram = resolvePoolProgram(operation, params) + if (poolProgram.equals(resolveTokenPoolProgram('burn-mint'))) { + throw new CCTParamsInvalidError( + operation, + params.poolProgramAddress === undefined ? 'poolType' : 'poolProgramAddress', + 'must be lock-release', + ) + } + return poolProgram +} + /** * Asserts `value` is an integer, optionally inside inclusive bounds. * @throws CCTParamsInvalidError if `value` is not an integer or is outside bounds. From 24b7262238244d05586f0d952e2c18a831ca532d Mon Sep 17 00:00:00 2001 From: mervin-link Date: Mon, 24 Aug 2026 20:08:36 +0800 Subject: [PATCH 09/12] fix: add lock release token pool idl --- .../idl/1.6.0/LOCK_RELEASE_TOKEN_POOL.ts | 1972 +++++++++++++++++ ccip-sdk/src/solana/idl/token-pool-coder.ts | 28 +- 2 files changed, 1992 insertions(+), 8 deletions(-) create mode 100644 ccip-sdk/src/solana/idl/1.6.0/LOCK_RELEASE_TOKEN_POOL.ts diff --git a/ccip-sdk/src/solana/idl/1.6.0/LOCK_RELEASE_TOKEN_POOL.ts b/ccip-sdk/src/solana/idl/1.6.0/LOCK_RELEASE_TOKEN_POOL.ts new file mode 100644 index 00000000..29cf17a0 --- /dev/null +++ b/ccip-sdk/src/solana/idl/1.6.0/LOCK_RELEASE_TOKEN_POOL.ts @@ -0,0 +1,1972 @@ +// generate: +// fetch('https://raw.githubusercontent.com/smartcontractkit/chainlink-ccip/refs/heads/main/chains/solana/contracts/target/types/lockrelease_token_pool.ts') +// .then((res) => res.text()) +// .then((text) => text.trim()) +export type LockreleaseTokenPool = { + version: '1.6.3' + name: 'lockrelease_token_pool' + instructions: [ + { + name: 'initGlobalConfig' + accounts: [ + { + name: 'config' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'systemProgram' + isMut: false + isSigner: false + }, + { + name: 'program' + isMut: false + isSigner: false + }, + { + name: 'programData' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'routerAddress' + type: 'publicKey' + }, + { + name: 'rmnAddress' + type: 'publicKey' + }, + ] + }, + { + name: 'updateSelfServedAllowed' + accounts: [ + { + name: 'config' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + { + name: 'program' + isMut: false + isSigner: false + }, + { + name: 'programData' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'selfServedAllowed' + type: 'bool' + }, + ] + }, + { + name: 'updateDefaultRouter' + accounts: [ + { + name: 'config' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + { + name: 'program' + isMut: false + isSigner: false + }, + { + name: 'programData' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'routerAddress' + type: 'publicKey' + }, + ] + }, + { + name: 'updateDefaultRmn' + accounts: [ + { + name: 'config' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + { + name: 'program' + isMut: false + isSigner: false + }, + { + name: 'programData' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'rmnAddress' + type: 'publicKey' + }, + ] + }, + { + name: 'initialize' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'systemProgram' + isMut: false + isSigner: false + }, + { + name: 'program' + isMut: false + isSigner: false + }, + { + name: 'programData' + isMut: false + isSigner: false + }, + { + name: 'config' + isMut: false + isSigner: false + }, + ] + args: [] + }, + { + name: 'typeVersion' + docs: [ + 'Returns the program type (name) and version.', + 'Used by offchain code to easily determine which program & version is being interacted with.', + '', + '# Arguments', + '* `ctx` - The context', + ] + accounts: [ + { + name: 'clock' + isMut: false + isSigner: false + }, + ] + args: [] + returns: 'string' + }, + { + name: 'transferOwnership' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + ] + args: [ + { + name: 'proposedOwner' + type: 'publicKey' + }, + ] + }, + { + name: 'acceptOwnership' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + ] + args: [] + }, + { + name: 'setRouter' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'program' + isMut: false + isSigner: false + }, + { + name: 'programData' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'newRouter' + type: 'publicKey' + }, + ] + }, + { + name: 'setRmn' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'program' + isMut: false + isSigner: false + }, + { + name: 'programData' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'rmnAddress' + type: 'publicKey' + }, + ] + }, + { + name: 'initializeStateVersion' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + ] + args: [ + { + name: 'mint' + type: 'publicKey' + }, + ] + }, + { + name: 'initChainRemoteConfig' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'chainConfig' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'systemProgram' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'remoteChainSelector' + type: 'u64' + }, + { + name: 'mint' + type: 'publicKey' + }, + { + name: 'cfg' + type: { + defined: 'RemoteConfig' + } + }, + ] + }, + { + name: 'editChainRemoteConfig' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'chainConfig' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'systemProgram' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'remoteChainSelector' + type: 'u64' + }, + { + name: 'mint' + type: 'publicKey' + }, + { + name: 'cfg' + type: { + defined: 'RemoteConfig' + } + }, + ] + }, + { + name: 'appendRemotePoolAddresses' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'chainConfig' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'systemProgram' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'remoteChainSelector' + type: 'u64' + }, + { + name: 'mint' + type: 'publicKey' + }, + { + name: 'addresses' + type: { + vec: { + defined: 'RemoteAddress' + } + } + }, + ] + }, + { + name: 'setChainRateLimit' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'chainConfig' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + ] + args: [ + { + name: 'remoteChainSelector' + type: 'u64' + }, + { + name: 'mint' + type: 'publicKey' + }, + { + name: 'inbound' + type: { + defined: 'RateLimitConfig' + } + }, + { + name: 'outbound' + type: { + defined: 'RateLimitConfig' + } + }, + ] + }, + { + name: 'setRateLimitAdmin' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + ] + args: [ + { + name: 'mint' + type: 'publicKey' + }, + { + name: 'newRateLimitAdmin' + type: 'publicKey' + }, + ] + }, + { + name: 'deleteChainConfig' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'chainConfig' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + ] + args: [ + { + name: 'remoteChainSelector' + type: 'u64' + }, + { + name: 'mint' + type: 'publicKey' + }, + ] + }, + { + name: 'configureAllowList' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'systemProgram' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'add' + type: { + vec: 'publicKey' + } + }, + { + name: 'enabled' + type: 'bool' + }, + ] + }, + { + name: 'removeFromAllowList' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'systemProgram' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'remove' + type: { + vec: 'publicKey' + } + }, + ] + }, + { + name: 'releaseOrMintTokens' + accounts: [ + { + name: 'authority' + isMut: false + isSigner: true + }, + { + name: 'offrampProgram' + isMut: false + isSigner: false + docs: [ + 'CHECK offramp program: exists only to derive the allowed offramp PDA', + 'and the authority PDA.', + ] + }, + { + name: 'allowedOfframp' + isMut: false + isSigner: false + docs: [ + 'CHECK PDA of the router program verifying the signer is an allowed offramp.', + "If PDA does not exist, the router doesn't allow this offramp", + ] + }, + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'tokenProgram' + isMut: false + isSigner: false + }, + { + name: 'mint' + isMut: true + isSigner: false + }, + { + name: 'poolSigner' + isMut: false + isSigner: false + }, + { + name: 'poolTokenAccount' + isMut: true + isSigner: false + }, + { + name: 'chainConfig' + isMut: true + isSigner: false + }, + { + name: 'rmnRemote' + isMut: false + isSigner: false + }, + { + name: 'rmnRemoteCurses' + isMut: false + isSigner: false + }, + { + name: 'rmnRemoteConfig' + isMut: false + isSigner: false + }, + { + name: 'receiverTokenAccount' + isMut: true + isSigner: false + }, + ] + args: [ + { + name: 'releaseOrMint' + type: { + defined: 'ReleaseOrMintInV1' + } + }, + ] + returns: { + defined: 'ReleaseOrMintOutV1' + } + }, + { + name: 'lockOrBurnTokens' + accounts: [ + { + name: 'authority' + isMut: false + isSigner: true + }, + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'tokenProgram' + isMut: false + isSigner: false + }, + { + name: 'mint' + isMut: true + isSigner: false + }, + { + name: 'poolSigner' + isMut: false + isSigner: false + }, + { + name: 'poolTokenAccount' + isMut: true + isSigner: false + }, + { + name: 'rmnRemote' + isMut: false + isSigner: false + }, + { + name: 'rmnRemoteCurses' + isMut: false + isSigner: false + }, + { + name: 'rmnRemoteConfig' + isMut: false + isSigner: false + }, + { + name: 'chainConfig' + isMut: true + isSigner: false + }, + ] + args: [ + { + name: 'lockOrBurn' + type: { + defined: 'LockOrBurnInV1' + } + }, + ] + returns: { + defined: 'LockOrBurnOutV1' + } + }, + { + name: 'setRebalancer' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + ] + args: [ + { + name: 'rebalancer' + type: 'publicKey' + }, + ] + }, + { + name: 'setCanAcceptLiquidity' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + ] + args: [ + { + name: 'allow' + type: 'bool' + }, + ] + }, + { + name: 'provideLiquidity' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'tokenProgram' + isMut: false + isSigner: false + }, + { + name: 'mint' + isMut: true + isSigner: false + }, + { + name: 'poolSigner' + isMut: false + isSigner: false + }, + { + name: 'poolTokenAccount' + isMut: true + isSigner: false + }, + { + name: 'remoteTokenAccount' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + ] + args: [ + { + name: 'amount' + type: 'u64' + }, + ] + }, + { + name: 'withdrawLiquidity' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'tokenProgram' + isMut: false + isSigner: false + }, + { + name: 'mint' + isMut: true + isSigner: false + }, + { + name: 'poolSigner' + isMut: false + isSigner: false + }, + { + name: 'poolTokenAccount' + isMut: true + isSigner: false + }, + { + name: 'remoteTokenAccount' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + ] + args: [ + { + name: 'amount' + type: 'u64' + }, + ] + }, + ] + accounts: [ + { + name: 'poolConfig' + type: { + kind: 'struct' + fields: [ + { + name: 'version' + type: 'u8' + }, + { + name: 'selfServedAllowed' + type: 'bool' + }, + { + name: 'router' + type: 'publicKey' + }, + { + name: 'rmnRemote' + type: 'publicKey' + }, + ] + } + }, + { + name: 'state' + type: { + kind: 'struct' + fields: [ + { + name: 'version' + type: 'u8' + }, + { + name: 'config' + type: { + defined: 'BaseConfig' + } + }, + ] + } + }, + { + name: 'chainConfig' + type: { + kind: 'struct' + fields: [ + { + name: 'base' + type: { + defined: 'BaseChain' + } + }, + ] + } + }, + ] +} + +export const IDL: LockreleaseTokenPool = { + version: '1.6.3', + name: 'lockrelease_token_pool', + instructions: [ + { + name: 'initGlobalConfig', + accounts: [ + { + name: 'config', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'systemProgram', + isMut: false, + isSigner: false, + }, + { + name: 'program', + isMut: false, + isSigner: false, + }, + { + name: 'programData', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'routerAddress', + type: 'publicKey', + }, + { + name: 'rmnAddress', + type: 'publicKey', + }, + ], + }, + { + name: 'updateSelfServedAllowed', + accounts: [ + { + name: 'config', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + { + name: 'program', + isMut: false, + isSigner: false, + }, + { + name: 'programData', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'selfServedAllowed', + type: 'bool', + }, + ], + }, + { + name: 'updateDefaultRouter', + accounts: [ + { + name: 'config', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + { + name: 'program', + isMut: false, + isSigner: false, + }, + { + name: 'programData', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'routerAddress', + type: 'publicKey', + }, + ], + }, + { + name: 'updateDefaultRmn', + accounts: [ + { + name: 'config', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + { + name: 'program', + isMut: false, + isSigner: false, + }, + { + name: 'programData', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'rmnAddress', + type: 'publicKey', + }, + ], + }, + { + name: 'initialize', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'systemProgram', + isMut: false, + isSigner: false, + }, + { + name: 'program', + isMut: false, + isSigner: false, + }, + { + name: 'programData', + isMut: false, + isSigner: false, + }, + { + name: 'config', + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: 'typeVersion', + docs: [ + 'Returns the program type (name) and version.', + 'Used by offchain code to easily determine which program & version is being interacted with.', + '', + '# Arguments', + '* `ctx` - The context', + ], + accounts: [ + { + name: 'clock', + isMut: false, + isSigner: false, + }, + ], + args: [], + returns: 'string', + }, + { + name: 'transferOwnership', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + ], + args: [ + { + name: 'proposedOwner', + type: 'publicKey', + }, + ], + }, + { + name: 'acceptOwnership', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + ], + args: [], + }, + { + name: 'setRouter', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'program', + isMut: false, + isSigner: false, + }, + { + name: 'programData', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'newRouter', + type: 'publicKey', + }, + ], + }, + { + name: 'setRmn', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'program', + isMut: false, + isSigner: false, + }, + { + name: 'programData', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'rmnAddress', + type: 'publicKey', + }, + ], + }, + { + name: 'initializeStateVersion', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + ], + args: [ + { + name: 'mint', + type: 'publicKey', + }, + ], + }, + { + name: 'initChainRemoteConfig', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'chainConfig', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'systemProgram', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'remoteChainSelector', + type: 'u64', + }, + { + name: 'mint', + type: 'publicKey', + }, + { + name: 'cfg', + type: { + defined: 'RemoteConfig', + }, + }, + ], + }, + { + name: 'editChainRemoteConfig', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'chainConfig', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'systemProgram', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'remoteChainSelector', + type: 'u64', + }, + { + name: 'mint', + type: 'publicKey', + }, + { + name: 'cfg', + type: { + defined: 'RemoteConfig', + }, + }, + ], + }, + { + name: 'appendRemotePoolAddresses', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'chainConfig', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'systemProgram', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'remoteChainSelector', + type: 'u64', + }, + { + name: 'mint', + type: 'publicKey', + }, + { + name: 'addresses', + type: { + vec: { + defined: 'RemoteAddress', + }, + }, + }, + ], + }, + { + name: 'setChainRateLimit', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'chainConfig', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + ], + args: [ + { + name: 'remoteChainSelector', + type: 'u64', + }, + { + name: 'mint', + type: 'publicKey', + }, + { + name: 'inbound', + type: { + defined: 'RateLimitConfig', + }, + }, + { + name: 'outbound', + type: { + defined: 'RateLimitConfig', + }, + }, + ], + }, + { + name: 'setRateLimitAdmin', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + ], + args: [ + { + name: 'mint', + type: 'publicKey', + }, + { + name: 'newRateLimitAdmin', + type: 'publicKey', + }, + ], + }, + { + name: 'deleteChainConfig', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'chainConfig', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + ], + args: [ + { + name: 'remoteChainSelector', + type: 'u64', + }, + { + name: 'mint', + type: 'publicKey', + }, + ], + }, + { + name: 'configureAllowList', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'systemProgram', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'add', + type: { + vec: 'publicKey', + }, + }, + { + name: 'enabled', + type: 'bool', + }, + ], + }, + { + name: 'removeFromAllowList', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'systemProgram', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'remove', + type: { + vec: 'publicKey', + }, + }, + ], + }, + { + name: 'releaseOrMintTokens', + accounts: [ + { + name: 'authority', + isMut: false, + isSigner: true, + }, + { + name: 'offrampProgram', + isMut: false, + isSigner: false, + docs: [ + 'CHECK offramp program: exists only to derive the allowed offramp PDA', + 'and the authority PDA.', + ], + }, + { + name: 'allowedOfframp', + isMut: false, + isSigner: false, + docs: [ + 'CHECK PDA of the router program verifying the signer is an allowed offramp.', + "If PDA does not exist, the router doesn't allow this offramp", + ], + }, + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'tokenProgram', + isMut: false, + isSigner: false, + }, + { + name: 'mint', + isMut: true, + isSigner: false, + }, + { + name: 'poolSigner', + isMut: false, + isSigner: false, + }, + { + name: 'poolTokenAccount', + isMut: true, + isSigner: false, + }, + { + name: 'chainConfig', + isMut: true, + isSigner: false, + }, + { + name: 'rmnRemote', + isMut: false, + isSigner: false, + }, + { + name: 'rmnRemoteCurses', + isMut: false, + isSigner: false, + }, + { + name: 'rmnRemoteConfig', + isMut: false, + isSigner: false, + }, + { + name: 'receiverTokenAccount', + isMut: true, + isSigner: false, + }, + ], + args: [ + { + name: 'releaseOrMint', + type: { + defined: 'ReleaseOrMintInV1', + }, + }, + ], + returns: { + defined: 'ReleaseOrMintOutV1', + }, + }, + { + name: 'lockOrBurnTokens', + accounts: [ + { + name: 'authority', + isMut: false, + isSigner: true, + }, + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'tokenProgram', + isMut: false, + isSigner: false, + }, + { + name: 'mint', + isMut: true, + isSigner: false, + }, + { + name: 'poolSigner', + isMut: false, + isSigner: false, + }, + { + name: 'poolTokenAccount', + isMut: true, + isSigner: false, + }, + { + name: 'rmnRemote', + isMut: false, + isSigner: false, + }, + { + name: 'rmnRemoteCurses', + isMut: false, + isSigner: false, + }, + { + name: 'rmnRemoteConfig', + isMut: false, + isSigner: false, + }, + { + name: 'chainConfig', + isMut: true, + isSigner: false, + }, + ], + args: [ + { + name: 'lockOrBurn', + type: { + defined: 'LockOrBurnInV1', + }, + }, + ], + returns: { + defined: 'LockOrBurnOutV1', + }, + }, + { + name: 'setRebalancer', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + ], + args: [ + { + name: 'rebalancer', + type: 'publicKey', + }, + ], + }, + { + name: 'setCanAcceptLiquidity', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + ], + args: [ + { + name: 'allow', + type: 'bool', + }, + ], + }, + { + name: 'provideLiquidity', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'tokenProgram', + isMut: false, + isSigner: false, + }, + { + name: 'mint', + isMut: true, + isSigner: false, + }, + { + name: 'poolSigner', + isMut: false, + isSigner: false, + }, + { + name: 'poolTokenAccount', + isMut: true, + isSigner: false, + }, + { + name: 'remoteTokenAccount', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + ], + args: [ + { + name: 'amount', + type: 'u64', + }, + ], + }, + { + name: 'withdrawLiquidity', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'tokenProgram', + isMut: false, + isSigner: false, + }, + { + name: 'mint', + isMut: true, + isSigner: false, + }, + { + name: 'poolSigner', + isMut: false, + isSigner: false, + }, + { + name: 'poolTokenAccount', + isMut: true, + isSigner: false, + }, + { + name: 'remoteTokenAccount', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + ], + args: [ + { + name: 'amount', + type: 'u64', + }, + ], + }, + ], + accounts: [ + { + name: 'poolConfig', + type: { + kind: 'struct', + fields: [ + { + name: 'version', + type: 'u8', + }, + { + name: 'selfServedAllowed', + type: 'bool', + }, + { + name: 'router', + type: 'publicKey', + }, + { + name: 'rmnRemote', + type: 'publicKey', + }, + ], + }, + }, + { + name: 'state', + type: { + kind: 'struct', + fields: [ + { + name: 'version', + type: 'u8', + }, + { + name: 'config', + type: { + defined: 'BaseConfig', + }, + }, + ], + }, + }, + { + name: 'chainConfig', + type: { + kind: 'struct', + fields: [ + { + name: 'base', + type: { + defined: 'BaseChain', + }, + }, + ], + }, + }, + ], +} +// generate:end diff --git a/ccip-sdk/src/solana/idl/token-pool-coder.ts b/ccip-sdk/src/solana/idl/token-pool-coder.ts index 088dcf96..3237b517 100644 --- a/ccip-sdk/src/solana/idl/token-pool-coder.ts +++ b/ccip-sdk/src/solana/idl/token-pool-coder.ts @@ -1,18 +1,30 @@ -import { type IdlTypes, BorshCoder } from '@coral-xyz/anchor' +import { type Idl, type IdlTypes, BorshCoder } from '@coral-xyz/anchor' import { IDL as BASE_TOKEN_POOL } from './1.6.0/BASE_TOKEN_POOL.ts' import { IDL as BURN_MINT_TOKEN_POOL } from './1.6.0/BURN_MINT_TOKEN_POOL.ts' +import { IDL as LOCK_RELEASE_TOKEN_POOL } from './1.6.0/LOCK_RELEASE_TOKEN_POOL.ts' -// Splice in base IDL types so BaseConfig is defined; required for accounts.decode. -export const TOKEN_POOL_IDL = { - ...BURN_MINT_TOKEN_POOL, - types: BASE_TOKEN_POOL.types, - events: BASE_TOKEN_POOL.events, - errors: [...BASE_TOKEN_POOL.errors, ...BURN_MINT_TOKEN_POOL.errors], +/** Adds shared base token-pool types, events, and errors to a pool-specific IDL. */ +function composeTokenPoolIdl(poolIdl: T) { + return { + ...poolIdl, + types: BASE_TOKEN_POOL.types, + events: BASE_TOKEN_POOL.events, + errors: [...BASE_TOKEN_POOL.errors, ...(poolIdl.errors ?? [])], + } } +/** Burn-mint token pool IDL with shared base definitions. */ +export const TOKEN_POOL_IDL = composeTokenPoolIdl(BURN_MINT_TOKEN_POOL) + +/** Lock-release token pool IDL with shared base definitions. */ +export const LOCK_RELEASE_TOKEN_POOL_IDL = composeTokenPoolIdl(LOCK_RELEASE_TOKEN_POOL) + /** Shared state configuration stored by canonical Solana token pools. */ export type TokenPoolConfig = IdlTypes['BaseConfig'] -/** Borsh decoder for canonical token pool accounts. */ +/** Borsh decoder for burn-mint token pool instructions and canonical token pool accounts. */ export const tokenPoolCoder = new BorshCoder(TOKEN_POOL_IDL) + +/** Borsh decoder for lock-release token pool instructions and canonical token pool accounts. */ +export const lockReleaseTokenPoolCoder = new BorshCoder(LOCK_RELEASE_TOKEN_POOL_IDL) From eedf3666da77fd714f6e5060cdaa39f2c127f4a5 Mon Sep 17 00:00:00 2001 From: mervin-link Date: Mon, 24 Aug 2026 20:28:30 +0800 Subject: [PATCH 10/12] fix: update tsdoc --- ccip-sdk/src/cct/solana/index.ts | 8 ++++---- .../token-pool/operations/set-can-accept-liquidity.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ccip-sdk/src/cct/solana/index.ts b/ccip-sdk/src/cct/solana/index.ts index de55f3d7..5eb416b8 100644 --- a/ccip-sdk/src/cct/solana/index.ts +++ b/ccip-sdk/src/cct/solana/index.ts @@ -1066,8 +1066,8 @@ export class SolanaTokenManager extends TokenManager /** * Builds an unsigned instruction that sets whether an initialized Solana lock-release token pool - * accepts liquidity. Pass canonical `poolType: 'lock-release'` or a compatible - * `poolProgramAddress`; `authority` defaults to `payer`. + * accepts `provideLiquidity` deposits and `withdrawLiquidity` transfers. Pass canonical + * `poolType: 'lock-release'` or a compatible `poolProgramAddress`; `authority` defaults to `payer`. * * @see {@link setCanAcceptLiquidity} * @@ -1092,8 +1092,8 @@ export class SolanaTokenManager extends TokenManager } /** - * Sets whether an initialized Solana lock-release token pool accepts liquidity using the pool - * owner wallet. + * Sets whether an initialized Solana lock-release token pool accepts `provideLiquidity` deposits + * and `withdrawLiquidity` transfers using the pool owner wallet. * * @see {@link generateUnsignedSetCanAcceptLiquidity} * diff --git a/ccip-sdk/src/cct/solana/token-pool/operations/set-can-accept-liquidity.ts b/ccip-sdk/src/cct/solana/token-pool/operations/set-can-accept-liquidity.ts index 7bc4b058..0bbd6ac6 100644 --- a/ccip-sdk/src/cct/solana/token-pool/operations/set-can-accept-liquidity.ts +++ b/ccip-sdk/src/cct/solana/token-pool/operations/set-can-accept-liquidity.ts @@ -27,7 +27,7 @@ import { type SetCanAcceptLiquidityParams = (LockReleasePoolProgramRef | CustomPoolProgramRef) & { /** Token mint address managed by the pool. */ tokenAddress: string - /** Whether the pool accepts liquidity. */ + /** Whether to enable liquidity provision and withdrawal. */ allow: boolean /** Pool owner. Defaults to `payer` for single-signer transactions. */ authority?: string From 802289f7936617fba393797e1aeec362bc131400 Mon Sep 17 00:00:00 2001 From: mervin-link Date: Wed, 26 Aug 2026 14:18:47 +0800 Subject: [PATCH 11/12] fix: address comments --- ccip-sdk/src/cct/solana/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ccip-sdk/src/cct/solana/index.ts b/ccip-sdk/src/cct/solana/index.ts index 5eb416b8..ae9ad3e0 100644 --- a/ccip-sdk/src/cct/solana/index.ts +++ b/ccip-sdk/src/cct/solana/index.ts @@ -1069,9 +1069,14 @@ export class SolanaTokenManager extends TokenManager * accepts `provideLiquidity` deposits and `withdrawLiquidity` transfers. Pass canonical * `poolType: 'lock-release'` or a compatible `poolProgramAddress`; `authority` defaults to `payer`. * + * @remarks + * ⚠️ **Consequence:** Setting `allow` to `true` lets the rebalancer both `provideLiquidity` and + * `withdrawLiquidity`. Setting `allow` to `false` **disables both** — liquidity already in the pool cannot be + * withdrawn until `allow` is re-enabled. Verify the current liquidity balance before flipping to `false`. + * * @see {@link setCanAcceptLiquidity} * - * @throws {@link CCTParamsInvalidError} If a pool parameter or public key is invalid. + * @throws {@link CCTParamsInvalidError} If `allow`, a pool parameter, or public key is invalid. * * @example * ```ts @@ -1095,10 +1100,15 @@ export class SolanaTokenManager extends TokenManager * Sets whether an initialized Solana lock-release token pool accepts `provideLiquidity` deposits * and `withdrawLiquidity` transfers using the pool owner wallet. * + * @remarks + * ⚠️ **Consequence:** Setting `allow` to `true` lets the rebalancer both `provideLiquidity` and + * `withdrawLiquidity`. Setting `allow` to `false` **disables both** — liquidity already in the pool cannot be + * withdrawn until `allow` is re-enabled. Verify the current liquidity balance before flipping to `false`. + * * @see {@link generateUnsignedSetCanAcceptLiquidity} * * @throws {@link CCIPWalletInvalidError} If `wallet` cannot sign Solana transactions. - * @throws {@link CCTParamsInvalidError} If a pool parameter is invalid or the authority differs + * @throws {@link CCTParamsInvalidError} If `allow` or a pool parameter is invalid, or the authority differs * from the executing wallet. * @throws {@link CCTTxFailedError} If the wallet is not the pool owner or simulation/submission fails. * From 63a1b5396b66ee7b288a94cfb76f2fbe3d3b3e51 Mon Sep 17 00:00:00 2001 From: Mervin Date: Thu, 27 Aug 2026 10:53:48 +0800 Subject: [PATCH 12/12] feat(cct-sdk): Add set rebalancer op solana (#370) * feat: add set rebalancer op solana * fix: update tsdoc * fix: address comments --- ccip-sdk/src/cct/solana/index.test.ts | 2 + ccip-sdk/src/cct/solana/index.ts | 92 +++++++++++ .../cct/solana/token-pool/operations/index.ts | 1 + .../operations/set-rebalancer.test.ts | 156 ++++++++++++++++++ .../token-pool/operations/set-rebalancer.ts | 118 +++++++++++++ 5 files changed, 369 insertions(+) create mode 100644 ccip-sdk/src/cct/solana/token-pool/operations/set-rebalancer.test.ts create mode 100644 ccip-sdk/src/cct/solana/token-pool/operations/set-rebalancer.ts diff --git a/ccip-sdk/src/cct/solana/index.test.ts b/ccip-sdk/src/cct/solana/index.test.ts index 7a17349c..d8a1f731 100644 --- a/ccip-sdk/src/cct/solana/index.test.ts +++ b/ccip-sdk/src/cct/solana/index.test.ts @@ -68,6 +68,8 @@ 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.generateUnsignedSetRebalancer, 'function') + assert.equal(typeof cct.setRebalancer, 'function') assert.equal(typeof cct.generateUnsignedTransferOwnership, 'function') assert.equal(typeof cct.transferOwnership, 'function') assert.equal(typeof cct.generateUnsignedAcceptOwnership, 'function') diff --git a/ccip-sdk/src/cct/solana/index.ts b/ccip-sdk/src/cct/solana/index.ts index ae9ad3e0..cb32d3f5 100644 --- a/ccip-sdk/src/cct/solana/index.ts +++ b/ccip-sdk/src/cct/solana/index.ts @@ -100,6 +100,8 @@ import { type ExecuteSetChainRateLimitResult, type ExecuteSetRateLimitAdminParams, type ExecuteSetRateLimitAdminResult, + type ExecuteSetRebalancerParams, + type ExecuteSetRebalancerResult, type ExecuteTransferOwnershipParams, type ExecuteTransferOwnershipResult, type GenerateAcceptOwnershipParams, @@ -128,6 +130,8 @@ import { type GenerateSetChainRateLimitResult, type GenerateSetRateLimitAdminParams, type GenerateSetRateLimitAdminResult, + type GenerateSetRebalancerParams, + type GenerateSetRebalancerResult, type GenerateTransferOwnershipParams, type GenerateTransferOwnershipResult, type GetTokenPoolRemotesParams, @@ -151,6 +155,7 @@ import { SetCanAcceptLiquidity, SetChainRateLimit, SetRateLimitAdmin, + SetRebalancer, TransferOwnership, } from './token-pool/operations/index.ts' @@ -188,6 +193,7 @@ export class SolanaTokenManager extends TokenManager readonly #setCanAcceptLiquidity = new SetCanAcceptLiquidity() readonly #setChainRateLimit = new SetChainRateLimit() readonly #setRateLimitAdmin = new SetRateLimitAdmin() + readonly #setRebalancer = new SetRebalancer() readonly #transferOwnership = new TransferOwnership() /** Creates a Solana CCT manager for an existing chain. */ @@ -1075,6 +1081,7 @@ export class SolanaTokenManager extends TokenManager * withdrawn until `allow` is re-enabled. Verify the current liquidity balance before flipping to `false`. * * @see {@link setCanAcceptLiquidity} + * @see {@link generateUnsignedSetRebalancer} * * @throws {@link CCTParamsInvalidError} If `allow`, a pool parameter, or public key is invalid. * @@ -1106,6 +1113,7 @@ export class SolanaTokenManager extends TokenManager * withdrawn until `allow` is re-enabled. Verify the current liquidity balance before flipping to `false`. * * @see {@link generateUnsignedSetCanAcceptLiquidity} + * @see {@link setRebalancer} * * @throws {@link CCIPWalletInvalidError} If `wallet` cannot sign Solana transactions. * @throws {@link CCTParamsInvalidError} If `allow` or a pool parameter is invalid, or the authority differs @@ -1129,6 +1137,90 @@ export class SolanaTokenManager extends TokenManager return this.#setCanAcceptLiquidity.execute(this.chain, opts) } + /** + * Builds an unsigned instruction that sets the address authorized to provide or withdraw + * liquidity for an initialized Solana lock-release token pool. Pass canonical + * `poolType: 'lock-release'` or a compatible `poolProgramAddress`; `authority` defaults to + * `payer`. The default/zero public key (`11111111111111111111111111111111`) disables + * rebalancing. + * + * @remarks + * ⚠️ **Consequence:** Rebalancer is the address allowed to provide or withdraw liquidity. + * Setting the zero address (`11111111111111111111111111111111`) removes the rebalancer; until a new one + * is set, **no account can provide or withdraw liquidity**, even liquidity already in the pool. + * This does not affect whether the pool accepts liquidity — see {@link setCanAcceptLiquidity}. + * + * @see {@link setRebalancer} + * @see {@link setCanAcceptLiquidity} + * @see {@link generateUnsignedSetCanAcceptLiquidity} + * + * @throws {@link CCTParamsInvalidError} If a pool parameter or public key is invalid. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * const unsigned = await cct.generateUnsignedSetRebalancer({ + * tokenAddress: mint, + * poolType: 'lock-release', + * rebalancer, + * payer, + * authority, + * }) + * ``` + */ + generateUnsignedSetRebalancer( + opts: GenerateSetRebalancerParams, + ): Promise { + return this.#setRebalancer.generate(this.chain, opts) + } + + /** + * Sets the address authorized to provide or withdraw liquidity for an initialized Solana + * lock-release token pool using the pool owner wallet. Pass canonical `poolType: 'lock-release'` + * or a compatible `poolProgramAddress`; set `rebalancer` to the default/zero public key + * (`11111111111111111111111111111111`) to disable rebalancing. + * + * @remarks + * ⚠️ **Consequence:** Rebalancer is the address allowed to provide or withdraw liquidity. + * Setting the zero address (`11111111111111111111111111111111`) removes the rebalancer; until a new one + * is set, **no account can provide or withdraw liquidity**, even liquidity already in the pool. + * This does not affect whether the pool accepts liquidity — see {@link setCanAcceptLiquidity}. + * + * @see {@link generateUnsignedSetRebalancer} + * @see {@link setCanAcceptLiquidity} + * @see {@link generateUnsignedSetCanAcceptLiquidity} + * + * @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 wallet is not the pool owner or simulation/submission fails. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * await cct.setRebalancer({ + * tokenAddress: mint, + * poolType: 'lock-release', + * rebalancer, + * wallet, + * }) + * ``` + * + * @example Disable rebalancing + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * await cct.setRebalancer({ + * tokenAddress: mint, + * poolType: 'lock-release', + * rebalancer: PublicKey.default.toBase58(), // disable + * wallet, + * }) + * ``` + */ + setRebalancer(opts: ExecuteSetRebalancerParams): Promise { + return this.#setRebalancer.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`. 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 021035e3..7ac19b44 100644 --- a/ccip-sdk/src/cct/solana/token-pool/operations/index.ts +++ b/ccip-sdk/src/cct/solana/token-pool/operations/index.ts @@ -13,4 +13,5 @@ export * from './remove-from-allowlist.ts' export * from './set-can-accept-liquidity.ts' export * from './set-chain-rate-limit.ts' export * from './set-rate-limit-admin.ts' +export * from './set-rebalancer.ts' export * from './transfer-ownership.ts' diff --git a/ccip-sdk/src/cct/solana/token-pool/operations/set-rebalancer.test.ts b/ccip-sdk/src/cct/solana/token-pool/operations/set-rebalancer.test.ts new file mode 100644 index 00000000..d16399c9 --- /dev/null +++ b/ccip-sdk/src/cct/solana/token-pool/operations/set-rebalancer.test.ts @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Keypair, PublicKey } from '@solana/web3.js' + +import { ChainFamily } from '../../../../networks.ts' +import { lockReleaseTokenPoolCoder } 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 REBALANCER = Keypair.generate().publicKey.toBase58() +const HASH = Keypair.generate().publicKey.toBase58() +const WALLET = { + publicKey: Keypair.generate().publicKey, + signTransaction: async (tx: T) => tx, +} + +function chain(): SolanaChain { + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + connection: {}, + } 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 } }), + }, + } as unknown as SolanaChain +} + +function generate(opts = {}) { + return SolanaTokenManager.fromChain(chain()).generateUnsignedSetRebalancer({ + tokenAddress: TOKEN, + poolType: 'lock-release', + payer: PAYER, + authority: AUTHORITY, + rebalancer: REBALANCER, + ...opts, + }) +} + +describe('SetRebalancer (cct/solana)', () => { + describe('generate', () => { + it('builds the set-rebalancer instruction', async () => { + const unsigned = await generate() + const [instruction] = unsigned.instructions + const poolProgram = resolveTokenPoolProgram('lock-release') + const decoded = lockReleaseTokenPoolCoder.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, 'setRebalancer') + assert.equal((decoded.data as { rebalancer: PublicKey }).rebalancer.toBase58(), REBALANCER) + }) + + it('defaults authority to payer and accepts the default rebalancer', async () => { + const unsigned = await generate({ + authority: undefined, + rebalancer: PublicKey.default.toBase58(), + }) + + 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 and burn-mint pools', async () => { + for (const [opts, param] of [ + [{ tokenAddress: 'invalid' }, 'tokenAddress'], + [{ rebalancer: 'invalid' }, 'rebalancer'], + [{ poolType: 'burn-mint' as const }, 'poolType'], + [ + { + poolType: undefined, + poolProgramAddress: resolveTokenPoolProgram('burn-mint').toBase58(), + }, + 'poolProgramAddress', + ], + ]) { + 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()).setRebalancer({ + tokenAddress: TOKEN, + poolType: 'lock-release', + rebalancer: REBALANCER, + wallet: WALLET, + }) + + assert.deepEqual(result, { hash: HASH }) + }) + + it('rejects a non-wallet authority for signed configuration', async () => { + await assert.rejects( + () => + SolanaTokenManager.fromChain(chain()).setRebalancer({ + tokenAddress: TOKEN, + poolType: 'lock-release', + rebalancer: REBALANCER, + authority: AUTHORITY, + wallet: WALLET, + }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'setRebalancer' && + err.context.param === 'authority', + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/solana/token-pool/operations/set-rebalancer.ts b/ccip-sdk/src/cct/solana/token-pool/operations/set-rebalancer.ts new file mode 100644 index 00000000..d7a8640f --- /dev/null +++ b/ccip-sdk/src/cct/solana/token-pool/operations/set-rebalancer.ts @@ -0,0 +1,118 @@ +import type { PublicKey } from '@solana/web3.js' + +import { ChainFamily } from '../../../../networks.ts' +import type { SolanaChain } from '../../../../solana/index.ts' +import type { UnsignedSolanaTx } from '../../../../solana/types.ts' +import type { TransactionResult } from '../../../operation.ts' +import { + type SolanaExecuteParams, + type SolanaGenerateParams, + SolanaOperation, +} from '../../operation.ts' +import { + type CustomPoolProgramRef, + type LockReleasePoolProgramRef, + createLockReleaseTokenPoolProgram, + deriveTokenPoolConfigPda, +} from '../../programs/token-pool.ts' +import { submit } from '../../submit.ts' +import { + parsePublicKey, + resolveLockReleasePoolProgram, + validateAuthorityMatchesWallet, +} from '../../validate.ts' + +/** Parameters shared by Solana lock-release pool rebalancer generation and execution. */ +type SetRebalancerParams = (LockReleasePoolProgramRef | CustomPoolProgramRef) & { + /** Token mint address managed by the pool. */ + tokenAddress: string + /** Address authorized to provide or withdraw pool liquidity (stored on the pool; not a transaction signer). Use the default/zero address (`11111111111111111111111111111111`) to disable rebalancing. */ + rebalancer: string + /** Pool owner. Defaults to `payer` for single-signer transactions. */ + authority?: string +} + +type ParsedSetRebalancerParams = { + tokenAddress: PublicKey + rebalancer: PublicKey + poolProgram: PublicKey + payer: PublicKey + authority: PublicKey +} + +/** Parameters for unsigned Solana lock-release pool rebalancer configuration. */ +export type GenerateSetRebalancerParams = SolanaGenerateParams + +/** Unsigned Solana lock-release pool rebalancer configuration result. */ +export type GenerateSetRebalancerResult = UnsignedSolanaTx + +/** Parameters for executing Solana lock-release pool rebalancer configuration. */ +export type ExecuteSetRebalancerParams = SolanaExecuteParams + +/** Result of executing Solana lock-release pool rebalancer configuration. */ +export type ExecuteSetRebalancerResult = TransactionResult + +/** Sets the address authorized to provide or withdraw a Solana lock-release token pool's liquidity. */ +export class SetRebalancer extends SolanaOperation< + SetRebalancerParams, + UnsignedSolanaTx, + ParsedSetRebalancerParams +> { + readonly name = 'setRebalancer' + + /** Parses public keys and defaults authority to payer without mutating caller params. */ + protected override parse(params: GenerateSetRebalancerParams): ParsedSetRebalancerParams { + const poolProgram = resolveLockReleasePoolProgram(this.name, params) + const payer = parsePublicKey(this.name, 'payer', params.payer) + + return { + tokenAddress: parsePublicKey(this.name, 'tokenAddress', params.tokenAddress), + rebalancer: parsePublicKey(this.name, 'rebalancer', params.rebalancer), + poolProgram, + payer, + authority: + params.authority === undefined + ? payer + : parsePublicKey(this.name, 'authority', params.authority), + } + } + + /** Builds the unsigned Solana `setRebalancer` instruction. */ + protected async buildUnsigned( + chain: SolanaChain, + opts: ParsedSetRebalancerParams, + ): Promise { + const instruction = await createLockReleaseTokenPoolProgram(chain, opts.poolProgram, opts.payer) + .methods.setRebalancer(opts.rebalancer) + .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 pool owner wallet. */ + override async execute( + chain: SolanaChain, + params: ExecuteSetRebalancerParams, + ): Promise { + const { wallet, computeUnits, parsed } = this.prepareWalletExecution(params) + + if (params.authority !== undefined) { + validateAuthorityMatchesWallet( + this.name, + parsed.authority, + wallet.publicKey, + 'setRebalancer requires authority to be the executing wallet. Use generateUnsignedSetRebalancer for externally signed transactions.', + ) + } + + return submit(chain, wallet, await this.buildUnsigned(chain, parsed), this.name, computeUnits) + } +}