All articles
solanaspl-tokentoken-2022extensions

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

typescript
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())
// → TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb

Different 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:

rust
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 bytes

And the same 165-byte token account:

rust
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 bytes

Anything 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:

text
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

typescript
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:

text
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
  ScaledUiAmountExtension

The 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).

typescript
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

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.