If you have built middleware in Web2, you already understand Token-2022 extensions.
The old SPL token program is like a plain Express router. Token-2022 is the same
router with a plugin system baked in. You opt a single mint into behaviors — fees,
interest, transfer locks — at creation time, and the protocol enforces them forever.
No forking, no custom Rust, no deploying your own program. Just flags.
I spent Days 50–54 of my #100DaysOfSolana challenge shipping three mints that each
demonstrate one of those behaviors. Here is what I built, the exact commands I ran,
and when you would actually reach for each extension.
Mint 1 — Transfer Fee (Days 50 & 51)
Mint: HxDYFvcXnLuy4VdxXCooUXrch8DZW34oUteQ6N2EFxEr
Explorer: View on Devnet
Extension: TransferFeeConfig
spl-token create-token
--program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb
--decimals 6
--transfer-fee-basis-points 100
--transfer-fee-maximum-fee 1000000
--transfer-fee-basis-points 100 means 1% of every transfer is withheld before
the recipient gets credited. The withheld amount sits in the recipient’s token
account until a privileged instruction sweeps it to the treasury.
When would you use this?
Creator royalties on a community token. A protocol fee on a stablecoin. A DAO
treasury skim that funds development every time the token changes hands. The key
insight: the fee is enforced by the Token-2022 program itself, not by your API.
Nobody can route around it by calling the program directly.
On Day 51 I ran the full lifecycle — transfer, inspect the withheld amount, then
sweep it back:
spl-token transfer $MINT 1000 $RECIPIENT --expected-fee 10
spl-token withdraw-withheld-tokens $MY_TA $RECIPIENT_TA
The --expected-fee 10 flag is a safety assertion. If the mint’s fee math
doesn’t produce exactly 10 tokens withheld, the instruction aborts. It is the
on-chain equivalent of an idempotency check.
Mint 2 — Transfer Fee + Interest Stacked (Day 52)
Mint: A6TAeNgxBVwYna8NqQVmBpQzjVYKoZA3e68yMvoVVUva
Explorer: View on Devnet
Extensions: TransferFeeConfig + InterestBearingConfig
spl-token create-token
--program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb
--decimals 6
--transfer-fee-basis-points 100
--transfer-fee-maximum-fee 1000000
--interest-rate 5000
One command, two TLV entries, two completely different mechanics.
What the interest extension does — and does NOT do:
This is the subtlety most tutorials skip. The InterestBearingConfig extension
does not mint new tokens. The raw amount stored on-chain never changes between
transactions. What changes is the UI amount — the number your wallet displays.
The formula is: UI amount = raw_amount × e^(rate × time_elapsed)
The network clock and the rate stored on the mint are all the CLI needs to compute
a growing display number every time you query. I proved this by reading the balance
twice with a 30-second sleep between them, with zero transactions in between:
spl-token accounts $MINT --verbose | awk 'NR==3'
sleep 30
spl-token accounts $MINT --verbose | awk 'NR==3'
# Output:
# 999032.271358
# 999032.762062 ← +0.49 tokens, no transaction fired
Think of it like a savings account display ticker. The number on screen grows.
The ledger entry does not change until a real transaction touches it.
Two extensions, zero conflict: TransferFeeConfig operates on raw amounts at
transfer time. InterestBearingConfig operates on the display layer at query time.
They are orthogonal, which is why they compose cleanly on a single mint.
Mint 3 — Non-Transferable / Soul-Bound (Day 54)
Mint: BQzJeZVZgkSvAYPj9f1M1apv56P7kggAgPNc9uSq7T5m
Explorer: View on Devnet
Extension: Non-transferable
spl-token create-token --program-2022 --enable-non-transferable
I minted one badge token to myself, then deliberately tried to send it to a throwaway wallet. Here is the exact runtime rejection:
Program log: Instruction: TransferChecked
Program log: Transfer is disabled for this mint
Program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb failed: custom program error: 0x25
Error 0x25 is decimal 37, the NonTransferable entry in the Token-2022 error
enum. The rejection came from the validator, not from the CLI or the RPC layer.
There is no way to call the program “around” the extension. The rule lives on the
asset.
spl-token display $MINT
# Extensions
# Non-transferable
When would you use this?
Completion certificates. Event attendance proofs. KYC credentials tied to a
specific wallet. Anything where the point is that the credential belongs to the
holder and cannot be resold or delegated.
In Web2 you would enforce this with a database constraint or an API check. The
risk is that anyone who can reach the database directly can break the rule. On
Solana the rule is in the program. The program is in the validator. There is no
around.
What surprised me
I expected the stacking to be complicated. It wasn’t. Two flags at creation time,
two TLV entries in the same byte buffer, and the CLI just prints both in the
Extensions block of spl-token display. The Token-2022 design is genuinely
composable in a way that feels like it was designed by someone who got burned by
non-composable systems before.
The interest extension surprised me most. I came in expecting it to mint tokens.
It doesn’t. It’s a view function disguised as a balance. Once I understood that,
I stopped thinking of it as a financial primitive and started thinking of it as
a display configuration. That reframe changed how I’d use it in a real product —
probably for a points or loyalty system where the raw backing supply is fixed but
the displayed “value” grows over time.
If I were building a real product today, I’d reach for TransferFeeConfig for any
token that needs sustainable protocol revenue, and NonTransferable for any
credential or badge that should be identity-bound. The interest extension I’d hold
for a specific display-layer use case where the raw supply needs to stay auditable
but the user-facing number should grow.
All code and terminal output is in my public build log:
👉 https://github.com/gopichandchalla16/100-days-of-solana
Day 55 of #100DaysOfSolana