SPL Token vs Token-2022: program IDs, account layout, and what changed
Two token programs on Solana. Same mental model, different program IDs, different byte layouts, plus Token-2022 extensions. Here's the diff that matters.
Solana has two SPL token programs in production. They look identical from a wallet's perspective — balances, transfers, approvals — and diverge in three concrete places: the program ID, the account layout (Token-2022 appends extensions), and a handful of new instructions that only exist on the newer one.
If you're writing code that touches tokens, you have to handle both. Here's the diff that matters.
The program IDs
import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from "@solana/spl-token"
console.log(TOKEN_PROGRAM_ID.toBase58())
// → TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
console.log(TOKEN_2022_PROGRAM_ID.toBase58())
// → TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEbDifferent program IDs, different deployed bytecode. A mint created with the old program is owned by the old program ID; a mint created with the new program is owned by the new program ID. You can't mix instructions between them — calling spl_token::instruction::transfer against a Token-2022 mint produces IncorrectProgramId.
The base mint layout
Both programs share the same 82-byte mint account schema:
pub struct Mint {
pub mint_authority: COption<Pubkey>, // 36 bytes (4 + 32)
pub supply: u64, // 8 bytes
pub decimals: u8, // 1 byte
pub is_initialized: bool, // 1 byte
pub freeze_authority: COption<Pubkey>, // 36 bytes (4 + 32)
}
// Total: 82 bytesAnd the same 165-byte token account:
pub struct Account {
pub mint: Pubkey, // 32
pub owner: Pubkey, // 32
pub amount: u64, // 8
pub delegate: COption<Pubkey>, // 36
pub state: AccountState, // 1
pub is_native: COption<u64>, // 12
pub delegated_amount:u64, // 8
pub close_authority: COption<Pubkey>, // 36
}
// Total: 165 bytesAnything that reads either of these structures from raw bytes works against both programs. The base offsets and field meanings are unchanged.
What Token-2022 adds
Token-2022 mints can be larger than 82 bytes — any bytes past offset 82 carry extension data in a TLV format:
Token-2022 mint layout:
[0..82] Base Mint (same as legacy)
[82..165] Padding to match Account size (so dynamic dispatch works)
[165] Account type byte (Mint = 1, Account = 2, Multisig = 3)
[166..] TLV: extension_type (u16) + length (u16) + payload, repeating
Token-2022 token account:
[0..165] Base Account
[165] Account type byte
[166..] TLV: account-side extensions (ImmutableOwner, CpiGuard, MemoTransfer)The padding at offset 82-165 is what makes the mint indistinguishable from an account by length alone — both data layouts are ≥165 bytes, so the program reads the account type byte at offset 165 to dispatch. Brilliant if you appreciate protocol design; annoying if you're hand-rolling a parser.
Detecting which program owns a mint
import { Connection, PublicKey } from "@solana/web3.js"
import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from "@solana/spl-token"
async function tokenProgramFor(conn: Connection, mint: PublicKey) {
const info = await conn.getAccountInfo(mint)
if (!info) throw new Error("mint not found")
if (info.owner.equals(TOKEN_PROGRAM_ID)) return TOKEN_PROGRAM_ID
if (info.owner.equals(TOKEN_2022_PROGRAM_ID)) return TOKEN_2022_PROGRAM_ID
throw new Error("not a recognised token mint")
}Always do this dynamically. Hardcoding TOKEN_PROGRAM_ID is a bug that only manifests when a new Token-2022 mint shows up in your flow.
The instruction set diff
Every instruction in the legacy program exists in Token-2022 with the same discriminator and shape. Token-2022 adds new instructions for managing extensions:
Shared (work on both programs):
InitializeMint, InitializeAccount, InitializeMultisig,
Transfer, Approve, Revoke,
SetAuthority, MintTo, Burn,
CloseAccount, FreezeAccount, ThawAccount,
TransferChecked, ApproveChecked, MintToChecked, BurnChecked,
SyncNative, InitializeAccount2, InitializeAccount3,
InitializeMint2, GetAccountDataSize, AmountToUiAmount,
UiAmountToAmount, InitializeMultisig2
Token-2022 only (extension-related):
InitializeMintCloseAuthority
TransferFeeExtension { Initialize, Transfer, WithdrawWithheld, HarvestWithheld, ... }
ConfidentialTransferExtension { Initialize, Deposit, Withdraw, Transfer, ... }
DefaultAccountStateExtension { Initialize, Update }
Reallocate // for account extensions
MemoTransferExtension { Enable, Disable }
CreateNativeMint
InitializeNonTransferableMint
InterestBearingMintExtension
CpiGuardExtension { Enable, Disable }
InitializePermanentDelegate
TransferHookExtension
MetadataPointerExtension + TokenMetadataInitialize/Update/Remove
GroupPointerExtension / GroupMemberPointerExtension
ConfidentialMintBurnExtension
ScaledUiAmountExtensionThe transferChecked rule
For Token-2022 mints, always use the *Checked variant (TransferChecked, BurnChecked, etc). The Checked variants pass the expected decimals as part of the instruction, which prevents certain extension-related footguns (interest-bearing mints with accrued balances, hooks that need decimal context, etc).
import {
createTransferCheckedInstruction,
createTransferCheckedWithTransferHookInstruction,
TOKEN_2022_PROGRAM_ID,
} from "@solana/spl-token"
// Basic Token-2022 transfer:
const ix = createTransferCheckedInstruction(
source, // ATA
mint,
dest,
authority,
amount, // bigint
decimals,
[], // multisig signers
TOKEN_2022_PROGRAM_ID,
)
// If the mint has TransferHook enabled, resolve the hook program + extra accounts:
const hookIx = await createTransferCheckedWithTransferHookInstruction(
connection, source, mint, dest, authority, amount, decimals,
[], "confirmed", TOKEN_2022_PROGRAM_ID,
)Migration: legacy → Token-2022
There's no "upgrade my mint to Token-2022" path. The two programs own different mints; to move a token to Token-2022 the issuer has to create a new mint with the new program, then either migrate users (mint new tokens 1:1 against burned legacy tokens) or run both in parallel.
For new tokens in 2026: start with Token-2022. You get the extensions framework for free, you're ready to add features later without a fork, and almost every wallet now handles both program IDs transparently.
References
- SPL Token docs
- Token-2022 docs
- Token-2022 extensions field guide — per-extension deep dive
Detect the program owner at runtime, use *Checked variants on Token-2022, and start new mints on Token-2022 unless you have a specific reason not to.