SPL Token and Token-2022 Client Flows
Build token transfer plans with explicit program selection, real instruction builders, and validation that happens before the wallet prompt.
Token flows go wrong when the client guesses.
The app guesses the token program.
It guesses the decimals.
It assumes any token account pair is valid for the chosen mint.
That is how users end up signing transfers that were malformed before the wallet popup even opened.
The correct model is stricter.
Before you ask the wallet to sign, the client should already know:
- which token program you are using
- which mint the transfer is for
- which token accounts belong to that mint
- which raw amount should be transferred
That is the whole lesson.
Classic SPL Token and Token-2022 Are Different Programs
They are not just two names for the same thing.
They are different program IDs with different extension capabilities.
So the client must keep the token program choice explicit.
Do not hide it inside a helper that silently assumes classic SPL Token.
Step 1: Build A Transfer Plan With Real Preflight Validation
Install the token clients if you have not already:
pnpm add @solana-program/token @solana-program/token-2022Now create lib/solana/tokenFlow.ts:
import { address, createSolanaRpc, type Address } from "@solana/kit";
import {
TOKEN_PROGRAM_ADDRESS,
fetchMint as fetchSplMint,
fetchToken as fetchSplToken,
findAssociatedTokenPda as findSplAssociatedTokenPda,
getTransferCheckedInstruction as getSplTransferCheckedInstruction,
} from "@solana-program/token";
import {
TOKEN_2022_PROGRAM_ADDRESS,
fetchMint as fetchToken2022Mint,
fetchToken as fetchToken2022Token,
findAssociatedTokenPda as findToken2022AssociatedTokenPda,
getTransferCheckedInstruction as getToken2022TransferCheckedInstruction,
} from "@solana-program/token-2022";
export type TokenProgramKind = "spl" | "token2022";
export type TokenTransferInput = {
programKind: TokenProgramKind;
mintAddress: string;
sourceOwnerAddress: string;
destinationOwnerAddress: string;
authorityAddress: string;
amountRaw: bigint;
};
const rpc = createSolanaRpc(
process.env.NEXT_PUBLIC_RPC_URL ?? "https://api.devnet.solana.com",
);
function getTokenApi(programKind: TokenProgramKind) {
if (programKind === "spl") {
return {
tokenProgramAddress: TOKEN_PROGRAM_ADDRESS,
fetchMint: fetchSplMint,
fetchToken: fetchSplToken,
findAssociatedTokenPda: findSplAssociatedTokenPda,
getTransferCheckedInstruction: getSplTransferCheckedInstruction,
};
}
return {
tokenProgramAddress: TOKEN_2022_PROGRAM_ADDRESS,
fetchMint: fetchToken2022Mint,
fetchToken: fetchToken2022Token,
findAssociatedTokenPda: findToken2022AssociatedTokenPda,
getTransferCheckedInstruction: getToken2022TransferCheckedInstruction,
};
}
export function validateTokenTransferInput(input: TokenTransferInput) {
if (!input.mintAddress) throw new Error("Missing mint address");
if (!input.sourceOwnerAddress) throw new Error("Missing source owner address");
if (!input.destinationOwnerAddress) {
throw new Error("Missing destination owner address");
}
if (!input.authorityAddress) throw new Error("Missing transfer authority");
if (input.amountRaw <= 0n) throw new Error("Amount must be positive");
}
export async function buildTokenTransferPlan(input: TokenTransferInput) {
validateTokenTransferInput(input);
const mintAddress = address(input.mintAddress) as Address;
const sourceOwnerAddress = address(input.sourceOwnerAddress) as Address;
const destinationOwnerAddress = address(input.destinationOwnerAddress) as Address;
const authorityAddress = address(input.authorityAddress) as Address;
const tokenApi = getTokenApi(input.programKind);
if (authorityAddress !== sourceOwnerAddress) {
throw new Error("This example expects the source owner to sign the transfer.");
}
const mint = await tokenApi.fetchMint(rpc, mintAddress);
const [sourceTokenAccount] = await tokenApi.findAssociatedTokenPda({
mint: mintAddress,
owner: sourceOwnerAddress,
tokenProgram: tokenApi.tokenProgramAddress,
});
const [destinationTokenAccount] = await tokenApi.findAssociatedTokenPda({
mint: mintAddress,
owner: destinationOwnerAddress,
tokenProgram: tokenApi.tokenProgramAddress,
});
const sourceToken = await tokenApi.fetchToken(rpc, sourceTokenAccount);
const destinationToken = await tokenApi.fetchToken(rpc, destinationTokenAccount);
if (sourceToken.data.mint !== mintAddress) {
throw new Error("Source token account is not for the selected mint.");
}
if (destinationToken.data.mint !== mintAddress) {
throw new Error("Destination token account is not for the selected mint.");
}
if (sourceToken.data.owner !== sourceOwnerAddress) {
throw new Error("Source token account is not owned by the expected wallet.");
}
if (destinationToken.data.owner !== destinationOwnerAddress) {
throw new Error(
"Destination token account is not owned by the expected wallet.",
);
}
return {
tokenProgramAddress: tokenApi.tokenProgramAddress,
sourceTokenAccount,
destinationTokenAccount,
decimals: mint.data.decimals,
instruction: tokenApi.getTransferCheckedInstruction({
source: sourceTokenAccount,
mint: mintAddress,
destination: destinationTokenAccount,
authority: authorityAddress,
amount: input.amountRaw,
decimals: mint.data.decimals,
}),
};
}This file now does the job the old version only claimed to do:
- fetch the mint before trusting decimals
- derive the token accounts instead of trusting pasted token-account strings
- verify that both accounts belong to the selected mint
- verify that the source and destination owners are the wallets you think they are
- only then build the transfer instruction
That is real client-side token validation.
Why The Input Model Changed
The old version asked for raw token-account addresses.
That is too loose for a beginner client.
It makes the UI responsible for knowing three things at once:
- the wallet owner
- the token account address
- whether that token account was created for the chosen mint and token program
This version is stricter.
You give the client wallet owner addresses and the mint.
The client derives the associated token accounts itself and verifies the actual on-chain data before it builds the transfer.
Why transferChecked Still Matters
transferChecked is still the right beginner default.
But now the client is not guessing the decimals anymore.
It reads mint.data.decimals from chain state and uses that value in the instruction.
That is the correct relationship:
- the mint defines precision
- the client reads that precision
- the transfer builder uses that exact precision
Step 2: Preview The Transfer Before Sending
Now create components/solana/TokenTransferPreview.tsx:
"use client";
import React, { useState } from "react";
import {
buildTokenTransferPlan,
type TokenProgramKind,
} from "@/lib/solana/tokenFlow";
export function TokenTransferPreview() {
const [programKind, setProgramKind] = useState<TokenProgramKind>("spl");
const [result, setResult] = useState<string>("");
const [error, setError] = useState("");
const onPreview = async () => {
setError("");
try {
const plan = await buildTokenTransferPlan({
programKind,
mintAddress: "ENTER_REAL_MINT_ADDRESS",
sourceOwnerAddress: "ENTER_REAL_SOURCE_WALLET_ADDRESS",
destinationOwnerAddress: "ENTER_REAL_DESTINATION_WALLET_ADDRESS",
authorityAddress: "ENTER_REAL_SOURCE_WALLET_ADDRESS",
amountRaw: 1_000_000n,
});
setResult(
JSON.stringify(
{
tokenProgram: plan.tokenProgramAddress,
sourceTokenAccount: plan.sourceTokenAccount,
destinationTokenAccount: plan.destinationTokenAccount,
decimals: plan.decimals,
},
null,
2,
),
);
} catch (e: any) {
setResult("");
setError(String(e?.message ?? e));
}
};
return (
<div className="space-y-3">
<select
value={programKind}
onChange={(e) => setProgramKind(e.target.value as TokenProgramKind)}
className="px-3 py-2 rounded bg-zinc-900"
>
<option value="spl">Classic SPL Token</option>
<option value="token2022">Token-2022</option>
</select>
<button
onClick={onPreview}
className="px-3 py-2 rounded bg-indigo-600 text-white"
>
Preview Transfer Plan
</button>
{error ? <div className="text-xs text-red-300">{error}</div> : null}
{result ? <pre className="text-xs bg-zinc-950 p-3 rounded">{result}</pre> : null}
</div>
);
}This preview still does not send the transfer.
That is still correct.
But now the preview is honest.
It only succeeds after the client has validated the mint, derived the token accounts, and confirmed the chain data matches the requested transfer.
The Failure Modes To Avoid
Assuming any token account can be pasted into the form
That is exactly how clients build invalid transfers.
If your app already knows the wallet owner and the mint, derive the associated token account and verify it.
Treating decimals as a display-only concern
Decimals affect raw amount construction.
They are part of transfer correctness.
The client should read them from the mint instead of trusting a manual input field.
Letting the wallet prompt become the first validation step
By the time the wallet opens, the transfer plan should already be coherent.
If the mint, owners, and token accounts do not line up, fail before the signature request.
What You Can Do After This
You can build token transfer plans with explicit program choice, derive and validate the actual token accounts involved, and use the mint's real decimals instead of guessing.
That is the client-side baseline for both classic SPL Token and Token-2022 flows.
Quick Check
Why should the client keep classic SPL Token and Token-2022 as an explicit choice instead of hiding it in a helper?
What should already be true before the wallet prompt opens for a token transfer?
Up Next in Solana Kit Clients
Anchor Client Patterns with Kit
Use the same separation of concerns for program actions by keeping instruction assembly in a service layer and transport in the shared client pipeline.