From 801840f65ad51337ed25632babfff599c469b9f3 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:42:33 -0700 Subject: [PATCH 1/5] feat(js): add token transfer destination assertion Refs: DEV-1241 --- .../assertValidTokenTransferDestination.ts | 233 +++++++++ clients/js/src/index.ts | 1 + ...ssertValidTokenTransferDestination.test.ts | 463 ++++++++++++++++++ 3 files changed, 697 insertions(+) create mode 100644 clients/js/src/assertValidTokenTransferDestination.ts create mode 100644 clients/js/test/assertValidTokenTransferDestination.test.ts diff --git a/clients/js/src/assertValidTokenTransferDestination.ts b/clients/js/src/assertValidTokenTransferDestination.ts new file mode 100644 index 00000000..72891e3d --- /dev/null +++ b/clients/js/src/assertValidTokenTransferDestination.ts @@ -0,0 +1,233 @@ +import { SYSTEM_PROGRAM_ADDRESS } from '@solana-program/system'; +import { + fetchEncodedAccount, + isOffCurveAddress, + type Address, + type FetchAccountConfig, + type GetAccountInfoApi, + type Rpc, +} from '@solana/kit'; +import { + getAddressFromResolvedInstructionAccount, + type InstructionAccountInput, +} from '@solana/kit/program-client-core'; + +import { AccountState, decodeToken, findAssociatedTokenPda, getTokenSize, TOKEN_PROGRAM_ADDRESS } from './generated'; + +/** Distinguishes why a destination was rejected by {@link assertValidTokenTransferDestination}. */ +export type InvalidTokenTransferDestinationReason = + | 'unexpected-owner' + | 'unfunded-recipient' + | 'off-curve' + | 'ata-mismatch' + | 'missing-token-account' + | 'not-token-account' + | 'mint-mismatch' + | 'token-owner-mismatch'; + +/** Details attached to an {@link InvalidTokenTransferDestinationError}, depending on its `reason`. */ +export type InvalidTokenTransferDestinationDetails = { + /** The program that owns the recipient account. Set when `reason` is `'unexpected-owner'`. */ + owner?: Address; + /** The mint the destination token account holds. Set when `reason` is `'mint-mismatch'`. */ + mint?: Address; + /** The current owner of the destination token account. Set when `reason` is `'token-owner-mismatch'`. */ + tokenOwner?: Address; +}; + +/** Thrown by {@link assertValidTokenTransferDestination} when a destination is rejected. */ +export class InvalidTokenTransferDestinationError extends Error { + override readonly name = 'InvalidTokenTransferDestinationError'; + readonly owner?: Address; + readonly mint?: Address; + readonly tokenOwner?: Address; + + constructor( + message: string, + readonly destination: Address, + readonly recipient: Address, + readonly reason: InvalidTokenTransferDestinationReason, + details: InvalidTokenTransferDestinationDetails = {}, + ) { + super(message); + this.owner = details.owner; + this.mint = details.mint; + this.tokenOwner = details.tokenOwner; + } +} + +export type AssertValidTokenTransferDestinationInput = { + /** The token mint to transfer. */ + mint: InstructionAccountInput; + /** + * Associated token account address to transfer to, which may not exist yet. With + * `allowNonAssociatedTokenAccount`, any existing token account owned by `recipient`. + * Note: Use {@link findAssociatedTokenPda} to derive the associated token account address. + */ + destination: InstructionAccountInput; + /** Wallet address for the destination. */ + recipient: InstructionAccountInput; +}; + +/** + * Options for {@link assertValidTokenTransferDestination}. Any {@link FetchAccountConfig} fields are passed + * through to the account fetches. + */ +export type AssertValidTokenTransferDestinationConfig = FetchAccountConfig & { + /** + * Allow a recipient that has no account on-chain yet. Defaults to `false`, since an unfunded recipient + * is often a mistyped address. + */ + allowUnfundedRecipient?: boolean; + /** + * Allow an off-curve (program-derived) recipient, existing or not. Defaults to `true`, since + * program-controlled wallets such as multisigs are common, legitimate recipients; set to `false` to + * reject off-curve recipients until they confirm they control them. + */ + allowOffCurve?: boolean; + /** + * The program an existing recipient account must be owned by. Defaults to the System Program + * ({@link SYSTEM_PROGRAM_ADDRESS}). + */ + programOwner?: Address; + /** + * Allow a destination that is not the recipient's associated token account, as long as it is an + * existing token account for `mint` owned by `recipient`. Defaults to `false`. Such a destination cannot + * be used with `getTransferToATAInstructionPlan`; transfer to it with `getTransferCheckedInstruction`. + */ + allowNonAssociatedTokenAccount?: boolean; + /** + * Allow an associated token account destination that does not exist yet, creating it as part of the + * transfer. Defaults to `true`, since a recipient's first transfer of a mint is the common case; set to + * `false` to avoid paying its rent or to require that the recipient has held the mint before. + */ + allowMissingAssociatedTokenAccount?: boolean; +}; + +/** + * Asserts that `recipient` can safely receive a transfer of `mint` at `destination`, its associated token + * account, by fetching both accounts. Matches the inputs of `getTransferToATAInstructionPlan`. Otherwise + * throws an {@link InvalidTokenTransferDestinationError} with one of these reasons: + * + * - `'unexpected-owner'`: the recipient account exists but is not owned by `programOwner` (the System + * Program by default), e.g. a token account or mint passed as the recipient. + * - `'unfunded-recipient'`: the recipient has no account on-chain, which often means a mistyped address. + * Skipped when `allowUnfundedRecipient` is set. + * - `'off-curve'`: the recipient is off-curve, existing or not, and may be a smart wallet they must confirm + * they control. Only checked when `allowOffCurve` is `false`. + * - `'ata-mismatch'`: `destination` is not the recipient's associated token account for `mint`. Skipped + * when `allowNonAssociatedTokenAccount` is set. + * - `'missing-token-account'`: `destination` has no account on-chain. Skipped for an associated token + * account unless `allowMissingAssociatedTokenAccount` is `false`. + * - `'not-token-account'`: a non-associated `destination` is not an initialized token account. + * - `'mint-mismatch'`: a non-associated `destination` holds a different mint. + * - `'token-owner-mismatch'`: the destination exists but is not owned by `recipient`, e.g. an associated + * token account whose owner was reassigned. + * + * Checks run in the order listed, so a destination that fails several reports the first. Since an + * unfunded recipient cannot have an associated token account, setting `allowMissingAssociatedTokenAccount` + * to `false` also rejects unfunded recipients. + */ +export async function assertValidTokenTransferDestination( + rpc: Rpc, + input: AssertValidTokenTransferDestinationInput, + config?: AssertValidTokenTransferDestinationConfig, +): Promise { + const mint = getAddressFromResolvedInstructionAccount('mint', input.mint); + const destination = getAddressFromResolvedInstructionAccount('destination', input.destination); + const recipient = getAddressFromResolvedInstructionAccount('recipient', input.recipient); + const { + allowOffCurve = true, + allowUnfundedRecipient = false, + programOwner = SYSTEM_PROGRAM_ADDRESS, + allowNonAssociatedTokenAccount = false, + allowMissingAssociatedTokenAccount = true, + ...fetchConfig + } = config ?? {}; + const [recipientAccount, destinationAccount, [ata]] = await Promise.all([ + fetchEncodedAccount(rpc, recipient, fetchConfig), + fetchEncodedAccount(rpc, destination, fetchConfig), + findAssociatedTokenPda({ owner: recipient, mint, tokenProgram: TOKEN_PROGRAM_ADDRESS }), + ]); + + if (recipientAccount.exists) { + if (recipientAccount.programAddress !== programOwner) { + throw new InvalidTokenTransferDestinationError( + `Refusing to transfer tokens to ${recipient}: it is owned by program ${recipientAccount.programAddress}, not the expected program (${programOwner}).`, + destination, + recipient, + 'unexpected-owner', + { owner: recipientAccount.programAddress }, + ); + } + } else if (!allowUnfundedRecipient) { + throw new InvalidTokenTransferDestinationError( + `Refusing to transfer tokens to ${recipient}: it has no account on-chain yet, which often means the address is mistyped. If you intend to send to a new wallet, pass { allowUnfundedRecipient: true }.`, + destination, + recipient, + 'unfunded-recipient', + ); + } + + if (isOffCurveAddress(recipient) && !allowOffCurve) { + throw new InvalidTokenTransferDestinationError( + `Refusing to transfer tokens to ${recipient}: it is an off-curve (program-derived) address, which no keypair controls. It may be a program-controlled account such as a multisig or smart wallet; once the recipient confirms they control it, pass { allowOffCurve: true }.`, + destination, + recipient, + 'off-curve', + ); + } + + const isAssociatedTokenAccount = destination === ata; + if (!isAssociatedTokenAccount && !allowNonAssociatedTokenAccount) { + throw new InvalidTokenTransferDestinationError( + `Refusing to transfer tokens to ${destination}: it is not the associated token account of ${recipient} for mint ${mint} (${ata}). To send to another token account owned by the recipient, pass { allowNonAssociatedTokenAccount: true }.`, + destination, + recipient, + 'ata-mismatch', + ); + } + + if (!destinationAccount.exists) { + if (isAssociatedTokenAccount && allowMissingAssociatedTokenAccount) return; + throw new InvalidTokenTransferDestinationError( + isAssociatedTokenAccount + ? `Refusing to transfer tokens to ${destination}: the associated token account has no account on-chain yet. To create it as part of the transfer, pass { allowMissingAssociatedTokenAccount: true }.` + : `Refusing to transfer tokens to ${destination}: it has no account on-chain, and only an associated token account can be created as part of the transfer.`, + destination, + recipient, + 'missing-token-account', + ); + } + + const token = + destinationAccount.programAddress === TOKEN_PROGRAM_ADDRESS && destinationAccount.data.length === getTokenSize() + ? decodeToken(destinationAccount).data + : null; + if (!token || token.state === AccountState.Uninitialized) { + throw new InvalidTokenTransferDestinationError( + `Refusing to transfer tokens to ${destination}: it is not an initialized token account.`, + destination, + recipient, + 'not-token-account', + ); + } + if (token.mint !== mint) { + throw new InvalidTokenTransferDestinationError( + `Refusing to transfer tokens to ${destination}: it holds mint ${token.mint}, not the expected mint (${mint}).`, + destination, + recipient, + 'mint-mismatch', + { mint: token.mint }, + ); + } + if (token.owner !== recipient) { + throw new InvalidTokenTransferDestinationError( + `Refusing to transfer tokens to ${destination}: it is owned by ${token.owner}, not the recipient ${recipient}.`, + destination, + recipient, + 'token-owner-mismatch', + { tokenOwner: token.owner }, + ); + } +} diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts index fc2c7db4..3cf4223c 100644 --- a/clients/js/src/index.ts +++ b/clients/js/src/index.ts @@ -7,3 +7,4 @@ export { type BatchInstruction, getBatchInstruction, parseBatchInstruction } fro export * from './createMint'; export * from './mintToATA'; export * from './transferToATA'; +export * from './assertValidTokenTransferDestination'; diff --git a/clients/js/test/assertValidTokenTransferDestination.test.ts b/clients/js/test/assertValidTokenTransferDestination.test.ts new file mode 100644 index 00000000..5eca7444 --- /dev/null +++ b/clients/js/test/assertValidTokenTransferDestination.test.ts @@ -0,0 +1,463 @@ +import { SYSTEM_PROGRAM_ADDRESS } from '@solana-program/system'; +import { + address, + generateKeyPairSigner, + getProgramDerivedAddress, + lamports, + type Address, + type HasAddress, +} from '@solana/kit'; +import { expect, it, vi } from 'vitest'; + +import { + assertValidTokenTransferDestination, + AuthorityType, + findAssociatedTokenPda, + getMultisigSize, + getTokenSize, + InvalidTokenTransferDestinationError, + TOKEN_PROGRAM_ADDRESS, +} from '../src'; +import { createTestClient, createToken, type TestClient } from './_setup'; + +const OTHER_PROGRAM_ADDRESS = address('BPFLoaderUpgradeab1e11111111111111111111111'); + +class AddressWrapper implements HasAddress { + constructor(readonly address: Address) {} +} + +const createTestMint = async (client: TestClient): Promise
=> { + const [mint, mintAuthority] = await Promise.all([generateKeyPairSigner(), generateKeyPairSigner()]); + await client.token.instructions + .createMint({ newMint: mint, decimals: 2, mintAuthority: mintAuthority.address }) + .sendTransaction(); + return mint.address; +}; + +const createFundedAddress = async (client: TestClient): Promise
=> { + const recipient = (await generateKeyPairSigner()).address; + await client.airdrop(recipient, lamports(1_000_000_000n)); + return recipient; +}; + +const createForeignOwnedAddress = async (client: TestClient): Promise
=> { + const account = await generateKeyPairSigner(); + await client.airdrop(account.address, lamports(1_000_000_000n)); + await client.system.instructions.assign({ account, programAddress: OTHER_PROGRAM_ADDRESS }).sendTransaction(); + return account.address; +}; + +const deriveOffCurveAddress = async (): Promise
=> { + const [pda] = await getProgramDerivedAddress({ + programAddress: SYSTEM_PROGRAM_ADDRESS, + seeds: ['guarded-transfer'], + }); + return pda; +}; + +const findAta = async (owner: Address, mint: Address): Promise
=> { + const [ata] = await findAssociatedTokenPda({ owner, mint, tokenProgram: TOKEN_PROGRAM_ADDRESS }); + return ata; +}; + +const createAta = async (client: TestClient, owner: Address, mint: Address): Promise
=> { + const payer = await generateKeyPairSigner(); + await client.airdrop(payer.address, lamports(1_000_000_000n)); + await client.associatedToken.instructions.createAssociatedToken({ payer, owner, mint }).sendTransaction(); + return await findAta(owner, mint); +}; + +const createUninitializedTokenProgramAccount = async (client: TestClient, space: number): Promise
=> { + const newAccount = await generateKeyPairSigner(); + const rent = await client.rpc.getMinimumBalanceForRentExemption(BigInt(space)).send(); + await client.system.instructions + .createAccount({ newAccount, lamports: rent, space, programAddress: TOKEN_PROGRAM_ADDRESS }) + .sendTransaction(); + return newAccount.address; +}; + +const nonAta = { allowNonAssociatedTokenAccount: true }; + +it('accepts the existing associated token account of a System-Program-owned recipient', async () => { + // Given a funded recipient with an associated token account for the mint. + const client = await createTestClient(); + const [mint, recipient] = await Promise.all([createTestMint(client), createFundedAddress(client)]); + const destination = await createAta(client, recipient, mint); + + // Then it is a valid destination. + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }), + ).resolves.toBeUndefined(); +}); + +it('accepts an associated token account that does not exist yet', async () => { + // Given a funded recipient without an associated token account for the mint. + const client = await createTestClient(); + const [mint, recipient] = await Promise.all([createTestMint(client), createFundedAddress(client)]); + const destination = await findAta(recipient, mint); + + // Then it is a valid destination. + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }), + ).resolves.toBeUndefined(); +}); + +it('rejects a missing associated token account when allowMissingAssociatedTokenAccount is false', async () => { + // Given a funded recipient without an associated token account for the mint. + const client = await createTestClient(); + const [mint, recipient] = await Promise.all([createTestMint(client), createFundedAddress(client)]); + const destination = await findAta(recipient, mint); + + // Then it is rejected as missing when missing associated token accounts are disallowed. + await expect( + assertValidTokenTransferDestination( + client.rpc, + { mint, destination, recipient }, + { allowMissingAssociatedTokenAccount: false }, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('allowMissingAssociatedTokenAccount'), + destination, + reason: 'missing-token-account', + }); +}); + +it('accepts an existing associated token account when allowMissingAssociatedTokenAccount is false', async () => { + // Given a funded recipient with an associated token account for the mint. + const client = await createTestClient(); + const [mint, recipient] = await Promise.all([createTestMint(client), createFundedAddress(client)]); + const destination = await createAta(client, recipient, mint); + + // Then it is valid when missing associated token accounts are disallowed. + await expect( + assertValidTokenTransferDestination( + client.rpc, + { mint, destination, recipient }, + { allowMissingAssociatedTokenAccount: false }, + ), + ).resolves.toBeUndefined(); +}); + +it('rejects an unfunded recipient as missing-token-account when allowMissingAssociatedTokenAccount is false', async () => { + // Given an on-curve recipient with no account on-chain. + const client = await createTestClient(); + const [mint, { address: recipient }] = await Promise.all([createTestMint(client), generateKeyPairSigner()]); + const destination = await findAta(recipient, mint); + + // Then it is rejected even when unfunded recipients are allowed. + await expect( + assertValidTokenTransferDestination( + client.rpc, + { mint, destination, recipient }, + { allowMissingAssociatedTokenAccount: false, allowUnfundedRecipient: true }, + ), + ).rejects.toMatchObject({ reason: 'missing-token-account' }); +}); + +it('rejects a recipient owned by another program', async () => { + // Given a recipient owned by another program. + const client = await createTestClient(); + const [mint, recipient] = await Promise.all([createTestMint(client), createForeignOwnedAddress(client)]); + const destination = await findAta(recipient, mint); + + // Then it is rejected as owned by an unexpected program. + const promise = assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }); + await expect(promise).rejects.toBeInstanceOf(InvalidTokenTransferDestinationError); + await expect(promise).rejects.toMatchObject({ + message: expect.stringContaining(`owned by program ${OTHER_PROGRAM_ADDRESS}`), + destination, + recipient, + reason: 'unexpected-owner', + owner: OTHER_PROGRAM_ADDRESS, + }); +}); + +it('rejects a token account passed as the recipient as unexpected-owner before off-curve', async () => { + // Given an associated token account, itself off-curve, used as the recipient. + const client = await createTestClient(); + const [mint, wallet] = await Promise.all([createTestMint(client), createFundedAddress(client)]); + const recipient = await createAta(client, wallet, mint); + const destination = await findAta(recipient, mint); + + // Then the owner check fails first, even when off-curve recipients are disallowed. + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }, { allowOffCurve: false }), + ).rejects.toMatchObject({ reason: 'unexpected-owner', owner: TOKEN_PROGRAM_ADDRESS }); +}); + +it('accepts a recipient owned by the program given in programOwner', async () => { + // Given a recipient owned by another program. + const client = await createTestClient(); + const [mint, recipient] = await Promise.all([createTestMint(client), createForeignOwnedAddress(client)]); + const destination = await findAta(recipient, mint); + + // Then it is valid when that program is the expected owner. + await expect( + assertValidTokenTransferDestination( + client.rpc, + { mint, destination, recipient }, + { programOwner: OTHER_PROGRAM_ADDRESS }, + ), + ).resolves.toBeUndefined(); +}); + +it('rejects an unfunded recipient by default', async () => { + // Given an on-curve recipient with no account on-chain. + const client = await createTestClient(); + const [mint, { address: recipient }] = await Promise.all([createTestMint(client), generateKeyPairSigner()]); + const destination = await findAta(recipient, mint); + + // Then it is rejected as unfunded. + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }), + ).rejects.toMatchObject({ recipient, reason: 'unfunded-recipient' }); +}); + +it('accepts an unfunded recipient when allowUnfundedRecipient is set', async () => { + // Given an on-curve recipient with no account on-chain. + const client = await createTestClient(); + const [mint, { address: recipient }] = await Promise.all([createTestMint(client), generateKeyPairSigner()]); + const destination = await findAta(recipient, mint); + + // Then it is valid when unfunded recipients are allowed. + await expect( + assertValidTokenTransferDestination( + client.rpc, + { mint, destination, recipient }, + { allowUnfundedRecipient: true }, + ), + ).resolves.toBeUndefined(); +}); + +it('accepts an off-curve recipient unless allowOffCurve is false', async () => { + // Given a funded off-curve recipient with an associated token account for the mint. + const client = await createTestClient(); + const [mint, recipient] = await Promise.all([createTestMint(client), deriveOffCurveAddress()]); + await client.airdrop(recipient, lamports(1_000_000_000n)); + const destination = await createAta(client, recipient, mint); + const input = { mint, destination, recipient }; + + // Then it is valid by default and rejected when off-curve recipients are disallowed. + await expect(assertValidTokenTransferDestination(client.rpc, input)).resolves.toBeUndefined(); + await expect( + assertValidTokenTransferDestination(client.rpc, input, { allowOffCurve: false }), + ).rejects.toMatchObject({ recipient, reason: 'off-curve' }); +}); + +it('rejects an unfunded off-curve recipient as off-curve when allowOffCurve is false', async () => { + // Given an off-curve recipient with no account on-chain. + const client = await createTestClient(); + const [mint, recipient] = await Promise.all([createTestMint(client), deriveOffCurveAddress()]); + const destination = await findAta(recipient, mint); + const input = { mint, destination, recipient }; + + // Then it is valid when unfunded recipients are allowed, unless off-curve recipients are disallowed. + await expect( + assertValidTokenTransferDestination(client.rpc, input, { allowUnfundedRecipient: true }), + ).resolves.toBeUndefined(); + await expect( + assertValidTokenTransferDestination(client.rpc, input, { + allowOffCurve: false, + allowUnfundedRecipient: true, + }), + ).rejects.toMatchObject({ reason: 'off-curve' }); +}); + +it('rejects a destination that is the recipient associated token account for another mint', async () => { + // Given a recipient and its associated token account for another mint. + const client = await createTestClient(); + const [mint, otherMint, recipient] = await Promise.all([ + createTestMint(client), + createTestMint(client), + createFundedAddress(client), + ]); + const [destination, expectedDestination] = await Promise.all([ + createAta(client, recipient, otherMint), + findAta(recipient, mint), + ]); + + // Then it is rejected as not the expected associated token account. + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }), + ).rejects.toMatchObject({ + message: expect.stringContaining(`(${expectedDestination})`), + destination, + recipient, + reason: 'ata-mismatch', + }); +}); + +it('rejects a non-associated token account owned by the recipient', async () => { + // Given a recipient and a non-associated token account it owns for the mint. + const client = await createTestClient(); + const [mint, recipient] = await Promise.all([createTestMint(client), createFundedAddress(client)]); + const destination = await createToken(client, mint, recipient); + + // Then it is rejected as not the expected associated token account. + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }), + ).rejects.toMatchObject({ reason: 'ata-mismatch' }); +}); + +it('accepts a non-associated token account owned by the recipient when allowNonAssociatedTokenAccount is set', async () => { + // Given a recipient and a non-associated token account it owns for the mint. + const client = await createTestClient(); + const [mint, recipient] = await Promise.all([createTestMint(client), createFundedAddress(client)]); + const destination = await createToken(client, mint, recipient); + + // Then it is valid when non-associated token accounts are allowed. + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }, nonAta), + ).resolves.toBeUndefined(); +}); + +it('still accepts associated token accounts when allowNonAssociatedTokenAccount is set', async () => { + // Given a funded recipient without an associated token account for the mint. + const client = await createTestClient(); + const [mint, recipient] = await Promise.all([createTestMint(client), createFundedAddress(client)]); + const destination = await findAta(recipient, mint); + + // Then the missing associated token account is still valid. + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }, nonAta), + ).resolves.toBeUndefined(); +}); + +it('rejects a missing non-associated token account', async () => { + // Given a recipient and an address with no account on-chain. + const client = await createTestClient(); + const [mint, recipient, { address: destination }] = await Promise.all([ + createTestMint(client), + createFundedAddress(client), + generateKeyPairSigner(), + ]); + + // Then it is rejected as missing. + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }, nonAta), + ).rejects.toMatchObject({ destination, reason: 'missing-token-account' }); +}); + +it('rejects a non-associated destination that is not a token account', async () => { + // Given a recipient, a wallet, a mint, a multisig-sized account and an uninitialized token account. + const client = await createTestClient(); + const [mint, recipient, wallet, multisig, uninitialized] = await Promise.all([ + createTestMint(client), + createFundedAddress(client), + createFundedAddress(client), + createUninitializedTokenProgramAccount(client, getMultisigSize()), + createUninitializedTokenProgramAccount(client, getTokenSize()), + ]); + + // Then each is rejected as not a token account. + for (const destination of [wallet, mint, multisig, uninitialized]) { + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }, nonAta), + ).rejects.toMatchObject({ destination, reason: 'not-token-account' }); + } +}); + +it('rejects a non-associated token account for another mint', async () => { + // Given a recipient and a token account it owns for another mint. + const client = await createTestClient(); + const [mint, otherMint, recipient] = await Promise.all([ + createTestMint(client), + createTestMint(client), + createFundedAddress(client), + ]); + const destination = await createToken(client, otherMint, recipient); + + // Then it is rejected as a mint mismatch. + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }, nonAta), + ).rejects.toMatchObject({ destination, reason: 'mint-mismatch', mint: otherMint }); +}); + +it('rejects a non-associated token account owned by someone else', async () => { + // Given a recipient and a token account for the mint owned by another wallet. + const client = await createTestClient(); + const [mint, recipient, otherOwner] = await Promise.all([ + createTestMint(client), + createFundedAddress(client), + createFundedAddress(client), + ]); + const destination = await createToken(client, mint, otherOwner); + + // Then it is rejected as owned by someone other than the recipient. + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }, nonAta), + ).rejects.toMatchObject({ destination, reason: 'token-owner-mismatch', tokenOwner: otherOwner }); +}); + +it('rejects an associated token account whose owner was reassigned', async () => { + // Given a recipient whose associated token account was reassigned to another owner. + const client = await createTestClient(); + const [mint, recipient, newOwner] = await Promise.all([ + createTestMint(client), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + await client.airdrop(recipient.address, lamports(1_000_000_000n)); + const destination = await createAta(client, recipient.address, mint); + await client.token.instructions + .setAuthority({ + owned: destination, + owner: recipient, + authorityType: AuthorityType.AccountOwner, + newAuthority: newOwner.address, + }) + .sendTransaction(); + + // Then it is rejected as owned by someone other than the recipient. + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient: recipient.address }), + ).rejects.toMatchObject({ + destination, + recipient: recipient.address, + reason: 'token-owner-mismatch', + tokenOwner: newOwner.address, + }); +}); + +it('accepts signer, HasAddress and ProgramDerivedAddress inputs', async () => { + // Given a mint held in an `AddressWrapper` and a funded recipient held as a signer. + const client = await createTestClient(); + const mint = new AddressWrapper(await createTestMint(client)); + const recipient = await generateKeyPairSigner(); + await client.airdrop(recipient.address, lamports(1_000_000_000n)); + const destination = await findAssociatedTokenPda({ + owner: recipient.address, + mint: mint.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + // Then it is valid with the destination held as a `[address, bump]` tuple. + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }), + ).resolves.toBeUndefined(); +}); + +it('does not forward guard options to getAccountInfo', async () => { + // Given a funded recipient and an RPC that records getAccountInfo calls. + const client = await createTestClient(); + const [mint, recipient] = await Promise.all([createTestMint(client), createFundedAddress(client)]); + const destination = await findAta(recipient, mint); + const getAccountInfo = vi.fn(client.rpc.getAccountInfo); + const rpc = { getAccountInfo } as unknown as typeof client.rpc; + + // When we validate it with guard options and a commitment. + await assertValidTokenTransferDestination( + rpc, + { mint, destination, recipient }, + { + allowOffCurve: true, + allowUnfundedRecipient: true, + commitment: 'confirmed', + programOwner: SYSTEM_PROGRAM_ADDRESS, + }, + ); + + // Then only the fetch options reach the RPC, for both the recipient and the destination. + expect(getAccountInfo).toHaveBeenCalledWith(recipient, { commitment: 'confirmed', encoding: 'base64' }); + expect(getAccountInfo).toHaveBeenCalledWith(destination, { commitment: 'confirmed', encoding: 'base64' }); +}); From f84bc53b0ec36317913a65d9c8f304f98bbdf382 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:38:37 -0700 Subject: [PATCH 2/5] refactor(js): narrow destination error details by reason Refs: DEV-1241 --- .../assertValidTokenTransferDestination.ts | 71 +++++++++---------- ...ssertValidTokenTransferDestination.test.ts | 30 ++++---- 2 files changed, 47 insertions(+), 54 deletions(-) diff --git a/clients/js/src/assertValidTokenTransferDestination.ts b/clients/js/src/assertValidTokenTransferDestination.ts index 72891e3d..7c5be2f0 100644 --- a/clients/js/src/assertValidTokenTransferDestination.ts +++ b/clients/js/src/assertValidTokenTransferDestination.ts @@ -14,45 +14,43 @@ import { import { AccountState, decodeToken, findAssociatedTokenPda, getTokenSize, TOKEN_PROGRAM_ADDRESS } from './generated'; -/** Distinguishes why a destination was rejected by {@link assertValidTokenTransferDestination}. */ -export type InvalidTokenTransferDestinationReason = - | 'unexpected-owner' - | 'unfunded-recipient' - | 'off-curve' - | 'ata-mismatch' - | 'missing-token-account' - | 'not-token-account' - | 'mint-mismatch' - | 'token-owner-mismatch'; +/** Why a destination was rejected by {@link assertValidTokenTransferDestination}, with the fields specific to that reason. */ +export type InvalidTokenTransferDestinationDetails = + | { + reason: 'unexpected-owner'; + /** The program that owns the recipient account. */ + owner: Address; + } + | { reason: 'unfunded-recipient' } + | { reason: 'off-curve' } + | { reason: 'ata-mismatch' } + | { reason: 'missing-token-account' } + | { reason: 'not-token-account' } + | { + reason: 'mint-mismatch'; + /** The mint the destination token account holds. */ + mint: Address; + } + | { + reason: 'token-owner-mismatch'; + /** The current owner of the destination token account. */ + tokenOwner: Address; + }; -/** Details attached to an {@link InvalidTokenTransferDestinationError}, depending on its `reason`. */ -export type InvalidTokenTransferDestinationDetails = { - /** The program that owns the recipient account. Set when `reason` is `'unexpected-owner'`. */ - owner?: Address; - /** The mint the destination token account holds. Set when `reason` is `'mint-mismatch'`. */ - mint?: Address; - /** The current owner of the destination token account. Set when `reason` is `'token-owner-mismatch'`. */ - tokenOwner?: Address; -}; +/** Distinguishes why a destination was rejected by {@link assertValidTokenTransferDestination}. */ +export type InvalidTokenTransferDestinationReason = InvalidTokenTransferDestinationDetails['reason']; /** Thrown by {@link assertValidTokenTransferDestination} when a destination is rejected. */ export class InvalidTokenTransferDestinationError extends Error { override readonly name = 'InvalidTokenTransferDestinationError'; - readonly owner?: Address; - readonly mint?: Address; - readonly tokenOwner?: Address; constructor( message: string, readonly destination: Address, readonly recipient: Address, - readonly reason: InvalidTokenTransferDestinationReason, - details: InvalidTokenTransferDestinationDetails = {}, + readonly details: InvalidTokenTransferDestinationDetails, ) { super(message); - this.owner = details.owner; - this.mint = details.mint; - this.tokenOwner = details.tokenOwner; } } @@ -156,8 +154,7 @@ export async function assertValidTokenTransferDestination( `Refusing to transfer tokens to ${recipient}: it is owned by program ${recipientAccount.programAddress}, not the expected program (${programOwner}).`, destination, recipient, - 'unexpected-owner', - { owner: recipientAccount.programAddress }, + { reason: 'unexpected-owner', owner: recipientAccount.programAddress }, ); } } else if (!allowUnfundedRecipient) { @@ -165,7 +162,7 @@ export async function assertValidTokenTransferDestination( `Refusing to transfer tokens to ${recipient}: it has no account on-chain yet, which often means the address is mistyped. If you intend to send to a new wallet, pass { allowUnfundedRecipient: true }.`, destination, recipient, - 'unfunded-recipient', + { reason: 'unfunded-recipient' }, ); } @@ -174,7 +171,7 @@ export async function assertValidTokenTransferDestination( `Refusing to transfer tokens to ${recipient}: it is an off-curve (program-derived) address, which no keypair controls. It may be a program-controlled account such as a multisig or smart wallet; once the recipient confirms they control it, pass { allowOffCurve: true }.`, destination, recipient, - 'off-curve', + { reason: 'off-curve' }, ); } @@ -184,7 +181,7 @@ export async function assertValidTokenTransferDestination( `Refusing to transfer tokens to ${destination}: it is not the associated token account of ${recipient} for mint ${mint} (${ata}). To send to another token account owned by the recipient, pass { allowNonAssociatedTokenAccount: true }.`, destination, recipient, - 'ata-mismatch', + { reason: 'ata-mismatch' }, ); } @@ -196,7 +193,7 @@ export async function assertValidTokenTransferDestination( : `Refusing to transfer tokens to ${destination}: it has no account on-chain, and only an associated token account can be created as part of the transfer.`, destination, recipient, - 'missing-token-account', + { reason: 'missing-token-account' }, ); } @@ -209,7 +206,7 @@ export async function assertValidTokenTransferDestination( `Refusing to transfer tokens to ${destination}: it is not an initialized token account.`, destination, recipient, - 'not-token-account', + { reason: 'not-token-account' }, ); } if (token.mint !== mint) { @@ -217,8 +214,7 @@ export async function assertValidTokenTransferDestination( `Refusing to transfer tokens to ${destination}: it holds mint ${token.mint}, not the expected mint (${mint}).`, destination, recipient, - 'mint-mismatch', - { mint: token.mint }, + { reason: 'mint-mismatch', mint: token.mint }, ); } if (token.owner !== recipient) { @@ -226,8 +222,7 @@ export async function assertValidTokenTransferDestination( `Refusing to transfer tokens to ${destination}: it is owned by ${token.owner}, not the recipient ${recipient}.`, destination, recipient, - 'token-owner-mismatch', - { tokenOwner: token.owner }, + { reason: 'token-owner-mismatch', tokenOwner: token.owner }, ); } } diff --git a/clients/js/test/assertValidTokenTransferDestination.test.ts b/clients/js/test/assertValidTokenTransferDestination.test.ts index 5eca7444..d70c3db9 100644 --- a/clients/js/test/assertValidTokenTransferDestination.test.ts +++ b/clients/js/test/assertValidTokenTransferDestination.test.ts @@ -118,7 +118,7 @@ it('rejects a missing associated token account when allowMissingAssociatedTokenA ).rejects.toMatchObject({ message: expect.stringContaining('allowMissingAssociatedTokenAccount'), destination, - reason: 'missing-token-account', + details: { reason: 'missing-token-account' }, }); }); @@ -151,7 +151,7 @@ it('rejects an unfunded recipient as missing-token-account when allowMissingAsso { mint, destination, recipient }, { allowMissingAssociatedTokenAccount: false, allowUnfundedRecipient: true }, ), - ).rejects.toMatchObject({ reason: 'missing-token-account' }); + ).rejects.toMatchObject({ details: { reason: 'missing-token-account' } }); }); it('rejects a recipient owned by another program', async () => { @@ -167,8 +167,7 @@ it('rejects a recipient owned by another program', async () => { message: expect.stringContaining(`owned by program ${OTHER_PROGRAM_ADDRESS}`), destination, recipient, - reason: 'unexpected-owner', - owner: OTHER_PROGRAM_ADDRESS, + details: { reason: 'unexpected-owner', owner: OTHER_PROGRAM_ADDRESS }, }); }); @@ -182,7 +181,7 @@ it('rejects a token account passed as the recipient as unexpected-owner before o // Then the owner check fails first, even when off-curve recipients are disallowed. await expect( assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }, { allowOffCurve: false }), - ).rejects.toMatchObject({ reason: 'unexpected-owner', owner: TOKEN_PROGRAM_ADDRESS }); + ).rejects.toMatchObject({ details: { reason: 'unexpected-owner', owner: TOKEN_PROGRAM_ADDRESS } }); }); it('accepts a recipient owned by the program given in programOwner', async () => { @@ -210,7 +209,7 @@ it('rejects an unfunded recipient by default', async () => { // Then it is rejected as unfunded. await expect( assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }), - ).rejects.toMatchObject({ recipient, reason: 'unfunded-recipient' }); + ).rejects.toMatchObject({ recipient, details: { reason: 'unfunded-recipient' } }); }); it('accepts an unfunded recipient when allowUnfundedRecipient is set', async () => { @@ -241,7 +240,7 @@ it('accepts an off-curve recipient unless allowOffCurve is false', async () => { await expect(assertValidTokenTransferDestination(client.rpc, input)).resolves.toBeUndefined(); await expect( assertValidTokenTransferDestination(client.rpc, input, { allowOffCurve: false }), - ).rejects.toMatchObject({ recipient, reason: 'off-curve' }); + ).rejects.toMatchObject({ recipient, details: { reason: 'off-curve' } }); }); it('rejects an unfunded off-curve recipient as off-curve when allowOffCurve is false', async () => { @@ -260,7 +259,7 @@ it('rejects an unfunded off-curve recipient as off-curve when allowOffCurve is f allowOffCurve: false, allowUnfundedRecipient: true, }), - ).rejects.toMatchObject({ reason: 'off-curve' }); + ).rejects.toMatchObject({ details: { reason: 'off-curve' } }); }); it('rejects a destination that is the recipient associated token account for another mint', async () => { @@ -283,7 +282,7 @@ it('rejects a destination that is the recipient associated token account for ano message: expect.stringContaining(`(${expectedDestination})`), destination, recipient, - reason: 'ata-mismatch', + details: { reason: 'ata-mismatch' }, }); }); @@ -296,7 +295,7 @@ it('rejects a non-associated token account owned by the recipient', async () => // Then it is rejected as not the expected associated token account. await expect( assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }), - ).rejects.toMatchObject({ reason: 'ata-mismatch' }); + ).rejects.toMatchObject({ details: { reason: 'ata-mismatch' } }); }); it('accepts a non-associated token account owned by the recipient when allowNonAssociatedTokenAccount is set', async () => { @@ -335,7 +334,7 @@ it('rejects a missing non-associated token account', async () => { // Then it is rejected as missing. await expect( assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }, nonAta), - ).rejects.toMatchObject({ destination, reason: 'missing-token-account' }); + ).rejects.toMatchObject({ destination, details: { reason: 'missing-token-account' } }); }); it('rejects a non-associated destination that is not a token account', async () => { @@ -353,7 +352,7 @@ it('rejects a non-associated destination that is not a token account', async () for (const destination of [wallet, mint, multisig, uninitialized]) { await expect( assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }, nonAta), - ).rejects.toMatchObject({ destination, reason: 'not-token-account' }); + ).rejects.toMatchObject({ destination, details: { reason: 'not-token-account' } }); } }); @@ -370,7 +369,7 @@ it('rejects a non-associated token account for another mint', async () => { // Then it is rejected as a mint mismatch. await expect( assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }, nonAta), - ).rejects.toMatchObject({ destination, reason: 'mint-mismatch', mint: otherMint }); + ).rejects.toMatchObject({ destination, details: { reason: 'mint-mismatch', mint: otherMint } }); }); it('rejects a non-associated token account owned by someone else', async () => { @@ -386,7 +385,7 @@ it('rejects a non-associated token account owned by someone else', async () => { // Then it is rejected as owned by someone other than the recipient. await expect( assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }, nonAta), - ).rejects.toMatchObject({ destination, reason: 'token-owner-mismatch', tokenOwner: otherOwner }); + ).rejects.toMatchObject({ destination, details: { reason: 'token-owner-mismatch', tokenOwner: otherOwner } }); }); it('rejects an associated token account whose owner was reassigned', async () => { @@ -414,8 +413,7 @@ it('rejects an associated token account whose owner was reassigned', async () => ).rejects.toMatchObject({ destination, recipient: recipient.address, - reason: 'token-owner-mismatch', - tokenOwner: newOwner.address, + details: { reason: 'token-owner-mismatch', tokenOwner: newOwner.address }, }); }); From b102b2df817ba6bc776995fd0732eca9d5ea9759 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:49:43 -0700 Subject: [PATCH 3/5] docs(js): fix destination assertion docs and messages Refs: DEV-1241 --- .../assertValidTokenTransferDestination.ts | 23 +++++++++---------- ...ssertValidTokenTransferDestination.test.ts | 12 +++++----- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/clients/js/src/assertValidTokenTransferDestination.ts b/clients/js/src/assertValidTokenTransferDestination.ts index 7c5be2f0..51f8fd89 100644 --- a/clients/js/src/assertValidTokenTransferDestination.ts +++ b/clients/js/src/assertValidTokenTransferDestination.ts @@ -80,7 +80,7 @@ export type AssertValidTokenTransferDestinationConfig = FetchAccountConfig & { /** * Allow an off-curve (program-derived) recipient, existing or not. Defaults to `true`, since * program-controlled wallets such as multisigs are common, legitimate recipients; set to `false` to - * reject off-curve recipients until they confirm they control them. + * reject off-curve recipients. */ allowOffCurve?: boolean; /** @@ -103,16 +103,17 @@ export type AssertValidTokenTransferDestinationConfig = FetchAccountConfig & { }; /** - * Asserts that `recipient` can safely receive a transfer of `mint` at `destination`, its associated token - * account, by fetching both accounts. Matches the inputs of `getTransferToATAInstructionPlan`. Otherwise - * throws an {@link InvalidTokenTransferDestinationError} with one of these reasons: + * Asserts that `recipient` can safely receive a transfer of `mint` at `destination` by fetching both + * accounts. By default, `destination` must be the recipient's associated token account for `mint`, matching + * the inputs of `getTransferToATAInstructionPlan`. Otherwise throws an + * {@link InvalidTokenTransferDestinationError} with one of these reasons: * * - `'unexpected-owner'`: the recipient account exists but is not owned by `programOwner` (the System * Program by default), e.g. a token account or mint passed as the recipient. * - `'unfunded-recipient'`: the recipient has no account on-chain, which often means a mistyped address. * Skipped when `allowUnfundedRecipient` is set. - * - `'off-curve'`: the recipient is off-curve, existing or not, and may be a smart wallet they must confirm - * they control. Only checked when `allowOffCurve` is `false`. + * - `'off-curve'`: the recipient is off-curve (program-derived), existing or not. Only checked when + * `allowOffCurve` is `false`. * - `'ata-mismatch'`: `destination` is not the recipient's associated token account for `mint`. Skipped * when `allowNonAssociatedTokenAccount` is set. * - `'missing-token-account'`: `destination` has no account on-chain. Skipped for an associated token @@ -122,9 +123,7 @@ export type AssertValidTokenTransferDestinationConfig = FetchAccountConfig & { * - `'token-owner-mismatch'`: the destination exists but is not owned by `recipient`, e.g. an associated * token account whose owner was reassigned. * - * Checks run in the order listed, so a destination that fails several reports the first. Since an - * unfunded recipient cannot have an associated token account, setting `allowMissingAssociatedTokenAccount` - * to `false` also rejects unfunded recipients. + * Checks run in the order listed, so a destination that fails several reports the first. */ export async function assertValidTokenTransferDestination( rpc: Rpc, @@ -166,9 +165,9 @@ export async function assertValidTokenTransferDestination( ); } - if (isOffCurveAddress(recipient) && !allowOffCurve) { + if (!allowOffCurve && isOffCurveAddress(recipient)) { throw new InvalidTokenTransferDestinationError( - `Refusing to transfer tokens to ${recipient}: it is an off-curve (program-derived) address, which no keypair controls. It may be a program-controlled account such as a multisig or smart wallet; once the recipient confirms they control it, pass { allowOffCurve: true }.`, + `Refusing to transfer tokens to ${recipient}: it is an off-curve (program-derived) address, which no keypair controls. It may be a program-controlled account such as a multisig or smart wallet; to allow it, pass { allowOffCurve: true }.`, destination, recipient, { reason: 'off-curve' }, @@ -178,7 +177,7 @@ export async function assertValidTokenTransferDestination( const isAssociatedTokenAccount = destination === ata; if (!isAssociatedTokenAccount && !allowNonAssociatedTokenAccount) { throw new InvalidTokenTransferDestinationError( - `Refusing to transfer tokens to ${destination}: it is not the associated token account of ${recipient} for mint ${mint} (${ata}). To send to another token account owned by the recipient, pass { allowNonAssociatedTokenAccount: true }.`, + `Refusing to transfer tokens to ${destination}: it is not the associated token account of ${recipient} for mint ${mint} (ATA is: ${ata}). To send to another token account owned by the recipient, pass { allowNonAssociatedTokenAccount: true }.`, destination, recipient, { reason: 'ata-mismatch' }, diff --git a/clients/js/test/assertValidTokenTransferDestination.test.ts b/clients/js/test/assertValidTokenTransferDestination.test.ts index d70c3db9..68ebb846 100644 --- a/clients/js/test/assertValidTokenTransferDestination.test.ts +++ b/clients/js/test/assertValidTokenTransferDestination.test.ts @@ -138,20 +138,20 @@ it('accepts an existing associated token account when allowMissingAssociatedToke ).resolves.toBeUndefined(); }); -it('rejects an unfunded recipient as missing-token-account when allowMissingAssociatedTokenAccount is false', async () => { - // Given an on-curve recipient with no account on-chain. +it('accepts an existing associated token account of an unfunded recipient when allowMissingAssociatedTokenAccount is false', async () => { + // Given an on-curve recipient with no account on-chain but an associated token account for the mint. const client = await createTestClient(); const [mint, { address: recipient }] = await Promise.all([createTestMint(client), generateKeyPairSigner()]); - const destination = await findAta(recipient, mint); + const destination = await createAta(client, recipient, mint); - // Then it is rejected even when unfunded recipients are allowed. + // Then it is valid when unfunded recipients are allowed and missing associated token accounts are not. await expect( assertValidTokenTransferDestination( client.rpc, { mint, destination, recipient }, { allowMissingAssociatedTokenAccount: false, allowUnfundedRecipient: true }, ), - ).rejects.toMatchObject({ details: { reason: 'missing-token-account' } }); + ).resolves.toBeUndefined(); }); it('rejects a recipient owned by another program', async () => { @@ -279,7 +279,7 @@ it('rejects a destination that is the recipient associated token account for ano await expect( assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }), ).rejects.toMatchObject({ - message: expect.stringContaining(`(${expectedDestination})`), + message: expect.stringContaining(`(ATA is: ${expectedDestination})`), destination, recipient, details: { reason: 'ata-mismatch' }, From c02ffffaff437d17eb5c1b3551509f36b39caf6e Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:29:14 -0700 Subject: [PATCH 4/5] feat(js): add program configs to destination assertion Refs: DEV-1241 --- .../assertValidTokenTransferDestination.ts | 40 ++++++++++- ...ssertValidTokenTransferDestination.test.ts | 68 +++++++++++++++++-- 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/clients/js/src/assertValidTokenTransferDestination.ts b/clients/js/src/assertValidTokenTransferDestination.ts index 51f8fd89..717c6dfb 100644 --- a/clients/js/src/assertValidTokenTransferDestination.ts +++ b/clients/js/src/assertValidTokenTransferDestination.ts @@ -5,6 +5,8 @@ import { type Address, type FetchAccountConfig, type GetAccountInfoApi, + type HasAddress, + type ReadonlyUint8Array, type Rpc, } from '@solana/kit'; import { @@ -12,7 +14,17 @@ import { type InstructionAccountInput, } from '@solana/kit/program-client-core'; -import { AccountState, decodeToken, findAssociatedTokenPda, getTokenSize, TOKEN_PROGRAM_ADDRESS } from './generated'; +import { + AccountState, + decodeToken, + findAssociatedTokenPda, + getMultisigSize, + getTokenSize, + TOKEN_PROGRAM_ADDRESS, +} from './generated'; + +/** The Token-2022 `AccountType` byte, stored right after the base layout, that marks a token account. */ +const TOKEN_2022_ACCOUNT_TYPE = 2; /** Why a destination was rejected by {@link assertValidTokenTransferDestination}, with the fields specific to that reason. */ export type InvalidTokenTransferDestinationDetails = @@ -100,8 +112,27 @@ export type AssertValidTokenTransferDestinationConfig = FetchAccountConfig & { * `false` to avoid paying its rent or to require that the recipient has held the mint before. */ allowMissingAssociatedTokenAccount?: boolean; + /** + * The token program that owns `mint` and `destination`. Defaults to the Token program + * ({@link TOKEN_PROGRAM_ADDRESS}); pass the Token-2022 program address for Token-2022 mints. + */ + tokenProgram?: Address | HasAddress; + /** The program that derives associated token accounts. Defaults to the Associated Token program. */ + associatedTokenProgram?: Address | HasAddress; }; +/** + * Whether `data` has the layout of a token account for `tokenProgram`: exactly the base token account size, + * or, outside the Token program, longer and marked as an account (Token-2022 extensions) rather than a mint or + * multisig. + */ +function isTokenAccountData(data: ReadonlyUint8Array, tokenProgram: Address): boolean { + const tokenSize = getTokenSize(); + if (data.length === tokenSize) return true; + if (tokenProgram === TOKEN_PROGRAM_ADDRESS) return false; + return data.length > tokenSize && data.length !== getMultisigSize() && data[tokenSize] === TOKEN_2022_ACCOUNT_TYPE; +} + /** * Asserts that `recipient` can safely receive a transfer of `mint` at `destination` by fetching both * accounts. By default, `destination` must be the recipient's associated token account for `mint`, matching @@ -139,12 +170,15 @@ export async function assertValidTokenTransferDestination( programOwner = SYSTEM_PROGRAM_ADDRESS, allowNonAssociatedTokenAccount = false, allowMissingAssociatedTokenAccount = true, + tokenProgram: tokenProgramInput = TOKEN_PROGRAM_ADDRESS, + associatedTokenProgram, ...fetchConfig } = config ?? {}; + const tokenProgram = getAddressFromResolvedInstructionAccount('tokenProgram', tokenProgramInput); const [recipientAccount, destinationAccount, [ata]] = await Promise.all([ fetchEncodedAccount(rpc, recipient, fetchConfig), fetchEncodedAccount(rpc, destination, fetchConfig), - findAssociatedTokenPda({ owner: recipient, mint, tokenProgram: TOKEN_PROGRAM_ADDRESS }), + findAssociatedTokenPda({ owner: recipient, mint, tokenProgram }, { programAddress: associatedTokenProgram }), ]); if (recipientAccount.exists) { @@ -197,7 +231,7 @@ export async function assertValidTokenTransferDestination( } const token = - destinationAccount.programAddress === TOKEN_PROGRAM_ADDRESS && destinationAccount.data.length === getTokenSize() + destinationAccount.programAddress === tokenProgram && isTokenAccountData(destinationAccount.data, tokenProgram) ? decodeToken(destinationAccount).data : null; if (!token || token.state === AccountState.Uninitialized) { diff --git a/clients/js/test/assertValidTokenTransferDestination.test.ts b/clients/js/test/assertValidTokenTransferDestination.test.ts index 68ebb846..798bc392 100644 --- a/clients/js/test/assertValidTokenTransferDestination.test.ts +++ b/clients/js/test/assertValidTokenTransferDestination.test.ts @@ -11,6 +11,7 @@ import { expect, it, vi } from 'vitest'; import { assertValidTokenTransferDestination, + ASSOCIATED_TOKEN_PROGRAM_ADDRESS, AuthorityType, findAssociatedTokenPda, getMultisigSize, @@ -21,15 +22,16 @@ import { import { createTestClient, createToken, type TestClient } from './_setup'; const OTHER_PROGRAM_ADDRESS = address('BPFLoaderUpgradeab1e11111111111111111111111'); +const TOKEN_2022_PROGRAM_ADDRESS = address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'); class AddressWrapper implements HasAddress { constructor(readonly address: Address) {} } -const createTestMint = async (client: TestClient): Promise
=> { +const createTestMint = async (client: TestClient, tokenProgram?: Address): Promise
=> { const [mint, mintAuthority] = await Promise.all([generateKeyPairSigner(), generateKeyPairSigner()]); await client.token.instructions - .createMint({ newMint: mint, decimals: 2, mintAuthority: mintAuthority.address }) + .createMint({ newMint: mint, decimals: 2, mintAuthority: mintAuthority.address }, { tokenProgram }) .sendTransaction(); return mint.address; }; @@ -55,16 +57,27 @@ const deriveOffCurveAddress = async (): Promise
=> { return pda; }; -const findAta = async (owner: Address, mint: Address): Promise
=> { - const [ata] = await findAssociatedTokenPda({ owner, mint, tokenProgram: TOKEN_PROGRAM_ADDRESS }); +const findAta = async ( + owner: Address, + mint: Address, + tokenProgram: Address = TOKEN_PROGRAM_ADDRESS, +): Promise
=> { + const [ata] = await findAssociatedTokenPda({ owner, mint, tokenProgram }); return ata; }; -const createAta = async (client: TestClient, owner: Address, mint: Address): Promise
=> { +const createAta = async ( + client: TestClient, + owner: Address, + mint: Address, + tokenProgram: Address = TOKEN_PROGRAM_ADDRESS, +): Promise
=> { const payer = await generateKeyPairSigner(); await client.airdrop(payer.address, lamports(1_000_000_000n)); - await client.associatedToken.instructions.createAssociatedToken({ payer, owner, mint }).sendTransaction(); - return await findAta(owner, mint); + await client.associatedToken.instructions + .createAssociatedToken({ payer, owner, mint, tokenProgram }) + .sendTransaction(); + return await findAta(owner, mint, tokenProgram); }; const createUninitializedTokenProgramAccount = async (client: TestClient, space: number): Promise
=> { @@ -435,6 +448,43 @@ it('accepts signer, HasAddress and ProgramDerivedAddress inputs', async () => { ).resolves.toBeUndefined(); }); +it('accepts a Token-2022 associated token account when tokenProgram is set', async () => { + // Given a funded recipient with a Token-2022 associated token account, which carries an extension. + const client = await createTestClient(); + const [mint, recipient] = await Promise.all([ + createTestMint(client, TOKEN_2022_PROGRAM_ADDRESS), + createFundedAddress(client), + ]); + const destination = await createAta(client, recipient, mint, TOKEN_2022_PROGRAM_ADDRESS); + const input = { mint, destination, recipient }; + + // Then it is valid for the Token-2022 program and not the associated token account of the Token program. + await expect( + assertValidTokenTransferDestination(client.rpc, input, { + tokenProgram: new AddressWrapper(TOKEN_2022_PROGRAM_ADDRESS), + }), + ).resolves.toBeUndefined(); + await expect(assertValidTokenTransferDestination(client.rpc, input)).rejects.toMatchObject({ + details: { reason: 'ata-mismatch' }, + }); +}); + +it('derives the associated token account from the associatedTokenProgram', async () => { + // Given a funded recipient with an associated token account for the mint. + const client = await createTestClient(); + const [mint, recipient] = await Promise.all([createTestMint(client), createFundedAddress(client)]); + const destination = await createAta(client, recipient, mint); + + // Then it is not the associated token account derived from another associated token program. + await expect( + assertValidTokenTransferDestination( + client.rpc, + { mint, destination, recipient }, + { associatedTokenProgram: new AddressWrapper(OTHER_PROGRAM_ADDRESS) }, + ), + ).rejects.toMatchObject({ details: { reason: 'ata-mismatch' } }); +}); + it('does not forward guard options to getAccountInfo', async () => { // Given a funded recipient and an RPC that records getAccountInfo calls. const client = await createTestClient(); @@ -448,10 +498,14 @@ it('does not forward guard options to getAccountInfo', async () => { rpc, { mint, destination, recipient }, { + allowMissingAssociatedTokenAccount: true, + allowNonAssociatedTokenAccount: true, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ADDRESS, allowOffCurve: true, allowUnfundedRecipient: true, commitment: 'confirmed', programOwner: SYSTEM_PROGRAM_ADDRESS, + tokenProgram: TOKEN_PROGRAM_ADDRESS, }, ); From d0c456816347e95b70f06deb44b45a2c931a7359 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:36:18 -0700 Subject: [PATCH 5/5] feat(js): reject frozen token transfer destinations Refs: DEV-1241 --- .../assertValidTokenTransferDestination.ts | 12 ++++++- ...ssertValidTokenTransferDestination.test.ts | 31 +++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/clients/js/src/assertValidTokenTransferDestination.ts b/clients/js/src/assertValidTokenTransferDestination.ts index 717c6dfb..58da7517 100644 --- a/clients/js/src/assertValidTokenTransferDestination.ts +++ b/clients/js/src/assertValidTokenTransferDestination.ts @@ -47,7 +47,8 @@ export type InvalidTokenTransferDestinationDetails = reason: 'token-owner-mismatch'; /** The current owner of the destination token account. */ tokenOwner: Address; - }; + } + | { reason: 'frozen' }; /** Distinguishes why a destination was rejected by {@link assertValidTokenTransferDestination}. */ export type InvalidTokenTransferDestinationReason = InvalidTokenTransferDestinationDetails['reason']; @@ -153,6 +154,7 @@ function isTokenAccountData(data: ReadonlyUint8Array, tokenProgram: Address): bo * - `'mint-mismatch'`: a non-associated `destination` holds a different mint. * - `'token-owner-mismatch'`: the destination exists but is not owned by `recipient`, e.g. an associated * token account whose owner was reassigned. + * - `'frozen'`: the destination is frozen by the mint's freeze authority, so the transfer would fail. * * Checks run in the order listed, so a destination that fails several reports the first. */ @@ -258,4 +260,12 @@ export async function assertValidTokenTransferDestination( { reason: 'token-owner-mismatch', tokenOwner: token.owner }, ); } + if (token.state === AccountState.Frozen) { + throw new InvalidTokenTransferDestinationError( + `Refusing to transfer tokens to ${destination}: the token account is frozen, so the transfer would fail.`, + destination, + recipient, + { reason: 'frozen' }, + ); + } } diff --git a/clients/js/test/assertValidTokenTransferDestination.test.ts b/clients/js/test/assertValidTokenTransferDestination.test.ts index 798bc392..cea8f48e 100644 --- a/clients/js/test/assertValidTokenTransferDestination.test.ts +++ b/clients/js/test/assertValidTokenTransferDestination.test.ts @@ -28,10 +28,16 @@ class AddressWrapper implements HasAddress { constructor(readonly address: Address) {} } -const createTestMint = async (client: TestClient, tokenProgram?: Address): Promise
=> { +const createTestMint = async ( + client: TestClient, + { freezeAuthority, tokenProgram }: { freezeAuthority?: Address; tokenProgram?: Address } = {}, +): Promise
=> { const [mint, mintAuthority] = await Promise.all([generateKeyPairSigner(), generateKeyPairSigner()]); await client.token.instructions - .createMint({ newMint: mint, decimals: 2, mintAuthority: mintAuthority.address }, { tokenProgram }) + .createMint( + { newMint: mint, decimals: 2, mintAuthority: mintAuthority.address, freezeAuthority }, + { tokenProgram }, + ) .sendTransaction(); return mint.address; }; @@ -430,6 +436,25 @@ it('rejects an associated token account whose owner was reassigned', async () => }); }); +it('rejects a frozen associated token account', async () => { + // Given a funded recipient whose associated token account was frozen by the mint's freeze authority. + const client = await createTestClient(); + const freezeAuthority = await generateKeyPairSigner(); + const [mint, recipient] = await Promise.all([ + createTestMint(client, { freezeAuthority: freezeAuthority.address }), + createFundedAddress(client), + ]); + const destination = await createAta(client, recipient, mint); + await client.token.instructions + .freezeAccount({ account: destination, mint, owner: freezeAuthority }) + .sendTransaction(); + + // Then it is rejected as frozen. + await expect( + assertValidTokenTransferDestination(client.rpc, { mint, destination, recipient }), + ).rejects.toMatchObject({ destination, recipient, details: { reason: 'frozen' } }); +}); + it('accepts signer, HasAddress and ProgramDerivedAddress inputs', async () => { // Given a mint held in an `AddressWrapper` and a funded recipient held as a signer. const client = await createTestClient(); @@ -452,7 +477,7 @@ it('accepts a Token-2022 associated token account when tokenProgram is set', asy // Given a funded recipient with a Token-2022 associated token account, which carries an extension. const client = await createTestClient(); const [mint, recipient] = await Promise.all([ - createTestMint(client, TOKEN_2022_PROGRAM_ADDRESS), + createTestMint(client, { tokenProgram: TOKEN_2022_PROGRAM_ADDRESS }), createFundedAddress(client), ]); const destination = await createAta(client, recipient, mint, TOKEN_2022_PROGRAM_ADDRESS);