From ac2136083d83d79bca81891438e443301eea1ed6 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] 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' }); +});