Token-2022 Transfer Hooks: Programmable Token Logic on Solana
**Category:** Development Tutorial | **Audience:** Advanced Developers
**Reading Time:** 20 minutes | **Author:** Builderz Team
**Verified Packages:** @solana/spl-token@0.4.14, @coral-xyz/anchor@0.32.1
What You'll Learn
Transfer Hooks are one of Token-2022's most powerful extensions, enabling custom program logic to execute on every token transfer. This deep dive teaches you to build, deploy, and integrate Transfer Hook programs using Anchor. By the end, you'll implement a complete transfer hook for compliance verification, usage tracking, or custom transfer rules.
Key Takeaways:
- Understand how Transfer Hooks work via Cross Program Invocation (CPI)
- Implement the Transfer Hook Interface with Anchor
- Create ExtraAccountMetaList for additional accounts
- Build real-world use cases: KYC verification, transfer taxes, whale alerts
- Handle common pitfalls and extension compatibility
TL;DR
Transfer Hooks let you run custom logic on every token transfer:
- **How**: Token Extensions Program CPIs to your Transfer Hook program
- **When**: Automatically on every transfer of enabled tokens
- **Interface**: Implement `Execute`, optionally `InitializeExtraAccountMetaList`
- **Limitations**: All accounts are read-only, no signer privileges
// Minimal Transfer Hook with Anchor 0.30+
#[program]
pub mod transfer_hook {
use super::*;
#[interface(spl_transfer_hook_interface::execute)]
pub fn execute(ctx: Context<Execute>, amount: u64) -> Result<()> {
msg!("Transfer hook executed for {} tokens", amount);
// Your custom logic here
Ok(())
}
}Prerequisites
Before starting, ensure you have:
- **Rust 1.75+** and Solana CLI 1.18+
- **Anchor 0.30+** installed (for `#[interface]` macro)
- Understanding of **PDAs** and **CPIs**
- Experience with **Token-2022** basics
- Familiarity with **SPL Token** operations
# Verify versions
rustc --version # >= 1.75
solana --version # >= 1.18
anchor --version # >= 0.30How Transfer Hooks Work
Architecture Overview
When a Transfer Hook-enabled token transfers, the Token Extensions Program:
- Processes the standard transfer logic
- Resolves additional accounts from `ExtraAccountMetaList`
- Invokes your Transfer Hook program via CPI
- Your `Execute` instruction runs with transfer context
User Wallet Token Extensions Program Your Hook Program
| | |
|-- Transfer Request --------->| |
| |-- Process Transfer --> |
| |-- Resolve Extra Accounts |
| |-- CPI: execute(amount) ----------->|
| | Custom Logic |
| |<-- Return OK/Error -----------------|
|<-- Transfer Complete --------| |Critical Security Constraint
All accounts passed to your Transfer Hook are converted to read-only. This prevents malicious hooks from:
- Draining user wallets
- Modifying balances
- Exploiting signer privileges
// In your Execute instruction, accounts are read-only
// You CANNOT modify sender/receiver balances
// You CAN read data, emit events, update your own PDAsProject Setup
Initialize Anchor Project
# Create new Anchor project
anchor init transfer_hook
cd transfer_hook
# Add dependencies to Cargo.tomlConfigure Dependencies
# programs/transfer_hook/Cargo.toml
[dependencies]
anchor-lang = "0.32.1"
anchor-spl = { version = "0.32.1", features = ["token-2022"] }
spl-transfer-hook-interface = "0.8.0"
spl-tlv-account-resolution = "0.8.0"
spl-type-length-value = "0.6.0"Program Structure
programs/transfer_hook/
├── src/
│ ├── lib.rs # Main program entry
│ ├── instructions/
│ │ ├── mod.rs
│ │ ├── initialize.rs # InitializeExtraAccountMetaList
│ │ └── execute.rs # Execute (transfer hook)
│ ├── state/
│ │ └── mod.rs # Account structures
│ └── errors.rs # Custom errors
└── Cargo.tomlImplementing the Transfer Hook Interface
Basic Transfer Hook Program
// programs/transfer_hook/src/lib.rs
use anchor_lang::prelude::*;
use anchor_spl::token_2022::Token2022;
use anchor_spl::token_interface::{Mint, TokenAccount};
use spl_transfer_hook_interface::instruction::ExecuteInstruction;
declare_id!("YourProgramIdHere");
#[program]
pub mod transfer_hook {
use super::*;
/// Initialize the ExtraAccountMetaList for this mint
pub fn initialize_extra_account_meta_list(
ctx: Context<InitializeExtraAccountMetaList>,
) -> Result<()> {
instructions::initialize::handler(ctx)
}
/// Transfer Hook: Execute custom logic on every transfer
/// The #[interface] macro ensures correct discriminator for Token Extensions CPI
#[interface(spl_transfer_hook_interface::execute)]
pub fn execute(ctx: Context<Execute>, amount: u64) -> Result<()> {
instructions::execute::handler(ctx, amount)
}
}
// Account structures
#[derive(Accounts)]
pub struct InitializeExtraAccountMetaList<'info> {
#[account(mut)]
pub payer: Signer<'info>,
/// The mint that will use this transfer hook
#[account(mut)]
pub mint: InterfaceAccount<'info, Mint>,
/// PDA storing additional account metas
/// CHECK: Validated in handler
#[account(
init,
payer = payer,
space = ExtraAccountMetaList::size_of(0)?, // Adjust based on extra accounts
seeds = [b"extra-account-metas", mint.key().as_ref()],
bump
)]
pub extra_account_meta_list: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Execute<'info> {
/// The source token account
#[account(token::mint = mint)]
pub source: InterfaceAccount<'info, TokenAccount>,
/// The mint of the token
pub mint: InterfaceAccount<'info, Mint>,
/// The destination token account
#[account(token::mint = mint)]
pub destination: InterfaceAccount<'info, TokenAccount>,
/// The authority (owner of source)
/// CHECK: Authority is validated by Token Extensions program
pub authority: UncheckedAccount<'info>,
/// Extra account meta list PDA
/// CHECK: Validated by seeds
#[account(
seeds = [b"extra-account-metas", mint.key().as_ref()],
bump
)]
pub extra_account_meta_list: UncheckedAccount<'info>,
}Initialize Extra Account Meta List
// programs/transfer_hook/src/instructions/initialize.rs
use anchor_lang::prelude::*;
use spl_tlv_account_resolution::{
account::ExtraAccountMeta,
state::ExtraAccountMetaList,
};
use spl_transfer_hook_interface::instruction::ExecuteInstruction;
pub fn handler(ctx: Context<InitializeExtraAccountMetaList>) -> Result<()> {
// Define additional accounts needed by Execute
let extra_account_metas: Vec<ExtraAccountMeta> = vec![
// Example: Add a config account PDA
ExtraAccountMeta::new_with_seeds(
&[
Seed::Literal { bytes: b"config".to_vec() },
Seed::AccountKey { index: 1 }, // mint
],
false, // is_signer
false, // is_writable (read-only in transfer hooks!)
)?,
// Example: Add whale alert account
ExtraAccountMeta::new_with_seeds(
&[
Seed::Literal { bytes: b"whale-alert".to_vec() },
Seed::AccountKey { index: 1 }, // mint
],
false,
true, // Can be writable if it's YOUR program's PDA
)?,
];
// Initialize the account with extra metas
let account_data = &mut ctx.accounts.extra_account_meta_list.data.borrow_mut();
ExtraAccountMetaList::init::<ExecuteInstruction>(
account_data,
&extra_account_metas,
)?;
msg!("Extra account meta list initialized with {} accounts", extra_account_metas.len());
Ok(())
}Execute Handler
// programs/transfer_hook/src/instructions/execute.rs
use anchor_lang::prelude::*;
pub fn handler(ctx: Context<Execute>, amount: u64) -> Result<()> {
let source = &ctx.accounts.source;
let destination = &ctx.accounts.destination;
let mint = &ctx.accounts.mint;
msg!("Transfer Hook Executed!");
msg!(" Amount: {} tokens", amount);
msg!(" From: {}", source.owner);
msg!(" To: {}", destination.owner);
msg!(" Mint: {}", mint.key());
// Your custom logic here
// Examples:
// - Check KYC status
// - Apply transfer tax (by updating your own accounts)
// - Emit whale alerts
// - Track transfer history
Ok(())
}Real-World Use Cases
1. KYC/Compliance Verification
Ensure both sender and receiver are KYC verified before allowing transfer.
// In your execute handler
pub fn handler(ctx: Context<Execute>, amount: u64) -> Result<()> {
// Load KYC registry from extra accounts
let kyc_registry = &ctx.remaining_accounts[0]; // First extra account
let source_owner = ctx.accounts.source.owner;
let dest_owner = ctx.accounts.destination.owner;
// Verify sender KYC status
let sender_kyc = get_kyc_status(kyc_registry, source_owner)?;
require!(sender_kyc.verified, ErrorCode::SenderNotVerified);
require!(!sender_kyc.blocked, ErrorCode::SenderBlocked);
// Verify receiver KYC status
let receiver_kyc = get_kyc_status(kyc_registry, dest_owner)?;
require!(receiver_kyc.verified, ErrorCode::ReceiverNotVerified);
require!(!receiver_kyc.blocked, ErrorCode::ReceiverBlocked);
msg!("KYC verification passed for transfer");
Ok(())
}
fn get_kyc_status(registry: &AccountInfo, user: Pubkey) -> Result<KycStatus> {
// Parse KYC registry and find user status
// Implementation depends on your registry structure
}
#[account]
pub struct KycStatus {
pub user: Pubkey,
pub verified: bool,
pub blocked: bool,
pub verification_date: i64,
}2. Whale Alert System
Track and emit events for large transfers.
#[account]
pub struct WhaleAlert {
pub mint: Pubkey,
pub threshold: u64, // Alert if transfer >= threshold
pub last_whale_sender: Pubkey,
pub last_whale_receiver: Pubkey,
pub last_whale_amount: u64,
pub last_whale_timestamp: i64,
pub total_whale_transfers: u64,
}
pub fn handler(ctx: Context<Execute>, amount: u64) -> Result<()> {
// Whale alert is passed as extra account
let whale_alert_account = &mut ctx.remaining_accounts[0];
let threshold = 10_000 * 10u64.pow(ctx.accounts.mint.decimals as u32);
if amount >= threshold {
// Update whale alert state
let mut whale_alert: WhaleAlert =
WhaleAlert::try_deserialize(&mut &whale_alert_account.data.borrow()[..])?;
whale_alert.last_whale_sender = ctx.accounts.source.owner;
whale_alert.last_whale_receiver = ctx.accounts.destination.owner;
whale_alert.last_whale_amount = amount;
whale_alert.last_whale_timestamp = Clock::get()?.unix_timestamp;
whale_alert.total_whale_transfers += 1;
// Serialize back
whale_alert.try_serialize(&mut &mut whale_alert_account.data.borrow_mut()[..])?;
// Emit event for indexers
emit!(WhaleTransferEvent {
mint: ctx.accounts.mint.key(),
sender: ctx.accounts.source.owner,
receiver: ctx.accounts.destination.owner,
amount,
timestamp: whale_alert.last_whale_timestamp,
});
msg!("WHALE ALERT: {} tokens transferred!", amount);
}
Ok(())
}
#[event]
pub struct WhaleTransferEvent {
pub mint: Pubkey,
pub sender: Pubkey,
pub receiver: Pubkey,
pub amount: u64,
pub timestamp: i64,
}3. Transfer Limit Enforcement
Restrict maximum transfer amounts per time period.
#[account]
pub struct TransferLimit {
pub mint: Pubkey,
pub max_per_transfer: u64,
pub max_per_day: u64,
pub current_day_total: u64,
pub day_start_timestamp: i64,
}
#[account]
pub struct UserTransferHistory {
pub user: Pubkey,
pub mint: Pubkey,
pub transfers_today: u64,
pub day_start: i64,
}
pub fn handler(ctx: Context<Execute>, amount: u64) -> Result<()> {
let limit_account = &ctx.remaining_accounts[0];
let limit: TransferLimit = TransferLimit::try_deserialize(
&mut &limit_account.data.borrow()[..]
)?;
// Check per-transfer limit
require!(
amount <= limit.max_per_transfer,
ErrorCode::ExceedsTransferLimit
);
// Check daily limit (would need user-specific tracking)
let user_history = get_user_history(ctx.remaining_accounts, ctx.accounts.source.owner)?;
let today_total = user_history.transfers_today + amount;
require!(
today_total <= limit.max_per_day,
ErrorCode::ExceedsDailyLimit
);
msg!("Transfer approved: {} of {} daily limit used", today_total, limit.max_per_day);
Ok(())
}Client-Side Integration
Creating a Token with Transfer Hook
// scripts/create-token-with-hook.ts
import {
Connection,
Keypair,
SystemProgram,
Transaction,
clusterApiUrl,
} from '@solana/web3.js';
import {
ExtensionType,
createInitializeMintInstruction,
createInitializeTransferHookInstruction,
getMintLen,
TOKEN_2022_PROGRAM_ID,
} from '@solana/spl-token';
const connection = new Connection(clusterApiUrl('devnet'));
const payer = Keypair.generate(); // Load your keypair
const TRANSFER_HOOK_PROGRAM_ID = new PublicKey('YourHookProgramId');
async function createTokenWithTransferHook() {
const mint = Keypair.generate();
const decimals = 9;
// Calculate space needed for mint with Transfer Hook extension
const extensions = [ExtensionType.TransferHook];
const mintLen = getMintLen(extensions);
const lamports = await connection.getMinimumBalanceForRentExemption(mintLen);
const transaction = new Transaction().add(
// Create mint account
SystemProgram.createAccount({
fromPubkey: payer.publicKey,
newAccountPubkey: mint.publicKey,
space: mintLen,
lamports,
programId: TOKEN_2022_PROGRAM_ID,
}),
// Initialize Transfer Hook extension
createInitializeTransferHookInstruction(
mint.publicKey,
payer.publicKey, // Authority
TRANSFER_HOOK_PROGRAM_ID, // Your Transfer Hook program
TOKEN_2022_PROGRAM_ID
),
// Initialize mint
createInitializeMintInstruction(
mint.publicKey,
decimals,
payer.publicKey, // Mint authority
null, // Freeze authority
TOKEN_2022_PROGRAM_ID
)
);
const signature = await connection.sendTransaction(transaction, [payer, mint]);
console.log('Mint created:', mint.publicKey.toBase58());
console.log('Signature:', signature);
return mint.publicKey;
}Initialize Extra Account Meta List
// After creating the mint, initialize the extra account meta list
async function initializeExtraAccountMetaList(mintPubkey: PublicKey) {
const [extraAccountMetaListPda] = PublicKey.findProgramAddressSync(
[Buffer.from('extra-account-metas'), mintPubkey.toBuffer()],
TRANSFER_HOOK_PROGRAM_ID
);
const instruction = await program.methods
.initializeExtraAccountMetaList()
.accounts({
payer: payer.publicKey,
mint: mintPubkey,
extraAccountMetaList: extraAccountMetaListPda,
systemProgram: SystemProgram.programId,
})
.instruction();
const tx = new Transaction().add(instruction);
const signature = await connection.sendTransaction(tx, [payer]);
console.log('Extra account meta list initialized:', signature);
}Transfer Tokens (Hook Executes Automatically)
// Standard transfer - hook executes via CPI
import { transferChecked, getAssociatedTokenAddress } from '@solana/spl-token';
async function transferWithHook(
mint: PublicKey,
sender: Keypair,
recipient: PublicKey,
amount: number
) {
const senderAta = await getAssociatedTokenAddress(
mint,
sender.publicKey,
false,
TOKEN_2022_PROGRAM_ID
);
const recipientAta = await getAssociatedTokenAddress(
mint,
recipient,
false,
TOKEN_2022_PROGRAM_ID
);
// The Transfer Hook executes automatically when using Token-2022 transfer
const signature = await transferChecked(
connection,
sender,
senderAta,
mint,
recipientAta,
sender.publicKey,
amount,
9, // decimals
[],
undefined,
TOKEN_2022_PROGRAM_ID
);
console.log('Transfer completed (hook executed):', signature);
}Common Pitfalls
Read-Only Account Constraint
Problem: Trying to modify sender/receiver accounts.
// WRONG - Accounts are read-only
ctx.accounts.source.amount -= fee; // Will fail!
// CORRECT - Update your own program's accounts
let fee_account = &mut ctx.remaining_accounts[0];
update_fee_record(fee_account, amount)?;Missing Interface Macro (Pre-Anchor 0.30)
Problem: Token Extensions can't find your Execute instruction.
// For Anchor < 0.30, use fallback
pub fn fallback<'info>(
program_id: &Pubkey,
accounts: &'info [AccountInfo<'info>],
data: &[u8],
) -> Result<()> {
// Parse and route to execute
}
// For Anchor >= 0.30, use #[interface]
#[interface(spl_transfer_hook_interface::execute)]
pub fn execute(ctx: Context<Execute>, amount: u64) -> Result<()> {
// Handler
}Extension Compatibility
Problem: Combining incompatible extensions.
// These combinations are NOT allowed:
// - TransferHook + NonTransferable (no transfers = no hook)
// - TransferHook + ConfidentialTransfer (amounts are encrypted)
// These ARE compatible:
// - TransferHook + MemoTransfer
// - TransferHook + TransferFeeConfig
// - TransferHook + MintCloseAuthorityExtra Account Resolution
Problem: Accounts not resolved correctly at transfer time.
// Ensure ExtraAccountMeta seeds match your program's PDAs
ExtraAccountMeta::new_with_seeds(
&[
Seed::Literal { bytes: b"config".to_vec() },
Seed::AccountKey { index: 1 }, // Index 1 = mint in execute accounts
],
false, // is_signer
false, // is_writable
)?Testing Transfer Hooks
Anchor Test Setup
// tests/transfer-hook.ts
import * as anchor from '@coral-xyz/anchor';
import { Program } from '@coral-xyz/anchor';
import { TransferHook } from '../target/types/transfer_hook';
import {
createMint,
createAccount,
mintTo,
transferChecked,
TOKEN_2022_PROGRAM_ID,
} from '@solana/spl-token';
describe('transfer-hook', () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.TransferHook as Program<TransferHook>;
it('Executes hook on transfer', async () => {
// 1. Create mint with Transfer Hook
const mint = await createMintWithHook(provider, program.programId);
// 2. Initialize extra account meta list
await program.methods
.initializeExtraAccountMetaList()
.accounts({ mint, /* ... */ })
.rpc();
// 3. Create token accounts
const sender = await createAccount(/* ... */);
const receiver = await createAccount(/* ... */);
// 4. Mint tokens to sender
await mintTo(/* ... */);
// 5. Transfer - hook should execute
const sig = await transferChecked(
connection,
provider.wallet.payer,
sender,
mint,
receiver,
provider.wallet.publicKey,
1000,
9,
[],
undefined,
TOKEN_2022_PROGRAM_ID
);
// 6. Verify hook executed (check logs, state updates)
const tx = await connection.getTransaction(sig, {
commitment: 'confirmed',
});
expect(tx.meta.logMessages).toContain('Transfer Hook Executed!');
});
});Next Steps & Resources
Advanced Topics
- **Composable Hooks**: Chain multiple transfer hooks
- **Dynamic Logic**: Adjust behavior based on on-chain conditions
- **Cross-Program Integration**: Hook into DeFi protocols
- **Gas Optimization**: Minimize hook compute costs
Official Resources
- [Solana Transfer Hook Guide](https://solana.com/developers/guides/token-extensions/transfer-hook)
- [Transfer Hook Interface Docs](https://spl.solana.com/transfer-hook-interface)
- [Token-2022 Specification (RareSkills)](https://rareskills.io/post/token-2022)
- [QuickNode Transfer Hooks Guide](https://www.quicknode.com/guides/solana-development/spl-tokens/token-2022/transfer-hooks)
Related Builderz Content
- [Token Extensions Guide](/blog/token-extensions-guide)
- [Anchor 0.30+ Migration](/blog/anchor-030-migration-guide)
- [Solana Development Services](/services)
Summary
Transfer Hooks unlock programmable token logic on Solana:
- **Interface**: Implement `Execute` and optionally `InitializeExtraAccountMetaList`
- **Security**: All accounts are read-only; update your own PDAs
- **Use Cases**: KYC, whale alerts, transfer limits, royalties
- **Anchor 0.30+**: Use `#[interface]` macro for correct discriminator
- **Compatibility**: Check extension combinations before design
With Transfer Hooks, tokens become programmable primitives for compliance, tracking, and custom business logic.
Built with expertise from Builderz - 32+ Solana projects including Token-2022 implementations.



