Token operations go through the SPL Token program via instruction builders. The builder crate must match the program id you are calling: spl_token::instruction::* for classic Token (TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA) and spl_token_2022::instruction::* for Token-2022 (TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb). Prefer the *_checked builders and validate mints, authorities, and decimals before any CPI.
use spl_token::state::Account as TokenAccount;use solana_program::program_pack::Pack;pub fn cpi_transfer(amount: u64, decimals: u8, token_program: &AccountInfo, mint: &AccountInfo, source: &AccountInfo, dest: &AccountInfo, auth: &AccountInfo, bump: u8) -> ProgramResult { // 1. The builder crate and the program account must agree. spl_token::check_program_account(token_program.key)?; // 2. Both token accounts must be owned by that token program. if source.owner != token_program.key || dest.owner != token_program.key { return Err(ProgramError::IncorrectProgramId); } // 3. Same mint on both sides, and it is the mint we are asserting decimals for. let src = TokenAccount::unpack(&source.try_borrow_data()?)?; let dst = TokenAccount::unpack(&dest.try_borrow_data()?)?; if src.mint != dst.mint || &src.mint != mint.key || &src.owner != auth.key { return Err(ProgramError::InvalidAccountData); } let ix = spl_token::instruction::transfer_checked( token_program.key, source.key, mint.key, dest.key, auth.key, &[], amount, decimals, )?; invoke_signed( &ix, &[source.clone(), mint.clone(), dest.clone(), auth.clone(), token_program.clone()], &[&[b"auth", &[bump]]], )}
What this demonstrates:
transfer_checked builds data + metas and asserts decimals on-chain.
Program id, owner, and mint checks run before the CPI.
Token-2022 is a separate program with a separate crate. Passing its program id to an spl_token::instruction::* builder fails at instruction-build time: those builders start with check_program_account(token_program_id)?, which returns IncorrectProgramId for anything that is not spl_token::ID. There is no "just swap the program account" path.
Use spl_token_2022::instruction::transfer_checked(...) for Token-2022 mints, or anchor_spl::token_interface (Anchor 0.32.1), which dispatches over both programs. Token-2022 also rejects the unchecked Transfer instruction on mints carrying a transfer-fee extension, so transfer_checked is the only portable choice.
Wrong token program id - Token-2022 vs classic. Fix: Validate the program account key and use the matching builder crate; spl_token builders reject the Token-2022 id.
Unchecked transfer - Deprecated, and Token-2022 rejects it on transfer-fee mints. Fix: Use transfer_checked; treat plain transfer as legacy only.
Mint mismatch - Drain wrong pool. Fix: Unpack and compare mint pubkeys.
Decimals confusion - Off-by 10^n. Fix: Use raw amounts consistently.
Stack versions: This page was written for Agave 4.1.1, Solana CLI 3.0.10, Anchor 0.32.1, anchor-lang 0.32.1, Rust 1.91.1, @solana/kit 7.0.0, Surfpool 0.12.0, and LiteSVM 0.6.x.
Reviewed by Chris St. John·Last updated Jul 16, 2026