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 632b0012..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,21 +70,25 @@ export function getMintToATAInstructionPlan( ]); } -type MintToATAInstructionPlanAsyncInput = Omit; +export type MintToATAInstructionPlanAsyncInput = MakeOptional; 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 ?? 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, ); diff --git a/clients/js/src/plugin.ts b/clients/js/src/plugin.ts index d01fd51e..bb60dce4 100644 --- a/clients/js/src/plugin.ts +++ b/clients/js/src/plugin.ts @@ -1,30 +1,49 @@ 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 { getMintToATAInstructionPlan, MintToATAInstructionPlanInput } from './mintToATA'; -import { getTransferToATAInstructionPlan, TransferToATAInstructionPlanInput } from './transferToATA'; +import { + getMintToATAInstructionPlanAsync, + MintToATAInstructionPlanAsyncInput, + MintToATAInstructionPlanConfig, +} from './mintToATA'; +import { + getTransferToATAInstructionPlanAsync, + TransferToATAInstructionPlanAsyncInput, + TransferToATAInstructionPlanConfig, +} from './transferToATA'; +import { MakeOptional } from './types'; export type TokenPluginRequirements = GeneratedTokenPluginRequirements & ClientWithPayer; export type TokenPlugin = Omit & { instructions: TokenPluginInstructions }; 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, - ) => ReturnType & SelfPlanAndSendFunctions; + input: MakeOptional, + config?: MintToATAInstructionPlanConfig, + ) => ReturnType & SelfPlanAndSendFunctions; + /** Transfer tokens to a recipient's ATA (created if needed). */ transferToATA: ( - input: MakeOptional, - ) => ReturnType & SelfPlanAndSendFunctions; + input: MakeOptional, + config?: TransferToATAInstructionPlanConfig, + ) => ReturnType & SelfPlanAndSendFunctions; }; export function tokenProgram() { @@ -35,25 +54,41 @@ export function tokenProgram() { ...c.token, instructions: { ...c.token.instructions, - createMint: input => + createMint: (input, config) => addSelfPlanAndSendFunctions( client, - getCreateMintInstructionPlan({ ...input, payer: input.payer ?? client.payer }), + getCreateMintInstructionPlan( + { + ...input, + payer: input.payer ?? client.payer, + }, + config, + ), ), - mintToATA: input => + mintToATA: (input, config) => addSelfPlanAndSendFunctions( client, - getMintToATAInstructionPlan({ ...input, payer: input.payer ?? client.payer }), + getMintToATAInstructionPlanAsync( + { + ...input, + payer: input.payer ?? client.payer, + }, + config, + ), ), - transferToATA: input => + transferToATA: (input, config) => addSelfPlanAndSendFunctions( client, - getTransferToATAInstructionPlan({ ...input, payer: input.payer ?? client.payer }), + getTransferToATAInstructionPlanAsync( + { + ...input, + payer: input.payer ?? client.payer, + }, + config, + ), ), }, }, })); }; } - -type MakeOptional = Omit & Partial>; diff --git a/clients/js/src/transferToATA.ts b/clients/js/src/transferToATA.ts index 243db2bc..2ffdeeb8 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,21 +72,43 @@ export function getTransferToATAInstructionPlan( ]); } -type TransferToATAInstructionPlanAsyncInput = Omit; +export type TransferToATAInstructionPlanAsyncInput = MakeOptional< + TransferToATAInstructionPlanInput, + 'source' | '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, - }); + const tokenProgram = config?.tokenProgram ?? TOKEN_PROGRAM_ADDRESS; + + let destination = input.destination; + if (!destination) { + [destination] = await findAssociatedTokenPda({ + owner: input.recipient, + tokenProgram, + mint: input.mint, + }); + } + + 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, }, 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>; diff --git a/clients/js/test/mintToATA.test.ts b/clients/js/test/mintToATA.test.ts index aaab1640..2e5edd7b 100644 --- a/clients/js/test/mintToATA.test.ts +++ b/clients/js/test/mintToATA.test.ts @@ -110,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(); diff --git a/clients/js/test/plugin.test.ts b/clients/js/test/plugin.test.ts new file mode 100644 index 00000000..9c836cb6 --- /dev/null +++ b/clients/js/test/plugin.test.ts @@ -0,0 +1,96 @@ +import { Account, generateKeyPairSigner } from '@solana/kit'; +import { createDefaultLocalhostRpcClient } from '@solana/kit-plugins'; +import test from 'ava'; +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, + }); + + t.like(await fetchToken(client.rpc, ata), >{ + address: ata, + data: { + mint: mint.address, + owner: owner.address, + amount: 1000n, + state: AccountState.Initialized, + }, + }); +}); + +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 [destAta] = await findAssociatedTokenPda({ + owner: ownerB.address, + mint, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + t.like(await fetchToken(client.rpc, sourceAta), >{ + data: { amount: 50n }, + }); + t.like(await fetchToken(client.rpc, destAta), >{ + data: { amount: 50n }, + }); +}); diff --git a/clients/js/test/transferToATA.test.ts b/clients/js/test/transferToATA.test.ts index 2482fb1e..7cf88c9e 100644 --- a/clients/js/test/transferToATA.test.ts +++ b/clients/js/test/transferToATA.test.ts @@ -153,3 +153,47 @@ test('it transfers tokens from one account to an existing ATA', async t => { t.like(tokenDataA, { amount: 40n }); t.like(tokenDataB, { amount: 60n }); }); + +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 owner A transfers 50 tokens to owner B without specifying source or destination. + const instructionPlan = await getTransferToATAInstructionPlanAsync({ + payer, + mint, + 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 both ATAs to have the correct balances. + 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 }); +});