From 0eae5228362310e85448aa3d73aa8234e386a445 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 19 Nov 2025 14:06:57 +0000 Subject: [PATCH] Add an instruction plan to transfer to an ATA --- clients/js/src/createMint.ts | 2 +- clients/js/src/index.ts | 1 + clients/js/src/mintToATA.ts | 8 +- clients/js/src/transferToATA.ts | 100 ++++++++++++++ clients/js/test/_setup.ts | 82 ++++++++--- clients/js/test/createMint.test.ts | 13 +- clients/js/test/mintToATA.test.ts | 24 ++-- clients/js/test/transferToATA.test.ts | 187 ++++++++++++++++++++++++++ 8 files changed, 370 insertions(+), 47 deletions(-) create mode 100644 clients/js/src/transferToATA.ts create mode 100644 clients/js/test/transferToATA.test.ts diff --git a/clients/js/src/createMint.ts b/clients/js/src/createMint.ts index 23d4f377..701e5856 100644 --- a/clients/js/src/createMint.ts +++ b/clients/js/src/createMint.ts @@ -39,7 +39,7 @@ type CreateMintInstructionPlanConfig = { tokenProgram?: Address; }; -export function createMintInstructionPlan( +export function getCreateMintInstructionPlan( input: CreateMintInstructionPlanInput, config?: CreateMintInstructionPlanConfig ): InstructionPlan { diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts index 0b19bf2d..a43829e2 100644 --- a/clients/js/src/index.ts +++ b/clients/js/src/index.ts @@ -1,3 +1,4 @@ export * from './generated'; export * from './createMint'; export * from './mintToATA'; +export * from './transferToATA'; diff --git a/clients/js/src/mintToATA.ts b/clients/js/src/mintToATA.ts index 8e999d3f..f534af96 100644 --- a/clients/js/src/mintToATA.ts +++ b/clients/js/src/mintToATA.ts @@ -16,7 +16,7 @@ type MintToATAInstructionPlanInput = { payer: TransactionSigner; /** Associated token account address to mint to. * Will be created if it does not already exist. - * Note: Use {@link mintToATAInstructionPlanAsync} instead to derive this automatically. + * Note: Use {@link getMintToATAInstructionPlanAsync} instead to derive this automatically. * Note: Use {@link findAssociatedTokenPda} to derive the associated token account address. */ ata: Address; @@ -39,7 +39,7 @@ type MintToATAInstructionPlanConfig = { associatedTokenProgram?: Address; }; -export function mintToATAInstructionPlan( +export function getMintToATAInstructionPlan( input: MintToATAInstructionPlanInput, config?: MintToATAInstructionPlanConfig ): InstructionPlan { @@ -79,7 +79,7 @@ type MintToATAInstructionPlanAsyncInput = Omit< 'ata' >; -export async function mintToATAInstructionPlanAsync( +export async function getMintToATAInstructionPlanAsync( input: MintToATAInstructionPlanAsyncInput, config?: MintToATAInstructionPlanConfig ): Promise { @@ -88,7 +88,7 @@ export async function mintToATAInstructionPlanAsync( tokenProgram: config?.tokenProgram ?? TOKEN_PROGRAM_ADDRESS, mint: input.mint, }); - return mintToATAInstructionPlan( + return getMintToATAInstructionPlan( { ...input, ata: ataAddress, diff --git a/clients/js/src/transferToATA.ts b/clients/js/src/transferToATA.ts new file mode 100644 index 00000000..ef2bf48d --- /dev/null +++ b/clients/js/src/transferToATA.ts @@ -0,0 +1,100 @@ +import { + InstructionPlan, + sequentialInstructionPlan, + Address, + TransactionSigner, +} from '@solana/kit'; +import { + findAssociatedTokenPda, + getCreateAssociatedTokenIdempotentInstruction, + getTransferCheckedInstruction, + TOKEN_PROGRAM_ADDRESS, +} from './generated'; + +type TransferToATAInstructionPlanInput = { + /** Funding account (must be a system account). */ + payer: TransactionSigner; + /** The token mint to transfer. */ + mint: Address; + /** The source account for the transfer. */ + source: Address; + /** The source account's owner/delegate or its multisignature account. */ + authority: Address | TransactionSigner; + /** Associated token account address to transfer to. + * Will be created if it does not already exist. + * Note: Use {@link getTransferToATAInstructionPlanAsync} instead to derive this automatically. + * Note: Use {@link findAssociatedTokenPda} to derive the associated token account address. + */ + destination: Address; + /** Wallet address for the destination. */ + recipient: Address; + /** The amount of tokens to transfer. */ + amount: number | bigint; + /** Expected number of base 10 digits to the right of the decimal place. */ + decimals: number; + multiSigners?: Array; +}; + +type TransferToATAInstructionPlanConfig = { + systemProgram?: Address; + tokenProgram?: Address; + associatedTokenProgram?: Address; +}; + +export function getTransferToATAInstructionPlan( + input: TransferToATAInstructionPlanInput, + config?: TransferToATAInstructionPlanConfig +): InstructionPlan { + return sequentialInstructionPlan([ + getCreateAssociatedTokenIdempotentInstruction( + { + payer: input.payer, + ata: input.destination, + owner: input.recipient, + mint: input.mint, + systemProgram: config?.systemProgram, + tokenProgram: config?.tokenProgram, + }, + { + programAddress: config?.associatedTokenProgram, + } + ), + getTransferCheckedInstruction( + { + source: input.source, + mint: input.mint, + destination: input.destination, + authority: input.authority, + amount: input.amount, + decimals: input.decimals, + multiSigners: input.multiSigners, + }, + { + programAddress: config?.tokenProgram, + } + ), + ]); +} + +type TransferToATAInstructionPlanAsyncInput = Omit< + TransferToATAInstructionPlanInput, + 'destination' +>; + +export async function getTransferToATAInstructionPlanAsync( + input: TransferToATAInstructionPlanAsyncInput, + config?: TransferToATAInstructionPlanConfig +): Promise { + const [ataAddress] = await findAssociatedTokenPda({ + owner: input.recipient, + tokenProgram: config?.tokenProgram ?? TOKEN_PROGRAM_ADDRESS, + mint: input.mint, + }); + return getTransferToATAInstructionPlan( + { + ...input, + destination: ataAddress, + }, + config + ); +} diff --git a/clients/js/test/_setup.ts b/clients/js/test/_setup.ts index 060f1a1c..a851e8eb 100644 --- a/clients/js/test/_setup.ts +++ b/clients/js/test/_setup.ts @@ -9,7 +9,8 @@ import { SolanaRpcSubscriptionsApi, TransactionMessageWithBlockhashLifetime, TransactionMessageWithFeePayer, - TransactionPlanExecutor, + TransactionPlan, + TransactionPlanResult, TransactionPlanner, TransactionSigner, airdropFactory, @@ -32,9 +33,11 @@ import { } from '@solana/kit'; import { TOKEN_PROGRAM_ADDRESS, + findAssociatedTokenPda, getInitializeAccountInstruction, getInitializeMintInstruction, getMintSize, + getMintToATAInstructionPlan, getMintToInstruction, getTokenSize, } from '../src'; @@ -42,12 +45,35 @@ import { type Client = { rpc: Rpc; rpcSubscriptions: RpcSubscriptions; + sendTransactionPlan: ( + transactionPlan: TransactionPlan + ) => Promise; }; export const createDefaultSolanaClient = (): Client => { const rpc = createSolanaRpc('http://127.0.0.1:8899'); const rpcSubscriptions = createSolanaRpcSubscriptions('ws://127.0.0.1:8900'); - return { rpc, rpcSubscriptions }; + + const sendAndConfirm = sendAndConfirmTransactionFactory({ + rpc, + rpcSubscriptions, + }); + const transactionPlanExecutor = createTransactionPlanExecutor({ + executeTransactionMessage: async (transactionMessage) => { + const signedTransaction = + await signTransactionMessageWithSigners(transactionMessage); + assertIsSendableTransaction(signedTransaction); + assertIsTransactionWithBlockhashLifetime(signedTransaction); + await sendAndConfirm(signedTransaction, { commitment: 'confirmed' }); + return { transaction: signedTransaction }; + }, + }); + + const sendTransactionPlan = async (transactionPlan: TransactionPlan) => { + return transactionPlanExecutor(transactionPlan); + }; + + return { rpc, rpcSubscriptions, sendTransactionPlan }; }; export const generateKeyPairSignerWithSol = async ( @@ -114,24 +140,6 @@ export const createDefaultTransactionPlanner = ( }); }; -export const createDefaultTransactionPlanExecutor = ( - client: Client, - commitment: Commitment = 'confirmed' -): TransactionPlanExecutor => { - return createTransactionPlanExecutor({ - executeTransactionMessage: async (transactionMessage) => { - const signedTransaction = - await signTransactionMessageWithSigners(transactionMessage); - assertIsSendableTransaction(signedTransaction); - assertIsTransactionWithBlockhashLifetime(signedTransaction); - await sendAndConfirmTransactionFactory(client)(signedTransaction, { - commitment, - }); - return { transaction: signedTransaction }; - }, - }); -}; - export const getBalance = async (client: Client, address: Address) => (await client.rpc.getBalance(address, { commitment: 'confirmed' }).send()) .value; @@ -235,3 +243,37 @@ export const createTokenWithAmount = async ( return token.address; }; + +export const createTokenPdaWithAmount = async ( + client: Client, + payer: TransactionSigner, + mintAuthority: TransactionSigner, + mint: Address, + owner: Address, + amount: bigint, + decimals: number +): Promise
=> { + const [token] = await findAssociatedTokenPda({ + owner, + mint, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + const transactionPlan = await createDefaultTransactionPlanner( + client, + payer + )( + getMintToATAInstructionPlan({ + payer, + ata: token, + owner, + mint, + mintAuthority, + amount, + decimals, + }) + ); + + await client.sendTransactionPlan(transactionPlan); + return token; +}; diff --git a/clients/js/test/createMint.test.ts b/clients/js/test/createMint.test.ts index 945349d5..b24d9006 100644 --- a/clients/js/test/createMint.test.ts +++ b/clients/js/test/createMint.test.ts @@ -1,11 +1,10 @@ import { generateKeyPairSigner, Account, some, none } from '@solana/kit'; import test from 'ava'; -import { fetchMint, Mint, createMintInstructionPlan } from '../src'; +import { fetchMint, Mint, getCreateMintInstructionPlan } from '../src'; import { createDefaultSolanaClient, generateKeyPairSignerWithSol, createDefaultTransactionPlanner, - createDefaultTransactionPlanExecutor, } from './_setup'; test('it creates and initializes a new mint account', async (t) => { @@ -15,7 +14,7 @@ test('it creates and initializes a new mint account', async (t) => { const mint = await generateKeyPairSigner(); // When we create and initialize a mint account at this address. - const instructionPlan = createMintInstructionPlan({ + const instructionPlan = getCreateMintInstructionPlan({ payer: authority, newMint: mint, decimals: 2, @@ -24,8 +23,7 @@ test('it creates and initializes a new mint account', async (t) => { const transactionPlanner = createDefaultTransactionPlanner(client, authority); const transactionPlan = await transactionPlanner(instructionPlan); - const transactionPlanExecutor = createDefaultTransactionPlanExecutor(client); - await transactionPlanExecutor(transactionPlan); + await client.sendTransactionPlan(transactionPlan); // Then we expect the mint account to exist and have the following data. const mintAccount = await fetchMint(client.rpc, mint.address); @@ -52,7 +50,7 @@ test('it creates a new mint account with a freeze authority', async (t) => { ]); // When we create and initialize a mint account at this address. - const instructionPlan = createMintInstructionPlan({ + const instructionPlan = getCreateMintInstructionPlan({ payer: payer, newMint: mint, decimals: 2, @@ -62,8 +60,7 @@ test('it creates a new mint account with a freeze authority', async (t) => { const transactionPlanner = createDefaultTransactionPlanner(client, payer); const transactionPlan = await transactionPlanner(instructionPlan); - const transactionPlanExecutor = createDefaultTransactionPlanExecutor(client); - await transactionPlanExecutor(transactionPlan); + await client.sendTransactionPlan(transactionPlan); // Then we expect the mint account to exist and have the following data. const mintAccount = await fetchMint(client.rpc, mint.address); diff --git a/clients/js/test/mintToATA.test.ts b/clients/js/test/mintToATA.test.ts index 5e9e3b07..995b5b57 100644 --- a/clients/js/test/mintToATA.test.ts +++ b/clients/js/test/mintToATA.test.ts @@ -4,14 +4,13 @@ import { AccountState, TOKEN_PROGRAM_ADDRESS, Token, - mintToATAInstructionPlan, - mintToATAInstructionPlanAsync, + getMintToATAInstructionPlan, + getMintToATAInstructionPlanAsync, fetchToken, findAssociatedTokenPda, } from '../src'; import { createDefaultSolanaClient, - createDefaultTransactionPlanExecutor, createDefaultTransactionPlanner, createMint, generateKeyPairSignerWithSol, @@ -34,7 +33,7 @@ test('it creates a new associated token account with an initial balance', async }); // When we mint to a token account at this address. - const instructionPlan = mintToATAInstructionPlan({ + const instructionPlan = getMintToATAInstructionPlan({ payer, ata, mint, @@ -46,8 +45,7 @@ test('it creates a new associated token account with an initial balance', async const transactionPlanner = createDefaultTransactionPlanner(client, payer); const transactionPlan = await transactionPlanner(instructionPlan); - const transactionPlanExecutor = createDefaultTransactionPlanExecutor(client); - await transactionPlanExecutor(transactionPlan); + await client.sendTransactionPlan(transactionPlan); // Then we expect the token account to exist and have the following data. t.like(await fetchToken(client.rpc, ata), >{ @@ -77,7 +75,7 @@ test('it derives a new associated token account with an initial balance', async const mint = await createMint(client, payer, mintAuthority.address, decimals); // When we mint to a token account for the mint. - const instructionPlan = await mintToATAInstructionPlanAsync({ + const instructionPlan = await getMintToATAInstructionPlanAsync({ payer, mint, owner: owner.address, @@ -88,8 +86,7 @@ test('it derives a new associated token account with an initial balance', async const transactionPlanner = createDefaultTransactionPlanner(client, payer); const transactionPlan = await transactionPlanner(instructionPlan); - const transactionPlanExecutor = createDefaultTransactionPlanExecutor(client); - await transactionPlanExecutor(transactionPlan); + await client.sendTransactionPlan(transactionPlan); // Then we expect the token account to exist and have the following data. const [ata] = await findAssociatedTokenPda({ @@ -130,7 +127,7 @@ test('it also mints to an existing associated token account', async (t) => { }); // When we create and initialize a token account at this address. - const instructionPlan = mintToATAInstructionPlan({ + const instructionPlan = getMintToATAInstructionPlan({ payer, ata, mint, @@ -142,11 +139,10 @@ test('it also mints to an existing associated token account', async (t) => { const transactionPlanner = createDefaultTransactionPlanner(client, payer); const transactionPlan = await transactionPlanner(instructionPlan); - const transactionPlanExecutor = createDefaultTransactionPlanExecutor(client); - await transactionPlanExecutor(transactionPlan); + await client.sendTransactionPlan(transactionPlan); // And then we mint additional tokens to the same account. - const instructionPlan2 = mintToATAInstructionPlan({ + const instructionPlan2 = getMintToATAInstructionPlan({ payer, ata, mint, @@ -157,7 +153,7 @@ test('it also mints to an existing associated token account', async (t) => { }); const transactionPlan2 = await transactionPlanner(instructionPlan2); - await transactionPlanExecutor(transactionPlan2); + await client.sendTransactionPlan(transactionPlan2); // Then we expect the token account to exist and have the following data. t.like(await fetchToken(client.rpc, ata), >{ diff --git a/clients/js/test/transferToATA.test.ts b/clients/js/test/transferToATA.test.ts new file mode 100644 index 00000000..2de1686a --- /dev/null +++ b/clients/js/test/transferToATA.test.ts @@ -0,0 +1,187 @@ +import { generateKeyPairSigner } from '@solana/kit'; +import test from 'ava'; +import { + Mint, + TOKEN_PROGRAM_ADDRESS, + Token, + fetchMint, + fetchToken, + findAssociatedTokenPda, + getTransferToATAInstructionPlan, + getTransferToATAInstructionPlanAsync, +} from '../src'; +import { + createDefaultSolanaClient, + createDefaultTransactionPlanner, + createMint, + createTokenPdaWithAmount, + createTokenWithAmount, + generateKeyPairSignerWithSol, +} from './_setup'; + +test('it transfers tokens from one account to a new ATA', async (t) => { + // Given a mint account, one token account with 100 tokens, and a second owner. + const client = createDefaultSolanaClient(); + const [payer, mintAuthority, ownerA, ownerB] = await Promise.all([ + generateKeyPairSignerWithSol(client), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + const decimals = 2; + const mint = await createMint(client, payer, mintAuthority.address, decimals); + const tokenA = await createTokenWithAmount( + client, + payer, + mintAuthority, + mint, + ownerA.address, + 100n + ); + + const [tokenB] = await findAssociatedTokenPda({ + owner: ownerB.address, + mint, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + // When owner A transfers 50 tokens to owner B. + const instructionPlan = getTransferToATAInstructionPlan({ + payer, + mint, + source: tokenA, + authority: ownerA, + destination: tokenB, + recipient: ownerB.address, + amount: 50n, + decimals, + }); + + const transactionPlanner = createDefaultTransactionPlanner(client, payer); + const transactionPlan = await transactionPlanner(instructionPlan); + await client.sendTransactionPlan(transactionPlan); + + // Then we expect the mint and token accounts to have the following updated data. + const [{ data: mintData }, { data: tokenDataA }, { data: tokenDataB }] = + await Promise.all([ + fetchMint(client.rpc, mint), + fetchToken(client.rpc, tokenA), + fetchToken(client.rpc, tokenB), + ]); + t.like(mintData, { supply: 100n }); + t.like(tokenDataA, { amount: 50n }); + t.like(tokenDataB, { amount: 50n }); +}); + +test('derives a new ATA and transfers tokens to it', async (t) => { + // Given a mint account, one token account with 100 tokens, and a second owner. + const client = createDefaultSolanaClient(); + const [payer, mintAuthority, ownerA, ownerB] = await Promise.all([ + generateKeyPairSignerWithSol(client), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + const decimals = 2; + const mint = await createMint(client, payer, mintAuthority.address, decimals); + const tokenA = await createTokenWithAmount( + client, + payer, + mintAuthority, + mint, + ownerA.address, + 100n + ); + + // When owner A transfers 50 tokens to owner B. + const instructionPlan = await getTransferToATAInstructionPlanAsync({ + payer, + mint, + source: tokenA, + authority: ownerA, + recipient: ownerB.address, + amount: 50n, + decimals, + }); + + const transactionPlanner = createDefaultTransactionPlanner(client, payer); + const transactionPlan = await transactionPlanner(instructionPlan); + await client.sendTransactionPlan(transactionPlan); + + // Then we expect the mint and token accounts to have the following updated data. + const [tokenB] = await findAssociatedTokenPda({ + owner: ownerB.address, + mint, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + const [{ data: mintData }, { data: tokenDataA }, { data: tokenDataB }] = + await Promise.all([ + fetchMint(client.rpc, mint), + fetchToken(client.rpc, tokenA), + fetchToken(client.rpc, tokenB), + ]); + t.like(mintData, { supply: 100n }); + t.like(tokenDataA, { amount: 50n }); + t.like(tokenDataB, { amount: 50n }); +}); + +test('it transfers tokens from one account to an existing ATA', async (t) => { + // Given a mint account and two token accounts. + // One with 90 tokens and the other with 10 tokens. + const client = createDefaultSolanaClient(); + const [payer, mintAuthority, ownerA, ownerB] = await Promise.all([ + generateKeyPairSignerWithSol(client), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + const decimals = 2; + const mint = await createMint(client, payer, mintAuthority.address, decimals); + const [tokenA, tokenB] = await Promise.all([ + createTokenWithAmount( + client, + payer, + mintAuthority, + mint, + ownerA.address, + 90n + ), + createTokenPdaWithAmount( + client, + payer, + mintAuthority, + mint, + ownerB.address, + 10n, + decimals + ), + ]); + + // When owner A transfers 50 tokens to owner B. + const instructionPlan = getTransferToATAInstructionPlan({ + payer, + mint, + source: tokenA, + authority: ownerA, + destination: tokenB, + recipient: ownerB.address, + amount: 50n, + decimals, + }); + + const transactionPlanner = createDefaultTransactionPlanner(client, payer); + const transactionPlan = await transactionPlanner(instructionPlan); + await client.sendTransactionPlan(transactionPlan); + + // Then we expect the mint and token accounts to have the following updated data. + const [{ data: mintData }, { data: tokenDataA }, { data: tokenDataB }] = + await Promise.all([ + fetchMint(client.rpc, mint), + fetchToken(client.rpc, tokenA), + fetchToken(client.rpc, tokenB), + ]); + t.like(mintData, { supply: 100n }); + t.like(tokenDataA, { amount: 40n }); + t.like(tokenDataB, { amount: 60n }); +});