diff --git a/ccip-sdk/src/cct/solana/index.test.ts b/ccip-sdk/src/cct/solana/index.test.ts index 9f0551a7..c575e790 100644 --- a/ccip-sdk/src/cct/solana/index.test.ts +++ b/ccip-sdk/src/cct/solana/index.test.ts @@ -197,6 +197,7 @@ describe('SolanaTokenManager (cct/solana)', () => { getTokenAdminRegistryFor: async (address: string) => address === reader ? pool : address === overrideAddress ? overrideRouter : account, getSupportedTokens: async () => [mint], + getTokenInfo: async () => ({ symbol: 'TKN', decimals: 6 }), getTokenPoolRemotes: async () => ({}), getRegistryTokenConfig: async (router: string) => router === overrideRouter @@ -779,6 +780,7 @@ describe('SolanaTokenManager (cct/solana)', () => { remoteChainSelector, }), ], + ['getTokenInfo', () => cct.getTokenInfo({ tokenAddress: mint })], [ 'getTokenPoolState', () => diff --git a/ccip-sdk/src/cct/solana/index.ts b/ccip-sdk/src/cct/solana/index.ts index 35962e80..a4f634be 100644 --- a/ccip-sdk/src/cct/solana/index.ts +++ b/ccip-sdk/src/cct/solana/index.ts @@ -177,8 +177,11 @@ import { type GenerateSetTokenAuthorityResult, type GenerateUpdateMetadataAuthorityParams, type GenerateUpdateMetadataAuthorityResult, + type GetTokenInfoParams, + type GetTokenInfoResult, ApproveToken, CreateTokenAccount, + GetTokenInfo, MintTokens, SetTokenAuthority, UpdateMetadataAuthority, @@ -190,6 +193,7 @@ export class SolanaTokenManager extends TokenManager // Token operations readonly #approveToken = new ApproveToken() readonly #createTokenAccount = new CreateTokenAccount() + readonly #getTokenInfo = new GetTokenInfo() readonly #mintTokens = new MintTokens() readonly #setTokenAuthority = new SetTokenAuthority() readonly #updateMetadataAuthority = new UpdateMetadataAuthority() @@ -818,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. * @@ -836,6 +853,7 @@ export class SolanaTokenManager extends TokenManager * payer, * authority, * allowlist: [allowedSender], + * createPoolSignerATA: true, * }) * ``` */ @@ -851,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. @@ -867,6 +899,7 @@ export class SolanaTokenManager extends TokenManager * await cct.deployTokenPool({ * tokenAddress: mint, * poolType: 'burn-mint', + * createPoolSignerATA: true, * wallet, * }) * ``` @@ -2230,6 +2263,36 @@ export class SolanaTokenManager extends TokenManager return this.#transferAdmin.execute(this.chain, opts) } + /** + * Reads an SPL token mint's metadata, program, supply, initialization state, and mint/freeze + * authorities. + * + * @remarks Metadata comes from {@link SolanaChain.getTokenInfo}; mint state comes directly from + * the SPL Token or Token-2022 mint account. Supply is in base units. Solana-only; no EVM CCT + * equivalent exists. + * + * @see {@link setTokenAuthority} Sets the mint or freeze authorities returned here. + * @see {@link updateMetadataAuthority} Updates the Metaplex metadata associated with this mint. + * @see {@link getTokenPoolState} Reads pool configuration rather than mint state. + * @see {@link SolanaChain.getTokenInfo} Reads the underlying token metadata. + * + * @throws {@link CCTParamsInvalidError} If `tokenAddress` is not a valid Solana public key. + * @throws {@link CCIPSplTokenInvalidError} If the token metadata is not a valid SPL token. + * @throws {@link CCIPTokenMintNotFoundError} If the mint account does not exist. + * @throws {@link CCIPTokenMintInvalidError} If the mint is not owned by an SPL Token program. + * @throws {@link CCIPTokenDataParseError} If the mint data cannot be parsed. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * const info = await cct.getTokenInfo({ tokenAddress: mint }) + * console.log(`${info.symbol}: ${info.decimals} decimals`) + * ``` + */ + getTokenInfo(opts: GetTokenInfoParams): Promise { + return this.#getTokenInfo.query(this.chain, opts) + } + /** * Reads all, or one selected, Solana token pool remote-chain configurations. * 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 diff --git a/ccip-sdk/src/cct/solana/token/operations/get-token-info.test.ts b/ccip-sdk/src/cct/solana/token/operations/get-token-info.test.ts new file mode 100644 index 00000000..b127eec2 --- /dev/null +++ b/ccip-sdk/src/cct/solana/token/operations/get-token-info.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { MINT_SIZE, MintLayout, TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { Keypair, PublicKey } from '@solana/web3.js' + +import { CCIPTokenDataParseError } from '../../../../errors/index.ts' +import type { SolanaChain } from '../../../../solana/index.ts' +import { GetTokenInfo } from './get-token-info.ts' + +const tokenAddress = Keypair.generate().publicKey.toBase58() +const mintAuthority = Keypair.generate().publicKey + +function mintData(): Buffer { + const data = Buffer.alloc(MINT_SIZE) + MintLayout.encode( + { + mintAuthorityOption: 1, + mintAuthority, + supply: 1_000_000n, + decimals: 6, + isInitialized: true, + freezeAuthorityOption: 0, + freezeAuthority: PublicKey.default, + }, + data, + ) + return data +} + +describe('GetTokenInfo (cct/solana)', () => { + describe('query', () => { + it('delegates metadata to SolanaChain.getTokenInfo and reads mint state', async () => { + const metadata = { symbol: 'TKN', decimals: 6, name: 'Token' } + let received: string | undefined + const chain = { + connection: { + getAccountInfo: async () => ({ owner: TOKEN_PROGRAM_ID, data: mintData() }), + }, + getTokenInfo: async (token: string) => { + received = token + return metadata + }, + } as unknown as SolanaChain + + assert.deepEqual(await new GetTokenInfo().query(chain, { tokenAddress }), { + ...metadata, + tokenProgram: TOKEN_PROGRAM_ID.toBase58(), + supply: 1_000_000n, + isInitialized: true, + mintAuthority: mintAuthority.toBase58(), + freezeAuthority: null, + }) + assert.equal(received, tokenAddress) + }) + + it('rejects non-mint SPL accounts before fetching metadata', async () => { + let metadataCalls = 0 + const chain = { + connection: { + getAccountInfo: async () => ({ owner: TOKEN_PROGRAM_ID, data: Buffer.alloc(1) }), + }, + getTokenInfo: async () => { + metadataCalls++ + return { symbol: 'TKN', decimals: 6 } + }, + } as unknown as SolanaChain + + await assert.rejects(new GetTokenInfo().query(chain, { tokenAddress }), (error: unknown) => { + assert.ok(error instanceof CCIPTokenDataParseError) + assert.equal(error.context.token, tokenAddress) + assert.ok(error.cause instanceof Error) + return true + }) + assert.equal(metadataCalls, 0) + }) + }) + + describe('validation', () => { + it('validates the mint address before querying', async () => { + await assert.rejects( + new GetTokenInfo().query({} as SolanaChain, { tokenAddress: 'not-a-public-key' }), + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/solana/token/operations/get-token-info.ts b/ccip-sdk/src/cct/solana/token/operations/get-token-info.ts new file mode 100644 index 00000000..70579e7c --- /dev/null +++ b/ccip-sdk/src/cct/solana/token/operations/get-token-info.ts @@ -0,0 +1,84 @@ +import { unpackMint } from '@solana/spl-token' +import type { PublicKey } from '@solana/web3.js' + +import type { TokenInfo } from '../../../../chain.ts' +import { CCIPTokenDataParseError } from '../../../../errors/index.ts' +import type { SolanaChain } from '../../../../solana/index.ts' +import { resolveTokenMint } from '../../../../solana/utils.ts' +import { SolanaQuery } from '../../query.ts' +import { parsePublicKey } from '../../validate.ts' + +/** Parameters for reading an SPL token mint's metadata. */ +export type GetTokenInfoParams = { + /** SPL token mint address. */ + tokenAddress: string +} + +/** SPL token metadata and mint state. */ +export type GetTokenInfoResult = TokenInfo & { + /** SPL Token or Token-2022 program that owns the mint. */ + tokenProgram: string + /** Total minted supply in base units. */ + supply: bigint + /** Whether the mint account has been initialized. */ + isInitialized: boolean + /** Authority allowed to mint new tokens, or null for fixed-supply tokens. */ + mintAuthority: string | null + /** Authority allowed to freeze token accounts, or null when freezing is disabled. */ + freezeAuthority: string | null +} + +type ParsedGetTokenInfoParams = GetTokenInfoParams & { mint: PublicKey } + +/** + * Reads an SPL token mint's metadata and state. + * + * @throws {@link CCTParamsInvalidError} If `tokenAddress` is not a valid Solana public key. + * @throws {@link CCIPSplTokenInvalidError} If the token metadata is not a valid SPL token. + * @throws {@link CCIPTokenMintNotFoundError} If the mint account does not exist. + * @throws {@link CCIPTokenMintInvalidError} If the mint is not owned by an SPL Token program. + * @throws {@link CCIPTokenDataParseError} If the mint data cannot be parsed. + */ +export class GetTokenInfo extends SolanaQuery< + GetTokenInfoParams, + GetTokenInfoResult, + ParsedGetTokenInfoParams +> { + readonly name = 'getTokenInfo' + + /** Converts and validates the mint address. */ + protected prepare(params: GetTokenInfoParams): ParsedGetTokenInfoParams { + return { + ...params, + mint: parsePublicKey(this.name, 'tokenAddress', params.tokenAddress), + } + } + + /** Delegates metadata lookup and reads the mint's SPL state. */ + protected async read( + chain: SolanaChain, + { mint, tokenAddress }: ParsedGetTokenInfoParams, + ): Promise { + const account = await resolveTokenMint(chain.connection, mint) + + let state + try { + state = unpackMint(mint, account, account.owner) + } catch (cause) { + throw new CCIPTokenDataParseError(tokenAddress, { + cause: cause instanceof Error ? cause : undefined, + }) + } + + const info = await chain.getTokenInfo(tokenAddress) + + return { + ...info, + tokenProgram: account.owner.toBase58(), + supply: state.supply, + isInitialized: state.isInitialized, + mintAuthority: state.mintAuthority?.toBase58() ?? null, + freezeAuthority: state.freezeAuthority?.toBase58() ?? null, + } + } +} diff --git a/ccip-sdk/src/cct/solana/token/operations/index.ts b/ccip-sdk/src/cct/solana/token/operations/index.ts index ac2fc604..6c8f9477 100644 --- a/ccip-sdk/src/cct/solana/token/operations/index.ts +++ b/ccip-sdk/src/cct/solana/token/operations/index.ts @@ -1,6 +1,7 @@ export * from './approve-token.ts' export * from './create-token-account.ts' export * from './deploy-token.ts' +export * from './get-token-info.ts' export * from './mint-tokens.ts' export * from './set-token-authority.ts' export * from './update-metadata-authority.ts' diff --git a/ccip-sdk/src/gas.ts b/ccip-sdk/src/gas.ts index 957177c4..738da11a 100644 --- a/ccip-sdk/src/gas.ts +++ b/ccip-sdk/src/gas.ts @@ -196,7 +196,8 @@ export async function getDestTokenAmount({ let sourceTokenAddress, sourcePoolAddress, destTokenAddress if ('destTokenAddress' in tokenAmount) { ;({ destTokenAddress, sourcePoolAddress, sourceTokenAddress } = tokenAmount) - } else if (!source) return tokenAmount // if we don't have a source, assume we were already given a dest `{token, amount}` + } else if (!source) + return tokenAmount // if we don't have a source, assume we were already given a dest `{token, amount}` else { ;({ destTokenAddress, sourceTokenAddress, sourcePoolAddress } = await sourceToDestTokenAddresses({ diff --git a/package-lock.json b/package-lock.json index d9e06459..b236d185 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6988,23 +6988,6 @@ "react-dom": "19.1.4" } }, - "node_modules/@ledgerhq/domain-service/node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/@ledgerhq/domain-service/node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/@ledgerhq/domain-service/node_modules/react": { "version": "19.1.4", "license": "MIT",