From cb08667bda037634f4ff7d712ce38786fc94676c Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:59:26 -0800 Subject: [PATCH 1/5] feat(js): add optional ata/source/destination derivation and plugin defaults - export async input types for mintToATA and transferToATA - make ata, source, destination, and tokenProgram optional on async variants (derived via PDA when omitted) - default mintAuthority and authority to client.payer in plugin - default mintAuthority to client.payer.address in createMint plugin method - add offline plan-inspection tests for address derivation paths --- clients/js/src/mintToATA.ts | 25 ++++-- clients/js/src/plugin.ts | 35 +++++--- clients/js/src/transferToATA.ts | 46 ++++++++-- clients/js/test/mintToATA.test.ts | 83 ++++++++++++++++- clients/js/test/plugin.test.ts | 124 ++++++++++++++++++++++++++ clients/js/test/transferToATA.test.ts | 118 +++++++++++++++++++++++- 6 files changed, 403 insertions(+), 28 deletions(-) create mode 100644 clients/js/test/plugin.test.ts diff --git a/clients/js/src/mintToATA.ts b/clients/js/src/mintToATA.ts index 632b0012..2ce7eb26 100644 --- a/clients/js/src/mintToATA.ts +++ b/clients/js/src/mintToATA.ts @@ -69,22 +69,31 @@ export function getMintToATAInstructionPlan( ]); } -type MintToATAInstructionPlanAsyncInput = Omit; +export type MintToATAInstructionPlanAsyncInput = Omit & { + /** Associated token account address. When omitted, derived from owner + mint. */ + ata?: Address; + /** Token program address. Defaults to TOKEN_PROGRAM_ADDRESS. */ + tokenProgram?: Address; +}; export async function getMintToATAInstructionPlanAsync( input: MintToATAInstructionPlanAsyncInput, config?: MintToATAInstructionPlanConfig, ): Promise { - const [ataAddress] = await findAssociatedTokenPda({ - owner: input.owner, - tokenProgram: config?.tokenProgram ?? TOKEN_PROGRAM_ADDRESS, - mint: input.mint, - }); + const tokenProgram = config?.tokenProgram ?? input.tokenProgram ?? TOKEN_PROGRAM_ADDRESS; + let ata = input.ata; + if (!ata) { + [ata] = await findAssociatedTokenPda({ + owner: input.owner, + tokenProgram, + mint: input.mint, + }); + } return getMintToATAInstructionPlan( { ...input, - ata: ataAddress, + ata, }, - config, + { ...config, tokenProgram }, ); } diff --git a/clients/js/src/plugin.ts b/clients/js/src/plugin.ts index d01fd51e..95a984d3 100644 --- a/clients/js/src/plugin.ts +++ b/clients/js/src/plugin.ts @@ -8,23 +8,26 @@ import { TokenPluginRequirements as GeneratedTokenPluginRequirements, tokenProgram as generatedTokenProgram, } from './generated'; -import { getMintToATAInstructionPlan, MintToATAInstructionPlanInput } from './mintToATA'; -import { getTransferToATAInstructionPlan, TransferToATAInstructionPlanInput } from './transferToATA'; +import { getMintToATAInstructionPlanAsync, MintToATAInstructionPlanAsyncInput } from './mintToATA'; +import { getTransferToATAInstructionPlanAsync, TransferToATAInstructionPlanAsyncInput } from './transferToATA'; export type TokenPluginRequirements = GeneratedTokenPluginRequirements & ClientWithPayer; export type TokenPlugin = Omit & { instructions: TokenPluginInstructions }; export type TokenPluginInstructions = GeneratedTokenPluginInstructions & { + /** Create a new token mint. */ createMint: ( - input: MakeOptional, + input: MakeOptional, ) => ReturnType & SelfPlanAndSendFunctions; + /** Mint tokens to an owner's ATA (created if needed). */ mintToATA: ( - input: MakeOptional, - ) => ReturnType & SelfPlanAndSendFunctions; + input: MakeOptional, + ) => Promise>> & SelfPlanAndSendFunctions; + /** Transfer tokens to a recipient's ATA (created if needed). */ transferToATA: ( - input: MakeOptional, - ) => ReturnType & SelfPlanAndSendFunctions; + input: MakeOptional, + ) => Promise>> & SelfPlanAndSendFunctions; }; export function tokenProgram() { @@ -38,17 +41,29 @@ export function tokenProgram() { createMint: input => addSelfPlanAndSendFunctions( client, - getCreateMintInstructionPlan({ ...input, payer: input.payer ?? client.payer }), + getCreateMintInstructionPlan({ + ...input, + payer: input.payer ?? client.payer, + mintAuthority: input.mintAuthority ?? client.payer.address, + }), ), mintToATA: input => addSelfPlanAndSendFunctions( client, - getMintToATAInstructionPlan({ ...input, payer: input.payer ?? client.payer }), + getMintToATAInstructionPlanAsync({ + ...input, + payer: input.payer ?? client.payer, + mintAuthority: input.mintAuthority ?? client.payer, + }), ), transferToATA: input => addSelfPlanAndSendFunctions( client, - getTransferToATAInstructionPlan({ ...input, payer: input.payer ?? client.payer }), + getTransferToATAInstructionPlanAsync({ + ...input, + payer: input.payer ?? client.payer, + authority: input.authority ?? client.payer, + }), ), }, }, diff --git a/clients/js/src/transferToATA.ts b/clients/js/src/transferToATA.ts index 243db2bc..1e0a174a 100644 --- a/clients/js/src/transferToATA.ts +++ b/clients/js/src/transferToATA.ts @@ -71,22 +71,52 @@ export function getTransferToATAInstructionPlan( ]); } -type TransferToATAInstructionPlanAsyncInput = Omit; +export type TransferToATAInstructionPlanAsyncInput = Omit< + TransferToATAInstructionPlanInput, + 'destination' | 'source' +> & { + /** Source token account. When omitted, derived from authority's address + mint. */ + source?: Address; + /** Destination ATA. When omitted, derived from recipient + mint. */ + destination?: Address; + /** Token program address. Defaults to TOKEN_PROGRAM_ADDRESS. */ + tokenProgram?: Address; +}; 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, - }); + const tokenProgram = config?.tokenProgram ?? input.tokenProgram ?? TOKEN_PROGRAM_ADDRESS; + + const destinationAta = + input.destination ?? + ( + await findAssociatedTokenPda({ + owner: input.recipient, + tokenProgram, + mint: input.mint, + }) + )[0]; + + let source = input.source; + if (!source) { + const authorityAddress: Address = + typeof input.authority === 'string' ? input.authority : input.authority.address; + const [sourceAta] = await findAssociatedTokenPda({ + owner: authorityAddress, + tokenProgram, + mint: input.mint, + }); + source = sourceAta; + } + return getTransferToATAInstructionPlan( { ...input, - destination: ataAddress, + source, + destination: destinationAta, }, - config, + { ...config, tokenProgram }, ); } diff --git a/clients/js/test/mintToATA.test.ts b/clients/js/test/mintToATA.test.ts index aaab1640..88a04601 100644 --- a/clients/js/test/mintToATA.test.ts +++ b/clients/js/test/mintToATA.test.ts @@ -1,4 +1,11 @@ -import { Account, generateKeyPairSigner, none } from '@solana/kit'; +import { + Account, + Address, + generateKeyPairSigner, + none, + type SingleInstructionPlan, + type SequentialInstructionPlan, +} from '@solana/kit'; import test from 'ava'; import { AccountState, @@ -16,6 +23,14 @@ import { generateKeyPairSignerWithSol, } from './_setup'; +// Extract the account addresses from a sequential instruction plan's instructions. +function getInstructionAccounts(plan: SequentialInstructionPlan): Address[][] { + return plan.plans.map(p => { + const single = p as SingleInstructionPlan; + return (single.instruction.accounts ?? []).map((a: any) => a.address); + }); +} + test('it creates a new associated token account with an initial balance', async t => { // Given a mint account, its mint authority, a token owner and the ATA. const client = createDefaultSolanaClient(); @@ -170,3 +185,69 @@ test('it also mints to an existing associated token account', async t => { }, }); }); + +// --- Offline tests: verify derived addresses in instruction plans --- + +test('async variant auto-derives ATA from owner + mint', async t => { + // Given an owner and mint. + const payer = await generateKeyPairSigner(); + const mintAuthority = await generateKeyPairSigner(); + const owner = (await generateKeyPairSigner()).address; + const mint = (await generateKeyPairSigner()).address; + + const [expectedAta] = await findAssociatedTokenPda({ + owner, + mint, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + // When building a plan without an explicit ATA. + const plan = await getMintToATAInstructionPlanAsync({ + payer, + mint, + owner, + mintAuthority, + amount: 500n, + decimals: 6, + }); + + // Then the plan should contain the derived ATA. + const seqPlan = plan as SequentialInstructionPlan; + t.is(seqPlan.kind, 'sequential'); + t.is(seqPlan.plans.length, 2); + + const accounts = getInstructionAccounts(seqPlan); + + // createAssociatedTokenIdempotent — ata at index 1 + t.is(accounts[0][1], expectedAta); + + // mintToChecked — token at index 1 + t.is(accounts[1][1], expectedAta); +}); + +test('async variant uses explicit ATA when provided', async t => { + // Given an explicit ATA address. + const payer = await generateKeyPairSigner(); + const mintAuthority = await generateKeyPairSigner(); + const owner = (await generateKeyPairSigner()).address; + const mint = (await generateKeyPairSigner()).address; + const explicitAta = (await generateKeyPairSigner()).address; + + // When building a plan with the explicit ATA. + const plan = await getMintToATAInstructionPlanAsync({ + payer, + mint, + owner, + mintAuthority, + ata: explicitAta, + amount: 500n, + decimals: 6, + }); + + // Then the plan should use the explicit ATA, not a derived one. + const seqPlan = plan as SequentialInstructionPlan; + const accounts = getInstructionAccounts(seqPlan); + + t.is(accounts[0][1], explicitAta); + t.is(accounts[1][1], explicitAta); +}); diff --git a/clients/js/test/plugin.test.ts b/clients/js/test/plugin.test.ts new file mode 100644 index 00000000..309cbe7b --- /dev/null +++ b/clients/js/test/plugin.test.ts @@ -0,0 +1,124 @@ +import { + Address, + generateKeyPairSigner, + type SingleInstructionPlan, + type SequentialInstructionPlan, +} from '@solana/kit'; +import test from 'ava'; +import { TOKEN_PROGRAM_ADDRESS, findAssociatedTokenPda, tokenProgram } from '../src'; + +// Extract the account addresses from a sequential instruction plan's instructions. +function getInstructionAccounts(plan: SequentialInstructionPlan): Address[][] { + return plan.plans.map(p => { + const single = p as SingleInstructionPlan; + return (single.instruction.accounts ?? []).map((a: any) => a.address); + }); +} + +/** + * Create a minimal mock client that satisfies TokenPluginRequirements. + * No real RPC — just enough for the plugin to wire up defaults. + */ +function createMockClient(payer: Awaited>) { + return tokenProgram()({ + payer, + rpc: {} as any, + rpcSubscriptions: {} as any, + planTransaction: (() => {}) as any, + planTransactions: (() => {}) as any, + sendTransaction: (() => {}) as any, + sendTransactions: (() => {}) as any, + }); +} + +test('plugin transferToATA defaults authority to payer and auto-derives source + destination', async t => { + const payer = await generateKeyPairSigner(); + const mint = (await generateKeyPairSigner()).address; + const recipient = (await generateKeyPairSigner()).address; + + const [expectedSource] = await findAssociatedTokenPda({ + owner: payer.address, + mint, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + const [expectedDestination] = await findAssociatedTokenPda({ + owner: recipient, + mint, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + const client = createMockClient(payer); + + // Call transferToATA with minimal input — payer, authority, source all defaulted/derived. + const plan = await client.token.instructions.transferToATA({ + mint, + recipient, + amount: 100n, + decimals: 9, + }); + + const seqPlan = plan as unknown as SequentialInstructionPlan; + t.is(seqPlan.kind, 'sequential'); + const accounts = getInstructionAccounts(seqPlan); + + // createAssociatedTokenIdempotent — ata (destination) at index 1 + t.is(accounts[0][1], expectedDestination); + + // transferChecked — source at index 0, destination at index 2 + t.is(accounts[1][0], expectedSource); + t.is(accounts[1][2], expectedDestination); +}); + +test('plugin mintToATA defaults mintAuthority to payer and auto-derives ATA', async t => { + const payer = await generateKeyPairSigner(); + const mint = (await generateKeyPairSigner()).address; + const owner = (await generateKeyPairSigner()).address; + + const [expectedAta] = await findAssociatedTokenPda({ + owner, + mint, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + const client = createMockClient(payer); + + // Call mintToATA with minimal input — payer and mintAuthority defaulted, ATA derived. + const plan = await client.token.instructions.mintToATA({ + mint, + owner, + amount: 1000n, + decimals: 6, + }); + + const seqPlan = plan as unknown as SequentialInstructionPlan; + t.is(seqPlan.kind, 'sequential'); + const accounts = getInstructionAccounts(seqPlan); + + // createAssociatedTokenIdempotent — ata at index 1 + t.is(accounts[0][1], expectedAta); + + // mintToChecked — token at index 1, mintAuthority at index 2 + t.is(accounts[1][1], expectedAta); + t.is(accounts[1][2], payer.address); // mintAuthority defaulted to payer +}); + +test('plugin createMint defaults mintAuthority to payer address', async t => { + const payer = await generateKeyPairSigner(); + const newMint = await generateKeyPairSigner(); + + const client = createMockClient(payer); + + // Call createMint without mintAuthority — should default to payer.address. + const plan = client.token.instructions.createMint({ + newMint, + decimals: 9, + }); + + const seqPlan = plan as unknown as SequentialInstructionPlan; + t.is(seqPlan.kind, 'sequential'); + t.is(seqPlan.plans.length, 2); + const accounts = getInstructionAccounts(seqPlan); + + // createAccount — payer at index 0 + t.is(accounts[0][0], payer.address); +}); diff --git a/clients/js/test/transferToATA.test.ts b/clients/js/test/transferToATA.test.ts index 2482fb1e..69eb05f0 100644 --- a/clients/js/test/transferToATA.test.ts +++ b/clients/js/test/transferToATA.test.ts @@ -1,4 +1,9 @@ -import { generateKeyPairSigner } from '@solana/kit'; +import { + generateKeyPairSigner, + Address, + type SingleInstructionPlan, + type SequentialInstructionPlan, +} from '@solana/kit'; import test from 'ava'; import { Mint, @@ -19,6 +24,14 @@ import { generateKeyPairSignerWithSol, } from './_setup'; +// Extract the account addresses from a sequential instruction plan's instructions. +function getInstructionAccounts(plan: SequentialInstructionPlan): Address[][] { + return plan.plans.map(p => { + const single = p as SingleInstructionPlan; + return (single.instruction.accounts ?? []).map((a: any) => a.address); + }); +} + 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(); @@ -153,3 +166,106 @@ test('it transfers tokens from one account to an existing ATA', async t => { t.like(tokenDataA, { amount: 40n }); t.like(tokenDataB, { amount: 60n }); }); + +test('async variant auto-derives source ATA when omitted', async t => { + // Given a keypair for the authority and a mint. + const authority = await generateKeyPairSigner(); + const mint = (await generateKeyPairSigner()).address; + const recipient = (await generateKeyPairSigner()).address; + + // Compute expected ATAs. + const [expectedSource] = await findAssociatedTokenPda({ + owner: authority.address, + mint, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + const [expectedDestination] = await findAssociatedTokenPda({ + owner: recipient, + mint, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + // When building a plan WITHOUT source (should auto-derive). + const plan = await getTransferToATAInstructionPlanAsync({ + payer: authority, + mint, + authority, + recipient, + amount: 100n, + decimals: 9, + }); + + // Then the instruction plan should contain the correct derived addresses. + const seqPlan = plan as SequentialInstructionPlan; + t.is(seqPlan.kind, 'sequential'); + t.is(seqPlan.plans.length, 2); + + const accounts = getInstructionAccounts(seqPlan); + + // 1st instruction: createAssociatedTokenIdempotent — ata (index 1) should be the destination ATA + t.is(accounts[0][1], expectedDestination); + + // 2nd instruction: transferChecked — source (index 0), destination (index 2) + t.is(accounts[1][0], expectedSource); + t.is(accounts[1][2], expectedDestination); +}); + +test('async variant auto-derives source from TransactionSigner authority', async t => { + // Given a signer authority (has .address property). + const authority = await generateKeyPairSigner(); + const mint = (await generateKeyPairSigner()).address; + const recipient = (await generateKeyPairSigner()).address; + + const [expectedSource] = await findAssociatedTokenPda({ + owner: authority.address, + mint, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + // When building a plan with authority as a TransactionSigner. + const plan = await getTransferToATAInstructionPlanAsync({ + payer: authority, + mint, + authority, + recipient, + amount: 50n, + decimals: 6, + }); + + // Then source should be derived from authority.address. + const seqPlan = plan as SequentialInstructionPlan; + const accounts = getInstructionAccounts(seqPlan); + t.is(accounts[1][0], expectedSource); +}); + +test('async variant uses explicit source and destination when provided', async t => { + // Given explicit source and destination addresses. + const authority = await generateKeyPairSigner(); + const mint = (await generateKeyPairSigner()).address; + const recipient = (await generateKeyPairSigner()).address; + const explicitSource = (await generateKeyPairSigner()).address; + const explicitDestination = (await generateKeyPairSigner()).address; + + // When building with explicit source and destination. + const plan = await getTransferToATAInstructionPlanAsync({ + payer: authority, + mint, + authority, + recipient, + source: explicitSource, + destination: explicitDestination, + amount: 100n, + decimals: 9, + }); + + // Then the plan should use the explicit addresses, not derived ones. + const seqPlan = plan as SequentialInstructionPlan; + const accounts = getInstructionAccounts(seqPlan); + + // createAssociatedTokenIdempotent — ata at index 1 + t.is(accounts[0][1], explicitDestination); + + // transferChecked — source at index 0, destination at index 2 + t.is(accounts[1][0], explicitSource); + t.is(accounts[1][2], explicitDestination); +}); From 328c15ab34f99356c2f07ad8ad051624e5676262 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:32:28 -0800 Subject: [PATCH 2/5] refactor(js): extract MakeOptional type and pass config through plugin - add src/types.ts with shared MakeOptional utility - export *InstructionPlanConfig types from createMint, mintToATA, transferToATA - remove tokenProgram from async input types (use config instead) - plugin instructions now accept optional config as second arg and pass it through --- clients/js/src/createMint.ts | 2 +- clients/js/src/mintToATA.ts | 14 +++---- clients/js/src/plugin.ts | 69 ++++++++++++++++++++++----------- clients/js/src/transferToATA.ts | 20 ++++------ clients/js/src/types.ts | 1 + 5 files changed, 60 insertions(+), 46 deletions(-) create mode 100644 clients/js/src/types.ts diff --git a/clients/js/src/createMint.ts b/clients/js/src/createMint.ts index 14214916..4515b5df 100644 --- a/clients/js/src/createMint.ts +++ b/clients/js/src/createMint.ts @@ -24,7 +24,7 @@ export type CreateMintInstructionPlanInput = { mintAccountLamports?: number; }; -type CreateMintInstructionPlanConfig = { +export type CreateMintInstructionPlanConfig = { systemProgram?: Address; tokenProgram?: Address; }; diff --git a/clients/js/src/mintToATA.ts b/clients/js/src/mintToATA.ts index 2ce7eb26..ec56022d 100644 --- a/clients/js/src/mintToATA.ts +++ b/clients/js/src/mintToATA.ts @@ -5,6 +5,7 @@ import { getMintToCheckedInstruction, TOKEN_PROGRAM_ADDRESS, } from './generated'; +import { MakeOptional } from './types'; export type MintToATAInstructionPlanInput = { /** Funding account (must be a system account). */ @@ -28,7 +29,7 @@ export type MintToATAInstructionPlanInput = { multiSigners?: Array; }; -type MintToATAInstructionPlanConfig = { +export type MintToATAInstructionPlanConfig = { systemProgram?: Address; tokenProgram?: Address; associatedTokenProgram?: Address; @@ -69,18 +70,13 @@ export function getMintToATAInstructionPlan( ]); } -export type MintToATAInstructionPlanAsyncInput = Omit & { - /** Associated token account address. When omitted, derived from owner + mint. */ - ata?: Address; - /** Token program address. Defaults to TOKEN_PROGRAM_ADDRESS. */ - tokenProgram?: Address; -}; +export type MintToATAInstructionPlanAsyncInput = MakeOptional; export async function getMintToATAInstructionPlanAsync( input: MintToATAInstructionPlanAsyncInput, config?: MintToATAInstructionPlanConfig, ): Promise { - const tokenProgram = config?.tokenProgram ?? input.tokenProgram ?? TOKEN_PROGRAM_ADDRESS; + const tokenProgram = config?.tokenProgram ?? TOKEN_PROGRAM_ADDRESS; let ata = input.ata; if (!ata) { [ata] = await findAssociatedTokenPda({ @@ -94,6 +90,6 @@ export async function getMintToATAInstructionPlanAsync( ...input, ata, }, - { ...config, tokenProgram }, + config, ); } diff --git a/clients/js/src/plugin.ts b/clients/js/src/plugin.ts index 95a984d3..f1933e5a 100644 --- a/clients/js/src/plugin.ts +++ b/clients/js/src/plugin.ts @@ -1,15 +1,28 @@ import { ClientWithPayer, pipe } from '@solana/kit'; import { addSelfPlanAndSendFunctions, SelfPlanAndSendFunctions } from '@solana/kit/program-client-core'; -import { CreateMintInstructionPlanInput, getCreateMintInstructionPlan } from './createMint'; +import { + CreateMintInstructionPlanConfig, + CreateMintInstructionPlanInput, + getCreateMintInstructionPlan, +} from './createMint'; import { TokenPlugin as GeneratedTokenPlugin, TokenPluginInstructions as GeneratedTokenPluginInstructions, TokenPluginRequirements as GeneratedTokenPluginRequirements, tokenProgram as generatedTokenProgram, } from './generated'; -import { getMintToATAInstructionPlanAsync, MintToATAInstructionPlanAsyncInput } from './mintToATA'; -import { getTransferToATAInstructionPlanAsync, TransferToATAInstructionPlanAsyncInput } from './transferToATA'; +import { + getMintToATAInstructionPlanAsync, + MintToATAInstructionPlanAsyncInput, + MintToATAInstructionPlanConfig, +} from './mintToATA'; +import { + getTransferToATAInstructionPlanAsync, + TransferToATAInstructionPlanAsyncInput, + TransferToATAInstructionPlanConfig, +} from './transferToATA'; +import { MakeOptional } from './types'; export type TokenPluginRequirements = GeneratedTokenPluginRequirements & ClientWithPayer; @@ -19,14 +32,17 @@ export type TokenPluginInstructions = GeneratedTokenPluginInstructions & { /** Create a new token mint. */ createMint: ( input: MakeOptional, + config?: CreateMintInstructionPlanConfig, ) => ReturnType & SelfPlanAndSendFunctions; /** Mint tokens to an owner's ATA (created if needed). */ mintToATA: ( input: MakeOptional, + config?: MintToATAInstructionPlanConfig, ) => Promise>> & SelfPlanAndSendFunctions; /** Transfer tokens to a recipient's ATA (created if needed). */ transferToATA: ( input: MakeOptional, + config?: TransferToATAInstructionPlanConfig, ) => Promise>> & SelfPlanAndSendFunctions; }; @@ -38,37 +54,44 @@ export function tokenProgram() { ...c.token, instructions: { ...c.token.instructions, - createMint: input => + createMint: (input, config) => addSelfPlanAndSendFunctions( client, - getCreateMintInstructionPlan({ - ...input, - payer: input.payer ?? client.payer, - mintAuthority: input.mintAuthority ?? client.payer.address, - }), + getCreateMintInstructionPlan( + { + ...input, + payer: input.payer ?? client.payer, + mintAuthority: input.mintAuthority ?? client.payer.address, + }, + config, + ), ), - mintToATA: input => + mintToATA: (input, config) => addSelfPlanAndSendFunctions( client, - getMintToATAInstructionPlanAsync({ - ...input, - payer: input.payer ?? client.payer, - mintAuthority: input.mintAuthority ?? client.payer, - }), + getMintToATAInstructionPlanAsync( + { + ...input, + payer: input.payer ?? client.payer, + mintAuthority: input.mintAuthority ?? client.payer, + }, + config, + ), ), - transferToATA: input => + transferToATA: (input, config) => addSelfPlanAndSendFunctions( client, - getTransferToATAInstructionPlanAsync({ - ...input, - payer: input.payer ?? client.payer, - authority: input.authority ?? client.payer, - }), + getTransferToATAInstructionPlanAsync( + { + ...input, + payer: input.payer ?? client.payer, + authority: input.authority ?? client.payer, + }, + config, + ), ), }, }, })); }; } - -type MakeOptional = Omit & Partial>; diff --git a/clients/js/src/transferToATA.ts b/clients/js/src/transferToATA.ts index 1e0a174a..31588eb3 100644 --- a/clients/js/src/transferToATA.ts +++ b/clients/js/src/transferToATA.ts @@ -5,6 +5,7 @@ import { getTransferCheckedInstruction, TOKEN_PROGRAM_ADDRESS, } from './generated'; +import { MakeOptional } from './types'; export type TransferToATAInstructionPlanInput = { /** Funding account (must be a system account). */ @@ -30,7 +31,7 @@ export type TransferToATAInstructionPlanInput = { multiSigners?: Array; }; -type TransferToATAInstructionPlanConfig = { +export type TransferToATAInstructionPlanConfig = { systemProgram?: Address; tokenProgram?: Address; associatedTokenProgram?: Address; @@ -71,23 +72,16 @@ export function getTransferToATAInstructionPlan( ]); } -export type TransferToATAInstructionPlanAsyncInput = Omit< +export type TransferToATAInstructionPlanAsyncInput = MakeOptional< TransferToATAInstructionPlanInput, - 'destination' | 'source' -> & { - /** Source token account. When omitted, derived from authority's address + mint. */ - source?: Address; - /** Destination ATA. When omitted, derived from recipient + mint. */ - destination?: Address; - /** Token program address. Defaults to TOKEN_PROGRAM_ADDRESS. */ - tokenProgram?: Address; -}; + 'source' | 'destination' +>; export async function getTransferToATAInstructionPlanAsync( input: TransferToATAInstructionPlanAsyncInput, config?: TransferToATAInstructionPlanConfig, ): Promise { - const tokenProgram = config?.tokenProgram ?? input.tokenProgram ?? TOKEN_PROGRAM_ADDRESS; + const tokenProgram = config?.tokenProgram ?? TOKEN_PROGRAM_ADDRESS; const destinationAta = input.destination ?? @@ -117,6 +111,6 @@ export async function getTransferToATAInstructionPlanAsync( source, destination: destinationAta, }, - { ...config, tokenProgram }, + config, ); } diff --git a/clients/js/src/types.ts b/clients/js/src/types.ts new file mode 100644 index 00000000..488df36a --- /dev/null +++ b/clients/js/src/types.ts @@ -0,0 +1 @@ +export type MakeOptional = Omit & Partial>; From d5e9b97d129e478705f9ea2c637980b7e5e07ef3 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:58:17 -0800 Subject: [PATCH 3/5] chore: test types --- clients/js/test/mintToATA.test.ts | 2 +- clients/js/test/plugin.test.ts | 20 +++++++++++++------- clients/js/test/transferToATA.test.ts | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/clients/js/test/mintToATA.test.ts b/clients/js/test/mintToATA.test.ts index 88a04601..70cd0e92 100644 --- a/clients/js/test/mintToATA.test.ts +++ b/clients/js/test/mintToATA.test.ts @@ -27,7 +27,7 @@ import { function getInstructionAccounts(plan: SequentialInstructionPlan): Address[][] { return plan.plans.map(p => { const single = p as SingleInstructionPlan; - return (single.instruction.accounts ?? []).map((a: any) => a.address); + return (single.instruction.accounts ?? []).map(a => a.address); }); } diff --git a/clients/js/test/plugin.test.ts b/clients/js/test/plugin.test.ts index 309cbe7b..88afc957 100644 --- a/clients/js/test/plugin.test.ts +++ b/clients/js/test/plugin.test.ts @@ -3,6 +3,12 @@ import { generateKeyPairSigner, type SingleInstructionPlan, type SequentialInstructionPlan, + type ClientWithTransactionPlanning, + type ClientWithTransactionSending, + Rpc, + SolanaRpcApi, + RpcSubscriptions, + SolanaRpcSubscriptionsApi, } from '@solana/kit'; import test from 'ava'; import { TOKEN_PROGRAM_ADDRESS, findAssociatedTokenPda, tokenProgram } from '../src'; @@ -11,7 +17,7 @@ import { TOKEN_PROGRAM_ADDRESS, findAssociatedTokenPda, tokenProgram } from '../ function getInstructionAccounts(plan: SequentialInstructionPlan): Address[][] { return plan.plans.map(p => { const single = p as SingleInstructionPlan; - return (single.instruction.accounts ?? []).map((a: any) => a.address); + return (single.instruction.accounts ?? []).map(a => a.address); }); } @@ -22,12 +28,12 @@ function getInstructionAccounts(plan: SequentialInstructionPlan): Address[][] { function createMockClient(payer: Awaited>) { return tokenProgram()({ payer, - rpc: {} as any, - rpcSubscriptions: {} as any, - planTransaction: (() => {}) as any, - planTransactions: (() => {}) as any, - sendTransaction: (() => {}) as any, - sendTransactions: (() => {}) as any, + rpc: {} as Rpc, + rpcSubscriptions: {} as RpcSubscriptions, + planTransaction: (async () => {}) as unknown as ClientWithTransactionPlanning['planTransaction'], + planTransactions: (async () => {}) as unknown as ClientWithTransactionPlanning['planTransactions'], + sendTransaction: (async () => {}) as unknown as ClientWithTransactionSending['sendTransaction'], + sendTransactions: (async () => {}) as unknown as ClientWithTransactionSending['sendTransactions'], }); } diff --git a/clients/js/test/transferToATA.test.ts b/clients/js/test/transferToATA.test.ts index 69eb05f0..4e2622f6 100644 --- a/clients/js/test/transferToATA.test.ts +++ b/clients/js/test/transferToATA.test.ts @@ -28,7 +28,7 @@ import { function getInstructionAccounts(plan: SequentialInstructionPlan): Address[][] { return plan.plans.map(p => { const single = p as SingleInstructionPlan; - return (single.instruction.accounts ?? []).map((a: any) => a.address); + return (single.instruction.accounts ?? []).map(a => a.address); }); } From e7f70ade585b946df7b44ebe3e99e33611b178cd Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:56:58 -0800 Subject: [PATCH 4/5] refactor(js): remove authority defaults and replace offline tests Only payer is defaulted in the plugin; callers must provide mintAuthority and authority explicitly. Replace mock/plan-inspection tests with integration tests against localhost validator. --- clients/js/src/plugin.ts | 9 +- clients/js/test/mintToATA.test.ts | 126 ++++++----------- clients/js/test/plugin.test.ts | 196 +++++++++++--------------- clients/js/test/transferToATA.test.ts | 140 +++++------------- 4 files changed, 162 insertions(+), 309 deletions(-) diff --git a/clients/js/src/plugin.ts b/clients/js/src/plugin.ts index f1933e5a..19505395 100644 --- a/clients/js/src/plugin.ts +++ b/clients/js/src/plugin.ts @@ -31,17 +31,17 @@ export type TokenPlugin = Omit & { instruc export type TokenPluginInstructions = GeneratedTokenPluginInstructions & { /** Create a new token mint. */ createMint: ( - input: MakeOptional, + input: MakeOptional, config?: CreateMintInstructionPlanConfig, ) => ReturnType & SelfPlanAndSendFunctions; /** Mint tokens to an owner's ATA (created if needed). */ mintToATA: ( - input: MakeOptional, + input: MakeOptional, config?: MintToATAInstructionPlanConfig, ) => Promise>> & SelfPlanAndSendFunctions; /** Transfer tokens to a recipient's ATA (created if needed). */ transferToATA: ( - input: MakeOptional, + input: MakeOptional, config?: TransferToATAInstructionPlanConfig, ) => Promise>> & SelfPlanAndSendFunctions; }; @@ -61,7 +61,6 @@ export function tokenProgram() { { ...input, payer: input.payer ?? client.payer, - mintAuthority: input.mintAuthority ?? client.payer.address, }, config, ), @@ -73,7 +72,6 @@ export function tokenProgram() { { ...input, payer: input.payer ?? client.payer, - mintAuthority: input.mintAuthority ?? client.payer, }, config, ), @@ -85,7 +83,6 @@ export function tokenProgram() { { ...input, payer: input.payer ?? client.payer, - authority: input.authority ?? client.payer, }, config, ), diff --git a/clients/js/test/mintToATA.test.ts b/clients/js/test/mintToATA.test.ts index 70cd0e92..2e5edd7b 100644 --- a/clients/js/test/mintToATA.test.ts +++ b/clients/js/test/mintToATA.test.ts @@ -1,11 +1,4 @@ -import { - Account, - Address, - generateKeyPairSigner, - none, - type SingleInstructionPlan, - type SequentialInstructionPlan, -} from '@solana/kit'; +import { Account, generateKeyPairSigner, none } from '@solana/kit'; import test from 'ava'; import { AccountState, @@ -23,14 +16,6 @@ import { generateKeyPairSignerWithSol, } from './_setup'; -// Extract the account addresses from a sequential instruction plan's instructions. -function getInstructionAccounts(plan: SequentialInstructionPlan): Address[][] { - return plan.plans.map(p => { - const single = p as SingleInstructionPlan; - return (single.instruction.accounts ?? []).map(a => a.address); - }); -} - test('it creates a new associated token account with an initial balance', async t => { // Given a mint account, its mint authority, a token owner and the ATA. const client = createDefaultSolanaClient(); @@ -125,6 +110,49 @@ test('it derives a new associated token account with an initial balance', async }); }); +test('it uses an explicit ATA when provided to the async variant', async t => { + // Given a mint account, its mint authority, a token owner and a pre-derived ATA. + const client = createDefaultSolanaClient(); + const [payer, mintAuthority, owner] = await Promise.all([ + generateKeyPairSignerWithSol(client), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + const decimals = 2; + const mint = await createMint(client, payer, mintAuthority.address, decimals); + const [ata] = await findAssociatedTokenPda({ + mint, + owner: owner.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + // When we mint via the async variant with an explicit ATA. + const instructionPlan = await getMintToATAInstructionPlanAsync({ + payer, + ata, + mint, + owner: owner.address, + mintAuthority, + amount: 1_000n, + decimals, + }); + + const transactionPlanner = createDefaultTransactionPlanner(client, payer); + const transactionPlan = await transactionPlanner(instructionPlan); + await client.sendTransactionPlan(transactionPlan); + + // Then the explicit ATA should hold the minted tokens. + t.like(await fetchToken(client.rpc, ata), >{ + address: ata, + data: { + mint, + owner: owner.address, + amount: 1000n, + state: AccountState.Initialized, + }, + }); +}); + test('it also mints to an existing associated token account', async t => { // Given a mint account, its mint authority, a token owner and the ATA. const client = createDefaultSolanaClient(); @@ -185,69 +213,3 @@ test('it also mints to an existing associated token account', async t => { }, }); }); - -// --- Offline tests: verify derived addresses in instruction plans --- - -test('async variant auto-derives ATA from owner + mint', async t => { - // Given an owner and mint. - const payer = await generateKeyPairSigner(); - const mintAuthority = await generateKeyPairSigner(); - const owner = (await generateKeyPairSigner()).address; - const mint = (await generateKeyPairSigner()).address; - - const [expectedAta] = await findAssociatedTokenPda({ - owner, - mint, - tokenProgram: TOKEN_PROGRAM_ADDRESS, - }); - - // When building a plan without an explicit ATA. - const plan = await getMintToATAInstructionPlanAsync({ - payer, - mint, - owner, - mintAuthority, - amount: 500n, - decimals: 6, - }); - - // Then the plan should contain the derived ATA. - const seqPlan = plan as SequentialInstructionPlan; - t.is(seqPlan.kind, 'sequential'); - t.is(seqPlan.plans.length, 2); - - const accounts = getInstructionAccounts(seqPlan); - - // createAssociatedTokenIdempotent — ata at index 1 - t.is(accounts[0][1], expectedAta); - - // mintToChecked — token at index 1 - t.is(accounts[1][1], expectedAta); -}); - -test('async variant uses explicit ATA when provided', async t => { - // Given an explicit ATA address. - const payer = await generateKeyPairSigner(); - const mintAuthority = await generateKeyPairSigner(); - const owner = (await generateKeyPairSigner()).address; - const mint = (await generateKeyPairSigner()).address; - const explicitAta = (await generateKeyPairSigner()).address; - - // When building a plan with the explicit ATA. - const plan = await getMintToATAInstructionPlanAsync({ - payer, - mint, - owner, - mintAuthority, - ata: explicitAta, - amount: 500n, - decimals: 6, - }); - - // Then the plan should use the explicit ATA, not a derived one. - const seqPlan = plan as SequentialInstructionPlan; - const accounts = getInstructionAccounts(seqPlan); - - t.is(accounts[0][1], explicitAta); - t.is(accounts[1][1], explicitAta); -}); diff --git a/clients/js/test/plugin.test.ts b/clients/js/test/plugin.test.ts index 88afc957..9c836cb6 100644 --- a/clients/js/test/plugin.test.ts +++ b/clients/js/test/plugin.test.ts @@ -1,130 +1,96 @@ -import { - Address, - generateKeyPairSigner, - type SingleInstructionPlan, - type SequentialInstructionPlan, - type ClientWithTransactionPlanning, - type ClientWithTransactionSending, - Rpc, - SolanaRpcApi, - RpcSubscriptions, - SolanaRpcSubscriptionsApi, -} from '@solana/kit'; +import { Account, generateKeyPairSigner } from '@solana/kit'; +import { createDefaultLocalhostRpcClient } from '@solana/kit-plugins'; import test from 'ava'; -import { TOKEN_PROGRAM_ADDRESS, findAssociatedTokenPda, tokenProgram } from '../src'; - -// Extract the account addresses from a sequential instruction plan's instructions. -function getInstructionAccounts(plan: SequentialInstructionPlan): Address[][] { - return plan.plans.map(p => { - const single = p as SingleInstructionPlan; - return (single.instruction.accounts ?? []).map(a => a.address); - }); -} - -/** - * Create a minimal mock client that satisfies TokenPluginRequirements. - * No real RPC — just enough for the plugin to wire up defaults. - */ -function createMockClient(payer: Awaited>) { - return tokenProgram()({ - payer, - rpc: {} as Rpc, - rpcSubscriptions: {} as RpcSubscriptions, - planTransaction: (async () => {}) as unknown as ClientWithTransactionPlanning['planTransaction'], - planTransactions: (async () => {}) as unknown as ClientWithTransactionPlanning['planTransactions'], - sendTransaction: (async () => {}) as unknown as ClientWithTransactionSending['sendTransaction'], - sendTransactions: (async () => {}) as unknown as ClientWithTransactionSending['sendTransactions'], - }); -} - -test('plugin transferToATA defaults authority to payer and auto-derives source + destination', async t => { - const payer = await generateKeyPairSigner(); - const mint = (await generateKeyPairSigner()).address; - const recipient = (await generateKeyPairSigner()).address; - - const [expectedSource] = await findAssociatedTokenPda({ - owner: payer.address, - mint, - tokenProgram: TOKEN_PROGRAM_ADDRESS, - }); - const [expectedDestination] = await findAssociatedTokenPda({ - owner: recipient, - mint, +import { AccountState, fetchToken, findAssociatedTokenPda, Token, TOKEN_PROGRAM_ADDRESS, tokenProgram } from '../src'; +import { + createMint, + createTokenPdaWithAmount, + generateKeyPairSignerWithSol, + createDefaultSolanaClient, +} from './_setup'; + +test('plugin mintToATA defaults payer and auto-derives ATA', async t => { + // Given a mint account, its mint authority and a token owner. + const client = await createDefaultLocalhostRpcClient().use(tokenProgram()); + const mintAuthority = await generateKeyPairSigner(); + const owner = await generateKeyPairSigner(); + const mint = await generateKeyPairSigner(); + + // And a mint created via the plugin. + await client.token.instructions + .createMint({ newMint: mint, decimals: 2, mintAuthority: mintAuthority.address }) + .sendTransaction(); + + // When we mint to the owner via the plugin (payer defaulted, ATA derived). + await client.token.instructions + .mintToATA({ + mint: mint.address, + owner: owner.address, + mintAuthority, + amount: 1_000n, + decimals: 2, + }) + .sendTransaction(); + + // Then we expect the derived ATA to exist with the correct balance. + const [ata] = await findAssociatedTokenPda({ + mint: mint.address, + owner: owner.address, tokenProgram: TOKEN_PROGRAM_ADDRESS, }); - const client = createMockClient(payer); - - // Call transferToATA with minimal input — payer, authority, source all defaulted/derived. - const plan = await client.token.instructions.transferToATA({ - mint, - recipient, - amount: 100n, - decimals: 9, + t.like(await fetchToken(client.rpc, ata), >{ + address: ata, + data: { + mint: mint.address, + owner: owner.address, + amount: 1000n, + state: AccountState.Initialized, + }, }); - - const seqPlan = plan as unknown as SequentialInstructionPlan; - t.is(seqPlan.kind, 'sequential'); - const accounts = getInstructionAccounts(seqPlan); - - // createAssociatedTokenIdempotent — ata (destination) at index 1 - t.is(accounts[0][1], expectedDestination); - - // transferChecked — source at index 0, destination at index 2 - t.is(accounts[1][0], expectedSource); - t.is(accounts[1][2], expectedDestination); }); -test('plugin mintToATA defaults mintAuthority to payer and auto-derives ATA', async t => { - const payer = await generateKeyPairSigner(); - const mint = (await generateKeyPairSigner()).address; - const owner = (await generateKeyPairSigner()).address; - - const [expectedAta] = await findAssociatedTokenPda({ - owner, +test('plugin transferToATA defaults payer and auto-derives source + destination', async t => { + // Given a mint account and ownerA's ATA with 100 tokens. + const baseClient = createDefaultSolanaClient(); + const [payer, mintAuthority, ownerA, ownerB] = await Promise.all([ + generateKeyPairSignerWithSol(baseClient), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); + const decimals = 2; + const mint = await createMint(baseClient, payer, mintAuthority.address, decimals); + await createTokenPdaWithAmount(baseClient, payer, mintAuthority, mint, ownerA.address, 100n, decimals); + + // When ownerA transfers 50 tokens to ownerB via the plugin (payer defaulted, source + destination derived). + const client = await createDefaultLocalhostRpcClient().use(tokenProgram()); + await client.token.instructions + .transferToATA({ + mint, + authority: ownerA, + recipient: ownerB.address, + amount: 50n, + decimals, + }) + .sendTransaction(); + + // Then we expect both ATAs to have the correct balances. + const [sourceAta] = await findAssociatedTokenPda({ + owner: ownerA.address, mint, tokenProgram: TOKEN_PROGRAM_ADDRESS, }); - - const client = createMockClient(payer); - - // Call mintToATA with minimal input — payer and mintAuthority defaulted, ATA derived. - const plan = await client.token.instructions.mintToATA({ + const [destAta] = await findAssociatedTokenPda({ + owner: ownerB.address, mint, - owner, - amount: 1000n, - decimals: 6, + tokenProgram: TOKEN_PROGRAM_ADDRESS, }); - const seqPlan = plan as unknown as SequentialInstructionPlan; - t.is(seqPlan.kind, 'sequential'); - const accounts = getInstructionAccounts(seqPlan); - - // createAssociatedTokenIdempotent — ata at index 1 - t.is(accounts[0][1], expectedAta); - - // mintToChecked — token at index 1, mintAuthority at index 2 - t.is(accounts[1][1], expectedAta); - t.is(accounts[1][2], payer.address); // mintAuthority defaulted to payer -}); - -test('plugin createMint defaults mintAuthority to payer address', async t => { - const payer = await generateKeyPairSigner(); - const newMint = await generateKeyPairSigner(); - - const client = createMockClient(payer); - - // Call createMint without mintAuthority — should default to payer.address. - const plan = client.token.instructions.createMint({ - newMint, - decimals: 9, + t.like(await fetchToken(client.rpc, sourceAta), >{ + data: { amount: 50n }, + }); + t.like(await fetchToken(client.rpc, destAta), >{ + data: { amount: 50n }, }); - - const seqPlan = plan as unknown as SequentialInstructionPlan; - t.is(seqPlan.kind, 'sequential'); - t.is(seqPlan.plans.length, 2); - const accounts = getInstructionAccounts(seqPlan); - - // createAccount — payer at index 0 - t.is(accounts[0][0], payer.address); }); diff --git a/clients/js/test/transferToATA.test.ts b/clients/js/test/transferToATA.test.ts index 4e2622f6..7cf88c9e 100644 --- a/clients/js/test/transferToATA.test.ts +++ b/clients/js/test/transferToATA.test.ts @@ -1,9 +1,4 @@ -import { - generateKeyPairSigner, - Address, - type SingleInstructionPlan, - type SequentialInstructionPlan, -} from '@solana/kit'; +import { generateKeyPairSigner } from '@solana/kit'; import test from 'ava'; import { Mint, @@ -24,14 +19,6 @@ import { generateKeyPairSignerWithSol, } from './_setup'; -// Extract the account addresses from a sequential instruction plan's instructions. -function getInstructionAccounts(plan: SequentialInstructionPlan): Address[][] { - return plan.plans.map(p => { - const single = p as SingleInstructionPlan; - return (single.instruction.accounts ?? []).map(a => a.address); - }); -} - 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(); @@ -167,105 +154,46 @@ test('it transfers tokens from one account to an existing ATA', async t => { t.like(tokenDataB, { amount: 60n }); }); -test('async variant auto-derives source ATA when omitted', async t => { - // Given a keypair for the authority and a mint. - const authority = await generateKeyPairSigner(); - const mint = (await generateKeyPairSigner()).address; - const recipient = (await generateKeyPairSigner()).address; - - // Compute expected ATAs. - const [expectedSource] = await findAssociatedTokenPda({ - owner: authority.address, - mint, - tokenProgram: TOKEN_PROGRAM_ADDRESS, - }); - const [expectedDestination] = await findAssociatedTokenPda({ - owner: recipient, - mint, - tokenProgram: TOKEN_PROGRAM_ADDRESS, - }); - - // When building a plan WITHOUT source (should auto-derive). - const plan = await getTransferToATAInstructionPlanAsync({ - payer: authority, - mint, - authority, - recipient, - amount: 100n, - decimals: 9, - }); - - // Then the instruction plan should contain the correct derived addresses. - const seqPlan = plan as SequentialInstructionPlan; - t.is(seqPlan.kind, 'sequential'); - t.is(seqPlan.plans.length, 2); - - const accounts = getInstructionAccounts(seqPlan); - - // 1st instruction: createAssociatedTokenIdempotent — ata (index 1) should be the destination ATA - t.is(accounts[0][1], expectedDestination); - - // 2nd instruction: transferChecked — source (index 0), destination (index 2) - t.is(accounts[1][0], expectedSource); - t.is(accounts[1][2], expectedDestination); -}); - -test('async variant auto-derives source from TransactionSigner authority', async t => { - // Given a signer authority (has .address property). - const authority = await generateKeyPairSigner(); - const mint = (await generateKeyPairSigner()).address; - const recipient = (await generateKeyPairSigner()).address; - - const [expectedSource] = await findAssociatedTokenPda({ - owner: authority.address, - mint, - tokenProgram: TOKEN_PROGRAM_ADDRESS, - }); +test('derives source and destination ATAs and transfers tokens', async t => { + // Given a mint account and ownerA's ATA with 100 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 = await createTokenPdaWithAmount(client, payer, mintAuthority, mint, ownerA.address, 100n, decimals); - // When building a plan with authority as a TransactionSigner. - const plan = await getTransferToATAInstructionPlanAsync({ - payer: authority, + // When owner A transfers 50 tokens to owner B without specifying source or destination. + const instructionPlan = await getTransferToATAInstructionPlanAsync({ + payer, mint, - authority, - recipient, + authority: ownerA, + recipient: ownerB.address, amount: 50n, - decimals: 6, + decimals, }); - // Then source should be derived from authority.address. - const seqPlan = plan as SequentialInstructionPlan; - const accounts = getInstructionAccounts(seqPlan); - t.is(accounts[1][0], expectedSource); -}); - -test('async variant uses explicit source and destination when provided', async t => { - // Given explicit source and destination addresses. - const authority = await generateKeyPairSigner(); - const mint = (await generateKeyPairSigner()).address; - const recipient = (await generateKeyPairSigner()).address; - const explicitSource = (await generateKeyPairSigner()).address; - const explicitDestination = (await generateKeyPairSigner()).address; + const transactionPlanner = createDefaultTransactionPlanner(client, payer); + const transactionPlan = await transactionPlanner(instructionPlan); + await client.sendTransactionPlan(transactionPlan); - // When building with explicit source and destination. - const plan = await getTransferToATAInstructionPlanAsync({ - payer: authority, + // Then we expect both ATAs to have the correct balances. + const [tokenB] = await findAssociatedTokenPda({ + owner: ownerB.address, mint, - authority, - recipient, - source: explicitSource, - destination: explicitDestination, - amount: 100n, - decimals: 9, + tokenProgram: TOKEN_PROGRAM_ADDRESS, }); - // Then the plan should use the explicit addresses, not derived ones. - const seqPlan = plan as SequentialInstructionPlan; - const accounts = getInstructionAccounts(seqPlan); - - // createAssociatedTokenIdempotent — ata at index 1 - t.is(accounts[0][1], explicitDestination); - - // transferChecked — source at index 0, destination at index 2 - t.is(accounts[1][0], explicitSource); - t.is(accounts[1][2], explicitDestination); + 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 }); }); From a3479143364a80d65ab30f8b20d010a05fc1fac4 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:32:59 -0800 Subject: [PATCH 5/5] refactor(js): simplify return types and normalize destinationAta style --- clients/js/src/plugin.ts | 4 ++-- clients/js/src/transferToATA.ts | 19 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/clients/js/src/plugin.ts b/clients/js/src/plugin.ts index 19505395..bb60dce4 100644 --- a/clients/js/src/plugin.ts +++ b/clients/js/src/plugin.ts @@ -38,12 +38,12 @@ export type TokenPluginInstructions = GeneratedTokenPluginInstructions & { mintToATA: ( input: MakeOptional, config?: MintToATAInstructionPlanConfig, - ) => Promise>> & SelfPlanAndSendFunctions; + ) => ReturnType & SelfPlanAndSendFunctions; /** Transfer tokens to a recipient's ATA (created if needed). */ transferToATA: ( input: MakeOptional, config?: TransferToATAInstructionPlanConfig, - ) => Promise>> & SelfPlanAndSendFunctions; + ) => ReturnType & SelfPlanAndSendFunctions; }; export function tokenProgram() { diff --git a/clients/js/src/transferToATA.ts b/clients/js/src/transferToATA.ts index 31588eb3..2ffdeeb8 100644 --- a/clients/js/src/transferToATA.ts +++ b/clients/js/src/transferToATA.ts @@ -83,15 +83,14 @@ export async function getTransferToATAInstructionPlanAsync( ): Promise { const tokenProgram = config?.tokenProgram ?? TOKEN_PROGRAM_ADDRESS; - const destinationAta = - input.destination ?? - ( - await findAssociatedTokenPda({ - owner: input.recipient, - tokenProgram, - mint: input.mint, - }) - )[0]; + let destination = input.destination; + if (!destination) { + [destination] = await findAssociatedTokenPda({ + owner: input.recipient, + tokenProgram, + mint: input.mint, + }); + } let source = input.source; if (!source) { @@ -109,7 +108,7 @@ export async function getTransferToATAInstructionPlanAsync( { ...input, source, - destination: destinationAta, + destination, }, config, );