DEV Community

Prasiddh Naik
Prasiddh Naik

Posted on

Solana Token Extensions: Fees and Metadata

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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-fee on 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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

  1. Rules are chosen at birth. Extensions are set when the mint is created. Plan the economics before you mint.

  2. Fees are withheld, not instantly routed. Transfer fees sit in recipient token accounts until someone with authority runs withdraw-withheld-tokens.

  3. 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.

  4. The --program-2022 flag is load-bearing. Same CLI, different program owner. Forgetting the flag produces errors that look like corruption but are just wrong program IDs.

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