If you have never touched Solana before, here is the one thing you need to know going in: Token-2022 is the upgraded SPL token program, and extensions are how it works. Instead of writing custom smart contract logic to enforce a royalty fee or an interest rate, you flip a flag when you create the mint. The rule travels with the asset, enforced by the protocol itself, visible to every wallet and program that touches it. Think of it as middleware baked directly into the currency, not bolted on next to it.
Over the last week I shipped three distinct mints to devnet. Each one adds a different extension. Here is what I built, what I ran, and what happened.
Mint 1 — Transfer fee (Days 50–51)
Mint address: G37ZvuZ9wQRnagSDGHb985qqaTsWWxPeuZPPE951LNZ9
View on Solana Explorer
Extension: TransferFeeConfig — 100 basis points (1%), maximum fee 1,000,000 tokens
bashspl-token --program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb \
create-token \
--transfer-fee-basis-points 100 \
--transfer-fee-maximum-fee 10000000 \
--decimals 6
When would you actually reach for this? Think protocol treasury skims on a stablecoin, royalties on a creator token, or a community currency that automatically funds a DAO wallet on every trade. The fee is not enforced by your API or your database — it is enforced by the validator. There is no way to route around it.
The lifecycle I tested: mint supply → transfer 1,000 tokens to a fresh wallet → check the recipient's account for the Transfer fees withheld field → withdraw those withheld fees back to my own account. The whole loop, no middleware, no webhook.
Mint 2 — Interest-bearing stacked on transfer fee (Day 52)
Mint address: F15ZkWji8PsQM7VrgnRNLUh1n8W8CQJp71TbDkSUsi2a
View on Solana Explorer
Extensions: InterestBearingConfig (5,000 bps / 50% APR) + TransferFeeConfig (100 bps, same as above)
bashspl-token create-token \
--program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb \
--decimals 6 \
--transfer-fee-basis-points 100 \
--transfer-fee-maximum-fee 1000000 \
--interest-rate 5000
One thing I want to be honest about here, because it confused me at first: the interest-bearing extension does not mint new supply. It adjusts the UI amount — the number your wallet shows you — based on elapsed time and the configured rate. The raw on-chain balance stays the same. I waited 30 seconds between two spl-token accounts calls and watched the displayed number tick up from 1000004.816733 to 1000007.985649 with no transactions in between. That is the extension doing the compounding math. If you are building a lending protocol or a synthetic yield token, you need to understand this distinction before you go anywhere near production.
The spl-token display output on this mint showed both extensions side by side in the same TLV blob — Interest-bearing at 5,000 bps and Transfer fees at 100 bps. Two behaviors, one create-token invocation.
Mint 3 — Non-transferable / soul-bound (Day 54)
Mint address: 7u4f9idiq865z2pYndPsPjpiQN95p4uh7fHdTgKkvWgE
View on Solana Explorer
Extension: NonTransferable
bashspl-token create-token --program-2022 --enable-non-transferable
This one is different in kind. The first two extensions are quantitative — they layer financial behaviors onto a currency primitive. Non-transferable is qualitative. It turns the same mint, the same accounts, the same CLI into an identity object. The closest Web2 analogy is a certificate of completion stapled to a profile page that no one can detach or resell.
I minted one token to myself, generated a throwaway keypair as a recipient, pre-funded their token account so the transfer could actually reach the program, then ran the transfer. Here is what came back:
Error: Client(Error { kind: RpcError(RpcResponseError {
message: "Transaction simulation failed: Error processing Instruction 0:
custom program error: 0x25" ...
logs: ["Program log: Instruction: TransferChecked",
"Program log: Transfer is disabled for this mint", ...
Transfer is disabled for this mint. The rejection came from the Token-2022 program itself, not from my application layer. In Web2 you might enforce this with a database constraint or an API check — but anyone who talks to the database around your application can break that rule. Here the rule lives on the asset, inside the program, inside the validator. There is no around.
Running spl-token display on the mint confirmed it: the Extensions block shows Non-transferable with nothing else needed.
What surprised me
I expected extensions to feel like configuration knobs bolted onto a normal token. They do not. They feel like the token is the configuration. The non-transferable experiment especially landed differently than I expected — seeing the validator reject a transfer at the protocol level, not at the app layer, is the moment the mental model clicks. The error message is the feature.
If I were building something real today, I would reach for transfer fees for any protocol where the treasury needs to be funded trustless, and for non-transferable for anything credential-shaped: completion badges, access passes, reputation tokens that should not be liquid. The interest-bearing extension is the most nuanced of the three — powerful for display purposes, but you need to understand what it is and is not doing to use it safely.












