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.

Share
devrels.xyz/a/96short link

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.

SPL Token vs Token-2022: program IDs, account layout, and what changed

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.

Keep reading

Get new articles in your inbox

Technical deep-dives on Solana tooling, infrastructure, and ecosystem. No noise.

SPL Token vs Token-2022: program IDs, account layout, and what changed | devrels.xyz