Over five days on devnet I went from a basic mint to on-chain metadata, transfer fees enforced by the Token-2022 program, a full lifecycle run without notes, and a non-transferable credential token. The throughline: token behavior is configured at the mint, not patched in later.
This post is what I wish I had read before I started.
Starting point
I already had Solana accounts, wallets, and SOL transfers down. Tokens were the next layer.
The Token Extensions Program (Token-2022) attaches capabilities to the mint account at creation time. Transfer fees, metadata pointers, non-transferability — you opt in with CLI flags when you run create-token. You cannot add those extensions after the fact.
Plan the token's rules before you mint.
Token Program vs Token Extensions
Solana has two token programs:
| Program | Address (short) | What I used it for |
|---|---|---|
| Original SPL Token | TokenkegQfeZyiNw... |
Classic fungible tokens |
| Token Extensions (Token-2022) | TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb |
Metadata, transfer fees, non-transferable mints |
If you want extensions, opt in at mint creation:
--program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb
Lesson I learned the hard way: after creating a Token-2022 mint, pass --program-2022 (or --program-id TokenzQd...) on every follow-up spl-token command. I hit AccountInvalidOwner on initialize-metadata until I added that flag. The CLI defaults to the original Token Program unless you tell it otherwise.
Day 29–30: Mint and metadata
The first step is boring on purpose — create a mint, create an associated token account, mint supply:
solana config set --url devnet
solana balance
spl-token create-token --decimals 9
spl-token create-account <MINT>
spl-token mint <MINT> 1000
That gives you a fungible token. Wallets and explorers can show a balance, but without metadata it is just an address with decimals.
Token-2022 lets you attach identity on-chain:
spl-token create-token \
--program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb \
--enable-metadata \
--decimals 9
spl-token initialize-metadata <MINT> "MyToken" "MTK" \
"https://example.com/metadata.json" \
--program-2022
Name, symbol, and URI live in the mint's extension data. Explorers and wallets read them directly from chain state.
Order matters: initialize metadata before you mint large supply if your workflow depends on a fully configured mint from the start.
Day 31: Transfer fees at the protocol level
This is where Token Extensions stopped feeling like "SPL Token plus extras" and started feeling like a different design surface.
I created ReinforceCoin (RFC) with a 2% transfer fee (200 basis points):
spl-token create-token \
--program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb \
--transfer-fee-basis-points 200 \
--transfer-fee-maximum-fee 5000 \
--enable-metadata \
--decimals 9
Mint: 8rVUkh7tbh1uR9GMbAZYZeFQ3EZ8Tkqu5nh3kToSAZ7a
After metadata, I minted 1000 tokens to my wallet, created a token account for a second devnet wallet (second-wallet.json), and transferred 100 tokens:
spl-token transfer --fund-recipient <MINT> 100 <RECIPIENT_PUBKEY> \
--expected-fee 2 \
--allow-unfunded-recipient \
--program-2022
The recipient balance was 98, not 100. Two tokens were withheld as fees — enforced by the Token-2022 program during TransferChecked, not by anything I wrote in a script.
Every transfer through this mint pays the same fee. Wallets, DEXs, and CLI tools all hit the same on-chain rule.
To collect withheld fees, the mint authority withdraws from the recipient's token account:
spl-token withdraw-withheld-tokens <MY_TOKEN_ACCOUNT> <RECIPIENT_TOKEN_ACCOUNT> \
--program-2022
Fees accrue on transfer and get collected in a separate step. That two-phase flow was not obvious from the docs alone.
Day 32: Reinforcing the full lifecycle
Day 32 was deliberately repetitive: run the entire flow in one sitting without notes. Create mint → metadata → mint supply → fund recipient → transfer with fee → withdraw withheld → verify with spl-token display.
Muscle memory, not trivia.
The commands that stuck:
-
spl-token display <MINT> --program-2022— extensions, fee config, authority -
--expected-feeon transfer — CLI sanity-checks your math against on-chain rules - Token account addresses vs mint addresses — see below
Day 33: Non-transferable tokens (soulbound credentials)
For credentials, diplomas, or achievement badges, you want the chain to reject transfers — not a UI warning.
spl-token create-token \
--program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb \
--enable-non-transferable
Mint: Gvztbeuf4Szs9KDvXxHkzRGPEsnf895FozQPoTnUPipu
I minted 10 tokens to my ATA (5abnDEFHy5Btm2kyazagTaiaQaXUv4bStZusw69jndS3) and tried to send 5 to an experiment wallet. The transaction failed in simulation:
Program log: Transfer is disabled for this mint
custom program error: 0x25
That failure was success. The Token-2022 program enforces non-transferability — no workaround at the client level.
Burning still worked. I reduced my balance from 10 to 7 by burning from the token account, not the mint:
# Wrong — mint address is not a token account
spl-token burn Gvztbeuf4Szs9KDvXxHkzRGPEsnf895FozQPoTnUPipu 3 ...
# Right — pass the ATA that holds your balance
spl-token burn 5abnDEFHy5Btm2kyazagTaiaQaXUv4bStZusw69jndS3 3 \
--program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb
| Address | Role |
|---|---|
Gvztbeuf... |
Mint — token definition |
5abnDEFH... |
My associated token account — holds balance |
5K6a2t28... |
Experiment wallet's token account |
Confusing mint with a token account is an easy mistake to make. The CLI error messages nudge you toward the right object, but knowing the account model saves time.
What surprised me
Rules are chosen at birth. Extensions are set when the mint is created. Plan the economics before you mint.
Fees are withheld, not instantly routed. Transfer fees sit in recipient token accounts until someone with authority runs
withdraw-withheld-tokens.Non-transferable does not mean frozen. I can still mint and burn. I just cannot move balances between owners — which is exactly what you want for credentials.
The
--program-2022flag is load-bearing. Same CLI, different program owner. Forgetting the flag produces errors that look like corruption but are just wrong program IDs.Write down addresses as you go. Mint, your ATA, recipient ATA — each step prints new ones. I lost time re-deriving them from transaction logs.
Useful references while you work: Tokens on Solana, Token Extensions, and the SPL Token CLI.
Top comments (0)