DEV Community

Cover image for 5 Token-2022 Mistakes I Made So You Don't Have To
Lymah
Lymah Subscriber

Posted on

5 Token-2022 Mistakes I Made So You Don't Have To

I spent two weeks building with Solana's Token-2022 program. I created fungible tokens, attached transfer fees, stacked interest on top, built
soulbound credentials, and minted NFTs with on-chain metadata. Every one of those features worked — eventually. But before "eventually," each one had a moment where my output was wrong, my transaction failed, or my wallet balance moved in a direction I didn't expect.

This post is about those moments. Not the happy path. The five mistakes
that cost me the most time, and exactly what I had to understand to fix
them.

If you're coming from Web2 and you're curious about Solana tokens, this
is the post I wish I'd had.


A Quick Mental Model Before We Start

On Solana, a token isn't a smart contract. It's a mint account — a small on-chain record that stores:

  • Total supply
  • Decimal precision
  • Who's allowed to create more tokens (the mint authority)

The Token-2022 program (also called Token Extensions) is the
upgraded version that lets you bolt additional behaviours onto a mint
at creation time. Things like automatic transfer fees, interest accrual,
and transfer restrictions.

The catch: extensions are declared at mint creation and cannot be
added later
. This is Mistake #3, and I'll come back to it. First,
the one that burned me the most.


Mistake 1: The Base Units Trap

When you create a Token-2022 mint with a transfer fee, you configure
two numbers: the fee rate in basis points, and a maximum fee cap.

The CLI command looks like this:

spl-token create-token \
  --program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb \
  --transfer-fee-basis-points 100 \
  --transfer-fee-maximum-fee 5000 \
  --decimals 9
Enter fullscreen mode Exit fullscreen mode

I ran this. I transferred 100 tokens at 1%. I expected 1 token to be
withheld as a fee. Instead, the recipient received 99.999995 tokens.
The withheld amount was 0.000005 — basically nothing.

What went wrong: --transfer-fee-maximum-fee 5000 is in base
units
, not whole tokens. With 9 decimals, 5000 base units equals
0.000005 whole tokens. The cap kicked in immediately and capped
every fee at a fraction of a cent.

The fix: Multiply by 10 ** decimals:

// Wrong
const MAX_FEE = BigInt(5000);

// Right — 5000 whole tokens as the cap
const MAX_FEE = BigInt(5000 * 10 ** DECIMALS);
Enter fullscreen mode Exit fullscreen mode

Visual proof of the bug

This is the most common Token-2022 gotcha. Every numeric parameter
involving token amounts is in base units. The CLI doesn't warn you.
The transaction succeeds. The number is just wrong.

Shows the fix working

and Withheld: 1 tokens

Web2 equivalent: Imagine a payment processor that charges fees in
cents but you accidentally configured it in mills (1/10 of a cent).
Everything "works" — the fee is just 10x smaller than you intended,
and you only notice when you check the receipts.


Mistake 2: The Instruction Order Problem

When creating a Token-2022 mint with extensions programmatically, you
build a transaction with multiple instructions. The order matters — a lot.

Here's the wrong order:

const tx = new Transaction().add(
  SystemProgram.createAccount({ ... }),
  createInitializeMintInstruction(...),     // ← WRONG: mint before extensions
  createInitializeTransferFeeConfigInstruction(...),
  createInitializeMetadataPointerInstruction(...),
);
Enter fullscreen mode Exit fullscreen mode

This produces a cryptic error:

Error: Transaction simulation failed:
Error processing Instruction 2: invalid account data for instruction
Enter fullscreen mode Exit fullscreen mode

The error points at instruction 2 (the transfer fee config), but the
real problem is instruction 1 (the mint initialization). The mint is
being initialized before the extension space is configured, so the
runtime finds data in unexpected places.

The fix: Extension initializers must come before
createInitializeMintInstruction:

const tx = new Transaction().add(
  SystemProgram.createAccount({ ... }),
  createInitializeMetadataPointerInstruction(...),   // extensions first
  createInitializeTransferFeeConfigInstruction(...), // extensions first
  createInitializeMintInstruction(...),              // mint LAST
  createInitializeInstruction({ ... }),              // metadata after mint
);
Enter fullscreen mode Exit fullscreen mode

The mental model: extensions configure the account's memory layout.
The mint instruction writes into that layout. If you write before you
configure, you're writing into the wrong shape.


Mistake 3: Extensions Are Immutable After Creation

Coming from Web2, I expected to be able to add features to a token
after deploying it. In Rails you'd add a column with a migration.
In Postgres you'd ALTER TABLE. On Token-2022, you can't.

Extensions are baked into the mint account when it's created.
The getMintLen() function calculates how many bytes to allocate
based on which extensions you declare. Once the account is allocated
and the mint is initialized, that's the shape it has forever.

I discovered this when I created a plain mint and then tried to add
a transfer fee to it. There's no instruction for that. I had to
create a new mint.

// This is everything you can do with a mint you forgot to add
// extensions to: nothing. You create a new one.
const extensions = [
  ExtensionType.TransferFeeConfig,  // must declare ALL of these
  ExtensionType.InterestBearingConfig, // at creation time
  ExtensionType.MetadataPointer,    // you can't add them later
];
const mintLen = getMintLen(extensions);
Enter fullscreen mode Exit fullscreen mode

The practical consequence: Design your token before you deploy it.
Write down every extension you think you'll need. Add one or two you
might need later. The cost of over-allocating is a slightly larger
rent-exempt deposit. The cost of under-allocating is creating a new
mint and migrating all your holders.


Mistake 4: Interest Is a View, Not a Balance

This one is subtle and it confused me for longer than I'd like to admit.

The Token-2022 InterestBearingConfig extension stores an APR on the
mint. As time passes, wallets and explorers show a growing balance.
I assumed this meant new tokens were being minted. It doesn't.

The raw on-chain balance never changes. What changes is the displayed
amount — a formula applied on the fly:

UI Amount = raw_balance × e^(rate × elapsed_years)
Enter fullscreen mode Exit fullscreen mode

I proved this by snapshotting the balance twice, 30 seconds apart,
with no transactions in between:

Snapshot 1: 1,000,000.069191 tokens
Snapshot 2: 1,000,000.544703 tokens
Growth:     +0.475512 tokens in 30s
Raw balance: 1,000,000,000,000 base units (unchanged)
Enter fullscreen mode Exit fullscreen mode

Day 52 snapshots with no tx

No transaction ran. The number grew because the display formula grew,
not because any state changed.

Day 52 Mint

Why this matters: Transfer fees operate on the raw balance. Interest display operates on the raw balance through a formula. They're completely independent. If you transfer tokens, the fee calculation uses the raw amount, not the interest-adjusted display amount. This is correct behavior, but if you're building a UI, you need to know which number to show and which to calculate with.

Web2 equivalent: A savings account balance display that multiplies
the raw database value by an interest factor before rendering. The
database row doesn't change until you explicitly trigger a settlement.


Mistake 5: The Two-Layer Image Cache Problem

When I updated my NFT's metadata URI to point at a new JSON file, the
name changed immediately in Solana Explorer. The image took over an
hour to update in wallets.

These are two completely different layers:

Layer Where it lives Update speed
Name, symbol, URI On-chain (mint account) Instant — one transaction
Image, attributes Off-chain (JSON at URI) Depends on wallet cache

The on-chain URI is a pointer. The image it points at lives on an HTTP
server. Wallets fetch that image when they first index the NFT and
cache it aggressively — sometimes for hours, sometimes for days.

When I ran this:

// Update the on-chain URI — instant
await sendAndConfirmTransaction(connection, new Transaction().add(
  createUpdateFieldInstruction({
    programId: TOKEN_2022_PROGRAM_ID,
    metadata: mint,
    updateAuthority: wallet.publicKey,
    field: "uri",
    value: "https://new-metadata.json",
  })
), [wallet]);
Enter fullscreen mode Exit fullscreen mode

Shows on-chain instant update

The URI changed on-chain immediately. But the wallet kept showing the
old image because it had cached the previous fetch.

The fix for production: Host your metadata and images on Arweave
or IPFS with content-addressed URLs. Since the content hash is part
of the URL, a changed image gets a new URL, which forces a fresh fetch.
A mutable HTTP URL (like a GitHub Gist) means wallets might show stale
content indefinitely.

This is why serious NFT projects pay for permanent storage. The
on-chain pointer is permanent. The thing it points at should be too.


What I'd Tell Myself at the Start

  1. Always multiply by 10 ** decimals. Every time. Without exception.

  2. Extension initializers come before createInitializeMintInstruction. Burn this into your muscle memory.

  3. Design your token schema before you create the mint. You can't migrate. You can only start over.

  4. Interest is a display formula, not a ledger update. Raw balance is truth. UI amount is interpretation.

  5. On-chain metadata updates instantly. Off-chain images don't. If you need fast image updates, use content-addressed storage.


What I Actually Built

Despite all of this, the extensions work beautifully once you
understand them. By the end of Epoch 2 I had:

  • A fungible token with 1% transfer fees, harvested and withdrawn
  • A stacked mint with both transfer fees and 50% APR interest
  • A soulbound credential token that the runtime physically refuses to transfer
  • An NFT with on-chain metadata, grouped into a collection
  • Live metadata mutation — renaming an NFT mid-life in one transaction

All of it built on Windows, without the spl-token CLI, using
Node.js and @solana/spl-token directly. The mistakes above are
the price of admission. The program is worth it.

Where to Go From Here

If you want to explore Token-2022 extensions yourself, the best
starting points are:

All of the code in this post was written in Node.js on Windows — no
CLI available, every extension built programmatically using
@solana/web3.js and @solana/spl-token. If I could do it without
the CLI, so can you.


This post is part of *#100DaysOfSolana*. I've been building on
Solana every day for over two months. If you're starting out, the
mistakes above will save you hours. If you're experienced and I got
something wrong, tell me in the comments — I'm still learning too.

Top comments (1)

Collapse
 
lymah profile image
Lymah

Built all of this on Windows without the spl-token CLI , every challenge solved programmatically in Node.js.
Happy to answer questions about any of the mistakes above