diff --git a/ccip-sdk/src/cct/solana/index.ts b/ccip-sdk/src/cct/solana/index.ts index db9e64c7..a4f634be 100644 --- a/ccip-sdk/src/cct/solana/index.ts +++ b/ccip-sdk/src/cct/solana/index.ts @@ -822,12 +822,25 @@ export class SolanaTokenManager extends TokenManager * @remarks * This only builds the pool `initialize` instruction for the canonical `burn-mint` and * `lock-release` programs selected by `poolType`; custom pool deployment is unsupported. `authority` - * must be allowed to initialize the pool. This does not create the pool signer PDA's associated - * token account; use the returned `poolSignerAddress` with `generateUnsignedCreateTokenAccount` - * before `generateUnsignedSetPool`. + * must be allowed to initialize the pool. + * + * **Important:** The pool requires a `pool_token_account` (the pool signer PDA's associated token + * account) to lock/release or mint on transfers. Set `createPoolSignerATA: true` to create it + * idempotently in this transaction. If omitted (defaults to `false`), create it separately with + * the returned `poolSignerAddress` via `generateUnsignedCreateTokenAccount` before + * `generateUnsignedSetPool`, or transfers fail with `AccountNotInitialized (3012)`. + * + * When to use `createPoolSignerATA: true` vs. the separate + * `generateUnsignedCreateTokenAccount` op: + * - Use this option when deploying a pool that will immediately receive transfers (simplest, one tx) + * - Use the separate op for vault-owned pools or when decoupling pool initialization from ATA setup + * + * This option is Solana-only (no EVM equivalent). It is analogous to `createRecipientATA` on + * {@link mintTokens}, the same idiomatic pattern for atomicity. * * @see {@link generateUnsignedCreateTokenAccount} * @see {@link generateUnsignedSetPool} + * @see {@link mintTokens} * * @throws {@link CCTParamsInvalidError} If a pool parameter is invalid. * @@ -840,6 +853,7 @@ export class SolanaTokenManager extends TokenManager * payer, * authority, * allowlist: [allowedSender], + * createPoolSignerATA: true, * }) * ``` */ @@ -855,11 +869,25 @@ export class SolanaTokenManager extends TokenManager * @remarks * This only sends the pool `initialize` instruction for the canonical `burn-mint` and * `lock-release` programs selected by `poolType`; custom pool deployment is unsupported. The signer - * must be allowed to initialize the pool. This does not create the pool signer PDA's associated - * token account; use the returned `poolSignerAddress` with `createTokenAccount` before `setPool`. + * must be allowed to initialize the pool. + * + * **Important:** The pool requires a `pool_token_account` (the pool signer PDA's associated token + * account) to lock/release or mint on transfers. Set `createPoolSignerATA: true` to create it + * idempotently in this transaction. If omitted (defaults to `false`), create it separately with + * the returned `poolSignerAddress` via `createTokenAccount` before `setPool`, or transfers fail + * with `AccountNotInitialized (3012)`. + * + * When to use `createPoolSignerATA: true` vs. the separate + * `generateUnsignedCreateTokenAccount` op: + * - Use this option when deploying a pool that will immediately receive transfers (simplest, one tx) + * - Use the separate op for vault-owned pools or when decoupling pool initialization from ATA setup + * + * This option is Solana-only (no EVM equivalent). It is analogous to `createRecipientATA` on + * {@link mintTokens}, the same idiomatic pattern for atomicity. * * @see {@link createTokenAccount} * @see {@link setPool} + * @see {@link mintTokens} * * @throws {@link CCIPWalletInvalidError} If `wallet` cannot sign Solana transactions. * @throws {@link CCTParamsInvalidError} If a pool parameter is invalid. @@ -871,6 +899,7 @@ export class SolanaTokenManager extends TokenManager * await cct.deployTokenPool({ * tokenAddress: mint, * poolType: 'burn-mint', + * createPoolSignerATA: true, * wallet, * }) * ``` diff --git a/ccip-sdk/src/cct/solana/token-pool/operations/deploy-token-pool.test.ts b/ccip-sdk/src/cct/solana/token-pool/operations/deploy-token-pool.test.ts index d45ba8b8..42cde3de 100644 --- a/ccip-sdk/src/cct/solana/token-pool/operations/deploy-token-pool.test.ts +++ b/ccip-sdk/src/cct/solana/token-pool/operations/deploy-token-pool.test.ts @@ -1,6 +1,11 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + getAssociatedTokenAddressSync, +} from '@solana/spl-token' import { Keypair, PublicKey } from '@solana/web3.js' import { ChainFamily } from '../../../../networks.ts' @@ -72,6 +77,36 @@ describe('DeployTokenPool (cct/solana)', () => { assert.equal(unsigned.instructions[1]!.programId.toBase58(), BURN_MINT_POOL_PROGRAM) }) + it('creates the pool signer ATA when requested', async () => { + const chain = Object.assign(stubChain(), { + connection: { getAccountInfo: async () => ({ owner: TOKEN_PROGRAM_ID }) }, + }) + const unsigned = await new DeployTokenPool().generate(chain, { + tokenAddress: TOKEN, + poolType: 'burn-mint', + payer: PAYER, + authority: AUTHORITY, + createPoolSignerATA: true, + }) + const poolSigner = deriveTokenPoolSignerPda( + resolveTokenPoolProgram('burn-mint'), + new PublicKey(TOKEN), + ) + const ata = getAssociatedTokenAddressSync( + new PublicKey(TOKEN), + poolSigner, + true, + TOKEN_PROGRAM_ID, + ) + const createATA = unsigned.instructions[1]! + + assert.equal(unsigned.instructions.length, 2) + assert.equal(createATA.programId.toBase58(), ASSOCIATED_TOKEN_PROGRAM_ID.toBase58()) + assert.equal(createATA.data[0], 1) // CreateIdempotent + assert.equal(createATA.keys[1]!.pubkey.toBase58(), ata.toBase58()) + assert.equal(createATA.keys[2]!.pubkey.toBase58(), poolSigner.toBase58()) + }) + it('uses canonical lock-release pool program', async () => { const unsigned = await generate({ poolType: 'lock-release' }) @@ -93,6 +128,14 @@ describe('DeployTokenPool (cct/solana)', () => { ) }) + it('rejects a non-boolean createPoolSignerATA', async () => { + await assert.rejects( + () => generate({ createPoolSignerATA: 'yes' }), + (err: unknown) => + err instanceof CCTParamsInvalidError && err.context.param === 'createPoolSignerATA', + ) + }) + it('rejects invalid pool types', async () => { await assert.rejects( () => generate({ poolType: 'custom' }), diff --git a/ccip-sdk/src/cct/solana/token-pool/operations/deploy-token-pool.ts b/ccip-sdk/src/cct/solana/token-pool/operations/deploy-token-pool.ts index 13f7684d..23c6388a 100644 --- a/ccip-sdk/src/cct/solana/token-pool/operations/deploy-token-pool.ts +++ b/ccip-sdk/src/cct/solana/token-pool/operations/deploy-token-pool.ts @@ -20,6 +20,7 @@ import { resolveTokenPoolProgram, } from '../../programs/token-pool.ts' import { submit } from '../../submit.ts' +import { CreateTokenAccount } from '../../token/operations/create-token-account.ts' import { parsePublicKey, validateAuthorityMatchesWallet, validatePoolType } from '../../validate.ts' /** @@ -42,6 +43,17 @@ type DeployTokenPoolParams = { * If omitted, the pool is initialized without an allowlist. */ allowlist?: string[] + /** + * Create the pool signer PDA's associated token account (`pool_token_account`) idempotently. + * + * @remarks The pool requires a `pool_token_account` (the associated token account owned by the + * `pool_signer` PDA) to lock/release or mint on transfers. Without it, a `ccip-send` fails with + * `AccountNotInitialized (3012)`. Setting `createPoolSignerATA: true` creates this account in the + * deploy transaction; otherwise create it separately before any transfer. + * + * Defaults to false. + */ + createPoolSignerATA?: boolean /** Pool authority. Defaults to payer for unsigned generation and wallet public key for execute. */ authority?: string } @@ -55,6 +67,7 @@ type ParsedDeployTokenPoolParams = { payer: PublicKey authority: PublicKey allowlist: PublicKey[] + createPoolSignerATA: boolean } /** Unsigned Solana token pool deploy result plus derived pool PDAs. */ @@ -86,6 +99,12 @@ export class DeployTokenPool extends SolanaOperation< if (params.allowlist !== undefined && !Array.isArray(params.allowlist)) { throw new CCTParamsInvalidError(this.name, 'allowlist', 'must be an array') } + if ( + params.createPoolSignerATA !== undefined && + typeof params.createPoolSignerATA !== 'boolean' + ) { + throw new CCTParamsInvalidError(this.name, 'createPoolSignerATA', 'must be a boolean') + } const payer = parsePublicKey(this.name, 'payer', params.payer) return { @@ -99,6 +118,7 @@ export class DeployTokenPool extends SolanaOperation< allowlist: (params.allowlist ?? []).map((address, i) => parsePublicKey(this.name, `allowlist[${i}]`, address), ), + createPoolSignerATA: params.createPoolSignerATA ?? false, } } @@ -107,7 +127,7 @@ export class DeployTokenPool extends SolanaOperation< chain: SolanaChain, opts: ParsedDeployTokenPoolParams, ): Promise { - const { tokenMint, poolProgram, payer, authority, allowlist } = opts + const { tokenMint, poolProgram, payer, authority, allowlist, createPoolSignerATA } = opts const program = createTokenPoolProgram(chain, poolProgram, payer) const state = deriveTokenPoolConfigPda(poolProgram, tokenMint) const poolSigner = deriveTokenPoolSignerPda(poolProgram, tokenMint) @@ -127,6 +147,19 @@ export class DeployTokenPool extends SolanaOperation< .instruction(), ] + if (createPoolSignerATA) { + // Append ATA creation after initialize (initialize must be main instruction index 0) + instructions.push( + ...( + await new CreateTokenAccount().generate(chain, { + payer: payer.toBase58(), + tokenAddress: tokenMint.toBase58(), + ownerAddress: poolSigner.toBase58(), + }) + ).instructions, + ) + } + if (allowlist.length) { instructions.push( await program.methods