diff --git a/clients/js/src/assertValidTokenTransferDestination.ts b/clients/js/src/assertValidTokenTransferDestination.ts new file mode 100644 index 00000000..58da7517 --- /dev/null +++ b/clients/js/src/assertValidTokenTransferDestination.ts @@ -0,0 +1,271 @@ +import { SYSTEM_PROGRAM_ADDRESS } from '@solana-program/system'; +import { + fetchEncodedAccount, + isOffCurveAddress, + type Address, + type FetchAccountConfig, + type GetAccountInfoApi, + type HasAddress, + type ReadonlyUint8Array, + type Rpc, +} from '@solana/kit'; +import { + getAddressFromResolvedInstructionAccount, + type InstructionAccountInput, +} from '@solana/kit/program-client-core'; + +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 = + | { + 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; + } + | { reason: 'frozen' }; + +/** 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'; + + constructor( + message: string, + readonly destination: Address, + readonly recipient: Address, + readonly details: InvalidTokenTransferDestinationDetails, + ) { + super(message); + } +} + +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. + */ + 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; + /** + * 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 + * 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 (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 + * 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. + * - `'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. + */ +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, + 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 }, { programAddress: associatedTokenProgram }), + ]); + + 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, + { reason: '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, + { reason: 'unfunded-recipient' }, + ); + } + + 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; to allow it, pass { allowOffCurve: true }.`, + destination, + recipient, + { reason: '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 is: ${ata}). To send to another token account owned by the recipient, pass { allowNonAssociatedTokenAccount: true }.`, + destination, + recipient, + { reason: '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, + { reason: 'missing-token-account' }, + ); + } + + const token = + destinationAccount.programAddress === tokenProgram && isTokenAccountData(destinationAccount.data, tokenProgram) + ? 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, + { reason: '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, + { reason: '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, + { 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/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..cea8f48e --- /dev/null +++ b/clients/js/test/assertValidTokenTransferDestination.test.ts @@ -0,0 +1,540 @@ +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, + ASSOCIATED_TOKEN_PROGRAM_ADDRESS, + AuthorityType, + findAssociatedTokenPda, + getMultisigSize, + getTokenSize, + InvalidTokenTransferDestinationError, + TOKEN_PROGRAM_ADDRESS, +} from '../src'; +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, + { 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, freezeAuthority }, + { tokenProgram }, + ) + .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, + tokenProgram: Address = TOKEN_PROGRAM_ADDRESS, +): Promise
=> { + const [ata] = await findAssociatedTokenPda({ owner, mint, tokenProgram }); + return ata; +}; + +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, tokenProgram }) + .sendTransaction(); + return await findAta(owner, mint, tokenProgram); +}; + +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, + details: { 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('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 createAta(client, recipient, mint); + + // 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 }, + ), + ).resolves.toBeUndefined(); +}); + +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, + details: { 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({ details: { 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, details: { 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, details: { 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({ details: { 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(`(ATA is: ${expectedDestination})`), + destination, + recipient, + details: { 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({ details: { 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, details: { 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, details: { 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, details: { 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, details: { 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, + details: { reason: 'token-owner-mismatch', tokenOwner: newOwner.address }, + }); +}); + +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(); + 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('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, { tokenProgram: 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(); + 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 }, + { + allowMissingAssociatedTokenAccount: true, + allowNonAssociatedTokenAccount: true, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ADDRESS, + allowOffCurve: true, + allowUnfundedRecipient: true, + commitment: 'confirmed', + programOwner: SYSTEM_PROGRAM_ADDRESS, + tokenProgram: TOKEN_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' }); +});