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
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);
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.
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(...),
);
This produces a cryptic error:
Error: Transaction simulation failed:
Error processing Instruction 2: invalid account data for instruction
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
);
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);
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)
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)
No transaction ran. The number grew because the display formula grew,
not because any state changed.
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]);
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
Always multiply by
10 ** decimals. Every time. Without exception.Extension initializers come before
createInitializeMintInstruction. Burn this into your muscle memory.Design your token schema before you create the mint. You can't migrate. You can only start over.
Interest is a display formula, not a ledger update. Raw balance is truth. UI amount is interpretation.
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:
- Token Extensions overview — the canonical reference
- Transfer Fee extension — with the base units trap explained
- Interest-Bearing extension — including the view vs balance distinction
- Token Metadata extension — on-chain vs off-chain metadata
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)
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