Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion clients/js/src/createMint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export type CreateMintInstructionPlanInput = {
mintAccountLamports?: number;
};

type CreateMintInstructionPlanConfig = {
export type CreateMintInstructionPlanConfig = {
systemProgram?: Address;
tokenProgram?: Address;
};
Expand Down
21 changes: 13 additions & 8 deletions clients/js/src/mintToATA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -28,7 +29,7 @@ export type MintToATAInstructionPlanInput = {
multiSigners?: Array<TransactionSigner>;
};

type MintToATAInstructionPlanConfig = {
export type MintToATAInstructionPlanConfig = {
systemProgram?: Address;
tokenProgram?: Address;
associatedTokenProgram?: Address;
Expand Down Expand Up @@ -69,21 +70,25 @@ export function getMintToATAInstructionPlan(
]);
}

type MintToATAInstructionPlanAsyncInput = Omit<MintToATAInstructionPlanInput, 'ata'>;
export type MintToATAInstructionPlanAsyncInput = MakeOptional<MintToATAInstructionPlanInput, 'ata'>;

export async function getMintToATAInstructionPlanAsync(
input: MintToATAInstructionPlanAsyncInput,
config?: MintToATAInstructionPlanConfig,
): Promise<InstructionPlan> {
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,
);
Expand Down
70 changes: 54 additions & 16 deletions clients/js/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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<GeneratedTokenPlugin, 'instructions'> & { instructions: TokenPluginInstructions };

export type TokenPluginInstructions = GeneratedTokenPluginInstructions & {
/** Create a new token mint. */
createMint: (
input: MakeOptional<CreateMintInstructionPlanInput, 'payer'>,
input: MakeOptional<CreateMintInstructionPlanInput, 'payer' | 'mintAuthority'>,
config?: CreateMintInstructionPlanConfig,
) => ReturnType<typeof getCreateMintInstructionPlan> & SelfPlanAndSendFunctions;
/** Mint tokens to an owner's ATA (created if needed). */
mintToATA: (
input: MakeOptional<MintToATAInstructionPlanInput, 'payer'>,
) => ReturnType<typeof getMintToATAInstructionPlan> & SelfPlanAndSendFunctions;
input: MakeOptional<MintToATAInstructionPlanAsyncInput, 'payer' | 'mintAuthority'>,
config?: MintToATAInstructionPlanConfig,
) => Promise<Awaited<ReturnType<typeof getMintToATAInstructionPlanAsync>>> & SelfPlanAndSendFunctions;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these can just be:

Suggested change
) => Promise<Awaited<ReturnType<typeof getMintToATAInstructionPlanAsync>>> & SelfPlanAndSendFunctions;
) => ReturnType<typeof getMintToATAInstructionPlanAsync> & SelfPlanAndSendFunctions;

Because getMintToATAInstructionPlanAsync already returns a promise.

/** Transfer tokens to a recipient's ATA (created if needed). */
transferToATA: (
input: MakeOptional<TransferToATAInstructionPlanInput, 'payer'>,
) => ReturnType<typeof getTransferToATAInstructionPlan> & SelfPlanAndSendFunctions;
input: MakeOptional<TransferToATAInstructionPlanAsyncInput, 'payer' | 'authority'>,
config?: TransferToATAInstructionPlanConfig,
) => Promise<Awaited<ReturnType<typeof getTransferToATAInstructionPlanAsync>>> & SelfPlanAndSendFunctions;
};

export function tokenProgram() {
Expand All @@ -35,25 +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 }),
getCreateMintInstructionPlan(
{
...input,
payer: input.payer ?? client.payer,
mintAuthority: input.mintAuthority ?? client.payer.address,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a tricky assumption.

Yes, most of the time you'll want the payer to be the authority or owner of anything you do with a program but not always. The assumption with client.payer is that this is going to be the wallet that pays for things like transaction fees and rent fees.

Codama actually has got two different nodes for identifying a user:

  • PayerValueNode: Meaning this account is used to pay for things.
  • IdentityValueNode: Meaning this account is used to identify the user interacting with the app. This would be a better contender for things like mintAuthority here.

Because Codama already has "identity" knowledge, it would be possible to generate that change, just like we do currently with the payer. This means though we need to introduce a client.identity wallet on the client and have plugins that can set both of these things to be the same by default.

I wanted to wait a little before introducing too many concepts to default clients to avoid overwhelming devs but this is something we can implement soon.

My other concern is that we'd be automatically filling important signer values for devs. Values that could have dangerous consequences if they just forgot to fill them or didn't know it was there. If you forget to inject a payer or put the wrong one, you end up with either an error or the wrong wallet that paid for a few fees. If you get the authority wrong, this can have irreversible consequences. This is why there is also a case for not introducing default values for identity accounts such as authority, owner, etc. and instead force the developer to explicitly set this information.

Copy link
Author

@amilz amilz Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • like the idea of client.identity -- the delineation would help us in our kora plugin, actually.
  • fair point re: auth/owner/etc. I'm fine leaving it off for now--we can chat about it and maybe revisit later.

},
config,
),
),
mintToATA: input =>
mintToATA: (input, config) =>
addSelfPlanAndSendFunctions(
client,
getMintToATAInstructionPlan({ ...input, payer: input.payer ?? client.payer }),
getMintToATAInstructionPlanAsync(
{
...input,
payer: input.payer ?? client.payer,
mintAuthority: input.mintAuthority ?? client.payer,
},
config,
),
),
transferToATA: input =>
transferToATA: (input, config) =>
addSelfPlanAndSendFunctions(
client,
getTransferToATAInstructionPlan({ ...input, payer: input.payer ?? client.payer }),
getTransferToATAInstructionPlanAsync(
{
...input,
payer: input.payer ?? client.payer,
authority: input.authority ?? client.payer,
},
config,
),
),
},
},
}));
};
}

type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
40 changes: 32 additions & 8 deletions clients/js/src/transferToATA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -30,7 +31,7 @@ export type TransferToATAInstructionPlanInput = {
multiSigners?: Array<TransactionSigner>;
};

type TransferToATAInstructionPlanConfig = {
export type TransferToATAInstructionPlanConfig = {
systemProgram?: Address;
tokenProgram?: Address;
associatedTokenProgram?: Address;
Expand Down Expand Up @@ -71,21 +72,44 @@ export function getTransferToATAInstructionPlan(
]);
}

type TransferToATAInstructionPlanAsyncInput = Omit<TransferToATAInstructionPlanInput, 'destination'>;
export type TransferToATAInstructionPlanAsyncInput = MakeOptional<
TransferToATAInstructionPlanInput,
'source' | 'destination'
>;

export async function getTransferToATAInstructionPlanAsync(
input: TransferToATAInstructionPlanAsyncInput,
config?: TransferToATAInstructionPlanConfig,
): Promise<InstructionPlan> {
const [ataAddress] = await findAssociatedTokenPda({
owner: input.recipient,
tokenProgram: config?.tokenProgram ?? TOKEN_PROGRAM_ADDRESS,
mint: input.mint,
});
const tokenProgram = config?.tokenProgram ?? TOKEN_PROGRAM_ADDRESS;

const destinationAta =
input.destination ??
(
await findAssociatedTokenPda({
owner: input.recipient,
tokenProgram,
mint: input.mint,
})
)[0];
Comment on lines +86 to +94
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Let's match the style of the sourceAta assignment for consistency.


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,
);
Expand Down
1 change: 1 addition & 0 deletions clients/js/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
83 changes: 82 additions & 1 deletion clients/js/test/mintToATA.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 => 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();
Expand Down Expand Up @@ -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);
});
Loading