Solana Token-2022: transfer hooks and fee-on-transfer

Solana Token-2022 adds extensibility to SPL tokens, but not all extensibility lives at the same layer.
Two features are often discussed together: the fee-on-transfer extension and transfer hooks. Both are triggered during a token transfer, and both influence what happens when value moves between accounts.
This similarity is misleading.
These features exist at different layers of the system, solve different classes of problems, and are subject to very different execution constraints. Treating them as interchangeable leads to designs that work in theory, pass simulation, and then fail at runtime.
In this post, we’ll start with the fee-on-transfer extension, how it works and why it exists. Then move to transfer hooks, and finally compare what is and is not possible with each.
Fee-on-transfer extension: Defining token economics
When people hear “fee on transfer,” they usually imagine a separate program that runs during a transfer and skims a percentage to a treasury.
Token-2022 does it differently.
The Fee-on-Transfer extension makes the fee part of the token program’s native transfer logic. In other words, it changes what a “transfer” means for that mint, at the protocol level.
What it is
A Token-2022 mint can be configured with:
- a fee rate (basis points), and
- a maximum fee cap (so big transfers don’t create absurd fees).
Once enabled, every transfer of that mint automatically applies the fee inside the Token-2022 program.
What actually happens during a transfer
Suppose Alice sends Bob 100 tokens and the mint charges a fee.
At execution time, Token-2022:
- calculates the fee from the mint’s fee config,
- deducts it from the transferred amount, and
- withholds the fee rather than immediately paying it out to an arbitrary address.
That “withheld” detail matters a lot.
Where the fees go
With transfer fees enabled, the fee amount is tracked in Solana Token-2022 extension state (commonly recorded on the recipient token account via the transfer-fee amount extension). The recipient can’t spend those withheld tokens as part of their normal balance.
Later, a withdraw authority (configured on the mint) can withdraw accumulated withheld fees.
There’s also a practical mechanism to harvest withheld fees from many token accounts into the mint before withdrawing, which matters when a token has lots of holders.
Why this extension exists
This extension exists because “transfer tax” is not just extra logic it is token economics:
- It changes how much value moves in the transfer.
- It must be deterministic (same inputs → same result).
- It must be enforced atomically by the token program, without relying on external programs, CPIs, wallets, or simulation quirks.
So you can think of it like this:
Fee-on-transfer is not a hook.
It’s a rule baked into the token program’s definition of a transfer.
Example
lets set the environment
solana config set --url devnetCreate two wallets (sender + recipient) and fund them
solana-keygen new --no-bip39-passphrase -o ./alice.json
solana-keygen new --no-bip39-passphrase -o ./bob.json
solana airdrop 1 -k ./alice.json
solana airdrop 1 -k ./bob.jsonCreate a Solana Token-2022 mint with transfer fees:
Important:
spl-tokenflag names can vary by version. First, confirm what your CLI supports:spl-token create-token --help | grep -i feeMost recent CLIs support enabling fees at mint creation using the flags for:
--transfer-fee-basis-points--transfer-fee-maximum-fee
set the primary private key
solana config set --keypair ./alice.jsonExample: 1% fee, max fee 5 tokens, 0 decimals (so it’s easy to see):
MINT=$(spl-token create-token \
--program-2022 \
--decimals 0 \
--transfer-fee-basis-points 100 \
--transfer-fee-maximum-fee 5 \
| awk '/Creating token/ {print $3}')
echo "MINT=$MINT"
# Output:
# MINT=9ncw8asA7P79w7vJ3XbUFWW4EKy7cSYRVdhp9xjXb9cfOn explorer it will look like this:

Note: Max fee is set to 5 means that the fee will never take more than 5 tokens
create Alice’s token account:
ALICE_ATA=$(spl-token create-account $MINT --program-2022 | awk '/Creating account/ {print $3}')
echo "ALICE_ATA=$ALICE_ATA"
# Output:
# ALICE_ATA=J9V83VXc4H1LcCHu6DjxKLn6tkem5Se4ZYiybLnqoG35On explorer it will look like this:

create Bob’s token account
# switch to bob
solana config set --keypair ./bob.json
BOB_ATA=$(spl-token create-account $MINT --program-2022 | awk '/Creating account/ {print $3}')
echo "BOB_ATA=$BOB_ATA"
# Output:
# BOB_ATA=G5DjAMaTSrTajDK3Y9EW1dwSsXz6SUWQdvpu9Ft3CNif
# Then switch back to Alice for minting/transferring:
solana config set --keypair ./alice.json On explorer it will look like this:

After you have both ATAs lets mint and transfer:
# Alice mints to herself
spl-token mint $MINT 1000 $ALICE_ATA --program-2022
# Alice transfers to Bob
spl-token transfer $MINT 100 $BOB_PUBKEY \
--from $ALICE_ATA \
--fund-recipient \
--program-2022
lets check the balance of Bob to see how much he got
spl-token balance $MINT \
--owner $BOB_PUBKEY \
--program-2022
# Output:
# 99On explorer it will look like this:

Transfer hooks
If Fee-on-Transfer is a built-in “tax law” of the token program, a Transfer Hook is a “custom security checkpoint.” It allows a token creator to mandate that an external, custom program must approve every single transfer before it can finalize.
What it is
The Transfer Hook extension allows a Mint to specify a Program ID that must be called during any transfer. This essentially turns a standard token into a programmable asset.
When a transfer is initiated:
- Solana Token-2022 sees the hook extension on the Mint.
- It pauses the transfer logic and sends a Cross-Program Invocation (CPI) to your custom program.
- Your hook program runs its logic and returns either a “Success” or an “Error”.
Why this is a game-changer
Before Token-2022, if you wanted to enforce custom logic you had to “wrap” the token in a custom contract or “freeze” it and only “unfreeze” it via a proxy program. This destroyed composability, wallets and DEXs didn’t know how to talk to your proxy.
Hooks fix this by putting the logic directly into the standard transfer flow.
Execution-Time Side Effects
A Transfer Hook is Solana Token-2022’s way to run your program during a token transfer. But the key detail is who initiates it:
The user does not call your hook program. The Token-2022 program calls it as part of processing a transfer.
So instead of “a token transfer plus my callback”, the mental model is:
A token transfer is a pipeline, and one stage of that pipeline can invoke your program.
What “hooked” actually means
When a mint has the transfer-hook extension enabled, Token-2022 stores:
- the hook program id
- and a configuration describing extra accounts the hook will need
Then, whenever someone transfers that mint, Token-2022 will:
- validate the transfer
- perform its internal extension logic
- invoke the hook program (CPI) with the transfer context
This invocation is automatic and deterministic. If the hook fails, the transfer fails.
Why hooks exist
Transfer hooks are not meant to redefine the economics of the token (that’s what fee-on-transfer is for). Hooks exist for things that are inherently conditional and policy-driven, like:
Gating / compliance:
- allowlist / blocklist
- KYC / region gating
- “only transfers to approved destinations”
- “only transfers above/below a threshold”
Runtime enforcement
- pause transfers based on some on-chain flag
- rate-limit transfers
- require “membership” / “badge” / “NFT ownership” to receive
The common theme:
Note: Hooks let you add policy and stateful side effects to transfers.
The most important constraint
When your hook runs, it’s not a top-level instruction. It’s running inside the Token-2022 transfer instruction. That means two consequences that matter a lot:
1) No privilege escalation
Accounts passed into your hook have fixed privileges (read-only / writable, signer / non-signer). Your program cannot “upgrade” an account’s permissions mid-flight.
If Solana Token-2022 passed an account as read-only, your hook cannot treat it as writable.
2) The hook cannot change what the transfer is
A hook can:
- validate
- reject
- record
- perform side effects on explicitly provided accounts
But it cannot retroactively redefine the transfer semantics the way a token-program-level extension can. This is exactly why “take a fee in the same token inside the hook” fails in practice.
Why hooks cannot implement token-level fees
After setting up a hook, the first instinct for many developers is to add a “tax” or “royalty” logic. However, if you try to make your hook move tokens to a treasury during a transfer, you will hit two fundamental architectural walls.
1. The “Read-Only” Wall
When the Token-2022 program invokes your hook, it provides the source and destination token accounts. However, for security reasons, these accounts are passed to your hook as read-only.
- The Constraint: You cannot modify the data or the balance of a read-only account.
- The Result: Since your hook cannot “write” to the balances, it cannot deduct a fee.
2. The Re-entrancy Lock
You might think: “I’ll just issue a CPI (Cross-Program Invocation) from my hook back to Token-2022 to move the 1% fee.”
- The Constraint: Solana prohibits “re-entrancy.” You cannot call the Token-2022 program to move a token while that same Token-2022 program is currently suspended, waiting for your hook to finish.
- The Result: The transaction will fail immediately.
Note: In next blog post we will write code and see this in practice
What is possible with each mechanism
The core difference lies in state mutation (writing) versus state validation (reading).
Modifying the transfer amount
- Fee-on-Transfer: Yes. Because this logic lives inside the Token-2022 program, it has the authority to split the transfer amount. If Alice sends 100, the program physically writes 99 to Bob and 1 to a withheld bucket.
- Transfer Hooks: No. Your hook program is a guest. The accounts it receives are restricted by the caller (Token-2022). To prevent a hook from “stealing” tokens, the runtime passes the source and destination accounts as Read-Only. You can see the 100 tokens, but you cannot change that number to 99.
Gating by identity or metadata
- Fee-on-Transfer: No. This extension is “blind.” It applies the same math to every transfer regardless of who is involved. You cannot say “charge 1% except for these five wallets” using only this extension.
- Transfer Hooks: Yes. This is the hook’s superpower. Because the hook is a custom program, it can look at a
MemberAccountPDA or an externalRegistryto see if Alice or Bob are authorized. If they aren’t, it returns an error and the whole transfer is aborted.
Side-effects in external programs
- Fee-on-Transfer: No. It does one thing: math. It cannot ping a different program to update a “Points” balance or log a trade on an external leaderboard.
- Transfer Hooks: Yes. Your hook can be passed writable extra accounts. For example, you could pass a
UserStatsPDA. Every time a user transfers tokens, your hook could increment a “Total Volume” counter on that PDA.
Choosing the correct layer
The decision tree is simple:
- Does it change the math of the transfer? Use Fee-on-Transfer. It is the only way to ensure the fee is deducted atomically and safely at the protocol level.
- Does it change the permission of the transfer? Use Transfer Hooks. It is the only way to check external state (like a denylist PDA or an NFT) to decide if a transfer should be allowed to happen at all.
Summary
Solana Token-2022 isn’t just about “more features”, it’s about putting those features at the correct layer of the stack.
- Fee-on-Transfer handles the the math. It ensures your token economics are immutable, deterministic, and enforced natively.
- Transfer Hooks handle the the policy. They turn your token into a programmable asset that can react to the world around it.
Coming up next: In the next blog post, we’re going to get our hands dirty. We’ll write a real Transfer Hook in Anchor, deploy it, and see exactly how to pair it with a Fee-on-Transfer mint to build a fully regulated token economy.
Learn more about Solana architecture from our articles
- Solana Token-2022 Metadata
- Where token metadata lives on Solana
- SPL Token Program Architecture
- Architecture & Parallel Transactions
- Account Model and Transactions
- Anchor Accounts: Seeds, Bumps, PDAs
- Instructions and Messages
- Transaction, Serialization, Signatures, Fees, and Runtime Execution
Reliable Solana RPC infrastructure
Getting started with Solana on Chainstack is fast and straightforward. Developers can deploy a reliable Solana node within seconds through an intuitive Console — no complex setup or hardware management required.
Chainstack provides low-latency Solana RPC access and real-time gRPC data streaming via Yellowstone Geyser Plugin, ensuring seamless connectivity for building, testing, and scaling DeFi, analytics, and trading applications. With Solana low-latency endpoints powered by global infrastructure, you can achieve lightning-fast response times and consistent performance across regions.
Start for free, connect your app to a reliable Solana RPC endpoint, and experience how easy it is to build and scale on Solana with Chainstack – one of the best RPC providers.





