learn.sol
Solana Kit Clients • Spl Token And Token 2022 Client Flows
Lesson 7 of 11

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.

Token Program KindTap to reveal
The explicit choice between classic SPL Token and Token-2022, which the client should treat as different programs.
Token Program Kind
Transfer PlanTap to reveal
The validated client-side result that includes the chosen token program, token accounts, decimals, and instruction to send.
Transfer Plan
Preflight ValidationTap to reveal
The checks the client performs before opening the wallet so malformed token flows fail early and honestly.
Preflight Validation
Associated Token AccountTap to reveal
The derived token account address for a given owner, mint, and token program that the client can compute instead of guessing.
Associated Token Account

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-2022

Now 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

Quick Check
Single answer

Why should the client keep classic SPL Token and Token-2022 as an explicit choice instead of hiding it in a helper?

Quick Check
Single answer

What should already be true before the wallet prompt opens for a token transfer?

Sources

Solana Assistant

AI-powered documentation helper

Welcome to Solana Assistant

Ask specific questions about Solana development:

Ask specific questions for better results400px