Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions ccip-sdk/src/cct/solana/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -822,12 +822,25 @@ export class SolanaTokenManager extends TokenManager<typeof ChainFamily.Solana>
* @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.
*
Expand All @@ -840,6 +853,7 @@ export class SolanaTokenManager extends TokenManager<typeof ChainFamily.Solana>
* payer,
* authority,
* allowlist: [allowedSender],
* createPoolSignerATA: true,
* })
* ```
*/
Expand All @@ -855,11 +869,25 @@ export class SolanaTokenManager extends TokenManager<typeof ChainFamily.Solana>
* @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.
Expand All @@ -871,6 +899,7 @@ export class SolanaTokenManager extends TokenManager<typeof ChainFamily.Solana>
* await cct.deployTokenPool({
* tokenAddress: mint,
* poolType: 'burn-mint',
* createPoolSignerATA: true,
* wallet,
* })
* ```
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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' })

Expand All @@ -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' }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand All @@ -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
}
Expand All @@ -55,6 +67,7 @@ type ParsedDeployTokenPoolParams = {
payer: PublicKey
authority: PublicKey
allowlist: PublicKey[]
createPoolSignerATA: boolean
}

/** Unsigned Solana token pool deploy result plus derived pool PDAs. */
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
}
}

Expand All @@ -107,7 +127,7 @@ export class DeployTokenPool extends SolanaOperation<
chain: SolanaChain,
opts: ParsedDeployTokenPoolParams,
): Promise<GenerateDeployTokenPoolResult> {
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)
Expand All @@ -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
Expand Down
Loading