The original SPL Token program on Solana had no concept of transfer hooks. If you wanted custom logic on every token move (royalty enforcement, compliance checks, transfer fees), you had to build a wrapper program and convince every integration to call your program instead of the token program directly. That’s a hard sell.
Token Extensions (the Token-2022 program) changed this. One of the extensions is Transfer Hooks: you point your mint at a custom program, and the Token-2022 program CPIs into it on every transfer. If the hook rejects the transfer, the whole transaction fails atomically. Wallets, DEXs, and other programs don’t need to know your hook exists. They just call the normal transfer instruction and your code runs.
The three pieces
A working transfer hook setup has three components:
A mint with the transfer hook extension enabled. When you create the mint using TOKEN_2022_PROGRAM_ID, you initialize the transfer hook extension and specify which program should be invoked. This is set at mint creation and can be updated later by the hook authority (unlike Aptos, where it’s permanent).
A transfer hook program. This is a regular Solana program that implements the spl-transfer-hook-interface. At minimum, it needs an execute instruction that runs your custom logic.
An ExtraAccountMetaList PDA. This is the part that catches people off guard. Your hook program probably needs accounts beyond what the standard transfer instruction provides (your program’s state accounts, PDAs, other token accounts). The ExtraAccountMetaList is a PDA that tells the Token-2022 program which additional accounts to include when it CPIs into your hook. Seeds are ["extra-account-metas", mint_pubkey], derived from your hook program’s ID.
The execute function
In Anchor 0.30+, the execute function looks like this:
#[interface(spl_transfer_hook_interface::execute)]
pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {
// your logic here
Ok(())
} The #[interface(...)] attribute is doing something important: it overrides Anchor’s default instruction discriminator to match what the spl-transfer-hook-interface expects. Without this, the Token-2022 program’s CPI won’t find your instruction. More on this gotcha later.
The first five accounts are always the same, in this order:
- Source token account
- Mint
- Destination token account
- Source account owner
- ExtraAccountMetaList PDA
Anything your hook needs beyond these goes into the ExtraAccountMetaList, and the framework includes them automatically.
ExtraAccountMetas: the setup tax
The ExtraAccountMetaList is where Solana’s transfer hooks get more involved than you’d expect. You need to initialize this PDA before the first transfer happens, and it has to describe every additional account your hook will need.
You set it up through an initialize_extra_account_meta_list instruction in your hook program:
pub fn initialize_extra_account_meta_list(
ctx: Context<InitializeExtraAccountMetaList>,
) -> Result<()> {
let account_metas = vec![
// a fixed address (like wrapped SOL mint)
ExtraAccountMeta::new_with_pubkey(&wsol_mint::ID, false, false)?,
// a PDA from your own program
ExtraAccountMeta::new_with_seeds(
&[Seed::Literal { bytes: b"config".to_vec() }],
false, // is_signer
false, // is_writable
)?,
// an external PDA (like an associated token account)
ExtraAccountMeta::new_external_pda_with_seeds(
&spl_associated_token_account::ID,
&[
Seed::AccountKey { index: 3 }, // owner
Seed::AccountKey { index: 4 }, // token program
Seed::AccountKey { index: 0 }, // mint
],
false,
true,
)?,
];
ExtraAccountMetaList::init::<ExecuteInstruction>(
&mut ctx.accounts.extra_account_meta_list.try_borrow_mut_data()?,
&account_metas,
)?;
Ok(())
} There are three ways to specify extra accounts:
- Direct pubkeys for fixed addresses (token program IDs, well-known mints)
- PDA seeds for your own program’s derived accounts
- External program PDAs for accounts derived from other programs
The Seed::AccountKey { index } references let you build PDAs from accounts that are already part of the transfer. Index 0 is the source token account, index 1 is the mint, and so on. This is clever but easy to get wrong. Off-by-one on the index and your PDA derivation silently produces the wrong address.
The read-only constraint
Here’s the biggest architectural difference between Solana’s transfer hooks and what other chains offer: all accounts from the original transfer instruction become read-only when passed to your hook.
The sender’s signer privileges don’t carry over to the hook program. This means your hook can validate, check conditions, and reject transfers, but it can’t move funds on behalf of the sender. It can’t debit their SOL account. It can’t transfer tokens from their wallet.
This is a deliberate security decision. Without it, a malicious hook could drain the sender’s wallet during what looks like a normal token transfer.
But it also means implementing something like “charge a SOL fee on every transfer” is more complicated than you’d think. You can’t just CPI a SOL transfer from the sender inside the hook. The standard workaround is the delegate pattern: the sender pre-approves a PDA from your hook program as a delegate on a separate token account (usually wrapped SOL). During the hook, your program signs with the delegate PDA’s seeds to transfer the fee. The anchor-transfer-hook example repo from Solana Labs demonstrates this pattern.
It works, but it’s extra setup per user. Every sender needs to create a wrapped SOL account and approve your delegate before they can transfer. That’s friction you need to account for in your UX.
Things that’ll trip you up
The discriminator problem. If you’re using Anchor, the transfer hook interface’s instruction discriminator doesn’t match Anchor’s default 8-byte discriminator scheme. In Anchor 0.30+, the #[interface(spl_transfer_hook_interface::execute)] attribute handles this. On older versions, you need a fallback function that manually unpacks the instruction and routes it:
pub fn fallback<'info>(
program_id: &Pubkey,
accounts: &'info [AccountInfo<'info>],
data: &[u8],
) -> Result<()> {
let instruction = TransferHookInstruction::unpack(data)?;
match instruction {
TransferHookInstruction::Execute { amount } => {
// call your transfer_hook logic
}
_ => Err(ProgramError::InvalidInstructionData.into()),
}
} If you forget this, transfers will just fail with a confusing instruction data error. Ask me how I know.
The transferring flag. Your execute instruction is a regular Solana instruction. Anyone can call it directly, not just the Token-2022 program. If your hook modifies state (incrementing a counter, updating a timestamp), someone could call execute directly without an actual transfer happening and mess up your state. To prevent this, check the transferring flag on the source token account’s extension data. It’s only set to true during an actual transfer.
Client-side account resolution. On the client side, you can’t just use a normal transfer instruction. You need createTransferCheckedWithTransferHookInstruction from the @solana/spl-token package. This helper reads your ExtraAccountMetaList PDA, resolves all the additional accounts, and includes them in the instruction. If you skip this, the transfer will fail because the hook program won’t receive the accounts it expects.
ExtraAccountMetaList must exist first. If someone tries to transfer your token before you’ve initialized the ExtraAccountMetaList PDA, the transfer will fail. Make sure initialization is part of your deployment process.
Token-2022 only. Transfer hooks don’t work with the old SPL Token program. Your mint must be created with TOKEN_2022_PROGRAM_ID. This means some older wallets and protocols that only support the original token program won’t be compatible.
How this compares to Aptos
Aptos has a similar concept called Dispatchable Fungible Assets (DFA). The core idea is the same: register hooks at token creation, framework calls them automatically. But the implementations diverge in interesting ways.
Aptos hooks receive a TransferRef that gives them direct power over the assets. Your withdraw hook can split tokens, burn a portion, redirect funds to a treasury, all inside the hook. Solana hooks get read-only accounts, so anything involving fund movement needs the delegate workaround.
Registration on Aptos is simpler: one function call with option::some() or option::none() for each hook point. No ExtraAccountMetaList to set up. No client-side account resolution to worry about. Callers using primary stores don’t need to change anything.
On the other hand, Solana’s hooks can be updated by the hook authority after mint creation. Aptos hooks are permanent. And Solana’s read-only constraint, while more limiting, is arguably a safer default.
I wrote a separate piece on how Aptos dispatchable fungible assets work with code examples if you want the side-by-side.
Where to go from here
The official Solana guide on transfer hooks walks through a complete example with tests. The anchor-transfer-hook repo shows the SOL-fee-on-transfer pattern with the delegate approach.
If you’re building something with transfer hooks, start with the ExtraAccountMetaList. Get that right first. It’s the piece with the most surface area for bugs, and you’ll be debugging account resolution issues if any of the seeds or indices are off. Once the extra accounts are wired up correctly, the actual hook logic is the easy part.