Solana's Token Extensions program represents a paradigm shift in how tokens can be designed and managed on-chain. By introducing programmable features directly into the token standard, developers can now implement sophisticated compliance mechanisms, dynamic metadata, and custom transfer logic without deploying separate wrapper contracts. This guide explores the architecture and implementation of transfer hooks and metadata extensions for production-grade token systems.
Token Extensions Architecture
The Token Extensions program (Token-2022) extends the original SPL Token program with a modular extension system. Unlike the original program where all tokens share identical functionality, Token-2022 allows each mint to declare which extensions it requires, enabling fine-grained control over token behavior while maintaining backwards compatibility.
Extensions are stored directly in the mint and token account data, with each extension occupying a specific byte range after the base account data. The program uses a type-length-value (TLV) encoding scheme that allows for efficient parsing and future extensibility without breaking existing integrations.
// Extension types and their purposes
pub enum ExtensionType {
// Mint extensions
TransferFeeConfig, // Automatic fee collection on transfers
MintCloseAuthority, // Allow closing mint accounts
ConfidentialTransferMint, // Enable confidential transfers
TransferHook, // Custom transfer logic via CPI
MetadataPointer, // Point to on-chain metadata
TokenMetadata, // Store metadata directly in mint
// Account extensions
TransferFeeAmount, // Track withheld fees
ConfidentialTransferAccount, // Confidential balance state
ImmutableOwner, // Prevent ownership transfer
MemoTransfer, // Require memo on incoming transfers
}
// Calculate required space for mint with extensions
pub fn get_mint_space(extensions: &[ExtensionType]) -> usize {
let base_size = Mint::LEN;
let extension_size: usize = extensions
.iter()
.map(|ext| ext.account_len())
.sum();
base_size + extension_size + ExtensionType::TLV_HEADER_SIZE
}
Transfer Hooks Deep Dive
Transfer hooks enable developers to execute custom logic on every token transfer without requiring users to interact with a separate program. When a transfer occurs, the Token-2022 program automatically invokes your hook program via CPI, passing all relevant transfer information. This opens possibilities for implementing royalties, transfer restrictions, analytics tracking, and complex compliance requirements.
The hook program must implement a specific interface that the Token-2022 program expects. The CPI call includes the source account, destination account, mint, amount, and any additional accounts your hook requires. Critically, the hook executes atomically with the transfer—if your hook fails, the entire transfer reverts.
use anchor_lang::prelude::*;
use anchor_spl::token_2022::TransferHook;
use spl_transfer_hook_interface::instruction::ExecuteInstruction;
declare_id!("Hook111111111111111111111111111111111111111");
#[program]
pub mod transfer_hook {
use super::*;
// Called by Token-2022 on every transfer
pub fn execute(
ctx: Context<Execute>,
amount: u64,
) -> Result<()> {
let mint = &ctx.accounts.mint;
let source = &ctx.accounts.source_token;
let destination = &ctx.accounts.destination_token;
let authority = &ctx.accounts.authority;
// Access your custom state
let hook_state = &mut ctx.accounts.hook_state;
// Implement transfer restrictions
require!(
!hook_state.is_frozen,
TransferHookError::TransfersFrozen
);
// Check sender whitelist for restricted tokens
if hook_state.requires_whitelist {
let sender_info = &ctx.accounts.sender_info;
require!(
sender_info.is_whitelisted,
TransferHookError::SenderNotWhitelisted
);
}
// Enforce daily transfer limits
let current_day = Clock::get()?.unix_timestamp / 86400;
if hook_state.last_transfer_day != current_day {
hook_state.daily_transferred = 0;
hook_state.last_transfer_day = current_day;
}
require!(
hook_state.daily_transferred + amount <= hook_state.daily_limit,
TransferHookError::DailyLimitExceeded
);
hook_state.daily_transferred += amount;
hook_state.total_transfers += 1;
emit!(TransferExecuted {
mint: mint.key(),
from: source.key(),
to: destination.key(),
amount,
timestamp: Clock::get()?.unix_timestamp,
});
Ok(())
}
}
#[derive(Accounts)]
pub struct Execute<'info> {
#[account(mut)]
pub source_token: InterfaceAccount<'info, TokenAccount>,
pub mint: InterfaceAccount<'info, Mint>,
#[account(mut)]
pub destination_token: InterfaceAccount<'info, TokenAccount>,
pub authority: Signer<'info>,
// Extra accounts for hook logic
#[account(
mut,
seeds = [b"hook_state", mint.key().as_ref()],
bump = hook_state.bump,
)]
pub hook_state: Account<'info, HookState>,
#[account(
seeds = [b"sender_info", authority.key().as_ref()],
bump,
)]
pub sender_info: Account<'info, SenderInfo>,
}
#[account]
pub struct HookState {
pub bump: u8,
pub is_frozen: bool,
pub requires_whitelist: bool,
pub daily_limit: u64,
pub daily_transferred: u64,
pub last_transfer_day: i64,
pub total_transfers: u64,
}
One of the most powerful aspects of transfer hooks is the ability to require additional accounts beyond the standard transfer accounts. These extra accounts are defined in an ExtraAccountMetaList PDA that the Token-2022 program reads to determine which accounts to pass to your hook. This mechanism allows hooks to access custom state, oracle data, or any other accounts needed for transfer validation.
use spl_transfer_hook_interface::collect_extra_account_metas_signer_seeds;
use spl_tlv_account_resolution::{
account::ExtraAccountMeta,
seeds::Seed,
state::ExtraAccountMetaList,
};
// Initialize the extra account metas list
pub fn initialize_extra_account_metas(
ctx: Context<InitializeExtraAccountMetas>,
) -> Result<()> {
let mint = ctx.accounts.mint.key();
// Define extra accounts needed by your hook
let extra_metas = vec![
// PDA derived from mint
ExtraAccountMeta::new_with_seeds(
&[
Seed::Literal { bytes: b"hook_state".to_vec() },
Seed::AccountKey { index: 1 }, // mint is index 1
],
false, // is_signer
true, // is_writable
)?,
// PDA derived from authority
ExtraAccountMeta::new_with_seeds(
&[
Seed::Literal { bytes: b"sender_info".to_vec() },
Seed::AccountKey { index: 3 }, // authority is index 3
],
false,
false,
)?,
// External oracle account (fixed pubkey)
ExtraAccountMeta::new_with_pubkey(
&PRICE_ORACLE_PUBKEY,
false,
false,
)?,
];
// Calculate required space
let account_size = ExtraAccountMetaList::size_of(extra_metas.len())?;
// Initialize the list
let extra_metas_account = &ctx.accounts.extra_account_metas;
let mut data = extra_metas_account.try_borrow_mut_data()?;
ExtraAccountMetaList::init::<ExecuteInstruction>(&mut data, &extra_metas)?;
Ok(())
}
#[derive(Accounts)]
pub struct InitializeExtraAccountMetas<'info> {
#[account(mut)]
pub payer: Signer<'info>,
/// CHECK: Validated by Token-2022 program
#[account(
init,
payer = payer,
space = ExtraAccountMetaList::size_of(3)?,
seeds = [b"extra-account-metas", mint.key().as_ref()],
bump,
)]
pub extra_account_metas: UncheckedAccount<'info>,
pub mint: InterfaceAccount<'info, Mint>,
pub system_program: Program<'info, System>,
}
Token-2022 introduces two complementary approaches to on-chain metadata: the MetadataPointer extension that references external metadata accounts, and the TokenMetadata extension that stores metadata directly within the mint account. The direct storage approach eliminates the need for separate Metaplex metadata accounts, reducing complexity and rent costs.
The TokenMetadata extension follows a standardized schema with required fields (name, symbol, uri) and supports arbitrary additional fields through a flexible key-value system. This enables rich metadata representation while maintaining interoperability with existing tooling and marketplaces.
use anchor_lang::prelude::*;
use anchor_spl::token_2022::{
self,
spl_token_2022::{
extension::{
metadata_pointer::MetadataPointer,
token_metadata::{TokenMetadata, Field},
ExtensionType,
},
state::Mint,
},
};
pub fn create_token_with_metadata(
ctx: Context<CreateTokenWithMetadata>,
name: String,
symbol: String,
uri: String,
additional_metadata: Vec<(String, String)>,
) -> Result<()> {
let mint = &ctx.accounts.mint;
let metadata_account = &ctx.accounts.mint; // Same account for embedded metadata
// Initialize mint with metadata pointer extension
let extensions = vec![
ExtensionType::MetadataPointer,
ExtensionType::TokenMetadata,
];
let space = ExtensionType::try_calculate_account_len::<Mint>(&extensions)?;
// Create the mint account
let cpi_accounts = anchor_lang::system_program::CreateAccount {
from: ctx.accounts.payer.to_account_info(),
to: mint.to_account_info(),
};
let cpi_program = ctx.accounts.system_program.to_account_info();
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
anchor_lang::system_program::create_account(
cpi_ctx,
Rent::get()?.minimum_balance(space),
space as u64,
&token_2022::ID,
)?;
// Initialize metadata pointer to self
let init_pointer_ix = spl_token_2022::extension::metadata_pointer::instruction::initialize(
&token_2022::ID,
&mint.key(),
Some(ctx.accounts.authority.key()),
Some(mint.key()), // Points to itself
)?;
anchor_lang::solana_program::program::invoke(
&init_pointer_ix,
&[mint.to_account_info()],
)?;
// Initialize the mint
let init_mint_ix = spl_token_2022::instruction::initialize_mint2(
&token_2022::ID,
&mint.key(),
&ctx.accounts.authority.key(),
Some(&ctx.accounts.authority.key()),
9, // decimals
)?;
anchor_lang::solana_program::program::invoke(
&init_mint_ix,
&[mint.to_account_info()],
)?;
// Initialize token metadata
let init_metadata_ix = spl_token_metadata_interface::instruction::initialize(
&token_2022::ID,
&mint.key(),
&ctx.accounts.authority.key(),
&mint.key(),
&ctx.accounts.authority.key(),
name,
symbol,
uri,
);
anchor_lang::solana_program::program::invoke(
&init_metadata_ix,
&[
mint.to_account_info(),
ctx.accounts.authority.to_account_info(),
],
)?;
// Add additional metadata fields
for (key, value) in additional_metadata {
let update_field_ix = spl_token_metadata_interface::instruction::update_field(
&token_2022::ID,
&mint.key(),
&ctx.accounts.authority.key(),
Field::Key(key),
value,
);
anchor_lang::solana_program::program::invoke(
&update_field_ix,
&[
mint.to_account_info(),
ctx.accounts.authority.to_account_info(),
],
)?;
}
Ok(())
}
Unlike immutable metadata systems, Token-2022 metadata can be updated by the designated update authority. This enables dynamic NFTs, evolving game assets, and tokens that reflect real-world state changes. The update mechanism supports both replacing existing fields and adding entirely new metadata entries.
import {
createUpdateFieldInstruction,
createRemoveKeyInstruction,
TOKEN_2022_PROGRAM_ID,
} from "@solana/spl-token";
import { Connection, PublicKey, Transaction } from "@solana/web3.js";
interface MetadataUpdate {
field: string;
value: string | null; // null to remove
}
async function updateTokenMetadata(
connection: Connection,
mint: PublicKey,
updateAuthority: PublicKey,
updates: MetadataUpdate[],
signTransaction: (tx: Transaction) => Promise<Transaction>
): Promise<string> {
const transaction = new Transaction();
for (const update of updates) {
if (update.value === null) {
// Remove the field
transaction.add(
createRemoveKeyInstruction({
programId: TOKEN_2022_PROGRAM_ID,
metadata: mint,
updateAuthority,
key: update.field,
idempotent: true,
})
);
} else {
// Update or add the field
transaction.add(
createUpdateFieldInstruction({
programId: TOKEN_2022_PROGRAM_ID,
metadata: mint,
updateAuthority,
field: update.field,
value: update.value,
})
);
}
}
const { blockhash } = await connection.getLatestBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = updateAuthority;
const signedTx = await signTransaction(transaction);
const signature = await connection.sendRawTransaction(signedTx.serialize());
await connection.confirmTransaction(signature);
return signature;
}
// Example: Update game NFT stats after battle
async function updateGameNFTStats(
connection: Connection,
nftMint: PublicKey,
gameAuthority: PublicKey,
newStats: {
level: number;
experience: number;
wins: number;
lastBattle: string;
}
) {
const updates: MetadataUpdate[] = [
{ field: "level", value: newStats.level.toString() },
{ field: "experience", value: newStats.experience.toString() },
{ field: "wins", value: newStats.wins.toString() },
{ field: "last_battle", value: newStats.lastBattle },
{ field: "updated_at", value: new Date().toISOString() },
];
return updateTokenMetadata(
connection,
nftMint,
gameAuthority,
updates,
wallet.signTransaction
);
}
Combining Extensions for Complex Use Cases
The true power of Token Extensions emerges when combining multiple extensions to create sophisticated token systems. A compliance-focused stablecoin might combine transfer hooks for KYC verification, transfer fees for revenue, confidential transfers for privacy, and metadata for regulatory documentation—all within a single token.
// Complete compliant token setup with multiple extensions
pub fn initialize_compliant_token(
ctx: Context<InitializeCompliantToken>,
params: CompliantTokenParams,
) -> Result<()> {
let extensions = vec![
ExtensionType::TransferHook, // KYC/AML compliance
ExtensionType::TransferFeeConfig, // Automatic fee collection
ExtensionType::MetadataPointer, // On-chain metadata
ExtensionType::TokenMetadata, // Embedded metadata
ExtensionType::PermanentDelegate, // Regulatory seizure capability
ExtensionType::InterestBearingConfig, // Yield-bearing stablecoin
];
// Calculate total space needed
let space = ExtensionType::try_calculate_account_len::<Mint>(&extensions)?;
// Initialize each extension...
// 1. Transfer Hook for compliance checks
initialize_transfer_hook(
&ctx.accounts.mint,
&ctx.accounts.compliance_program.key(),
)?;
// 2. Transfer fee (e.g., 0.1% with 1000 max fee)
initialize_transfer_fee(
&ctx.accounts.mint,
10, // 0.1% = 10 basis points
1000, // Max fee in token units
&ctx.accounts.fee_authority.key(),
&ctx.accounts.withdraw_authority.key(),
)?;
// 3. Metadata
initialize_metadata(
&ctx.accounts.mint,
¶ms.name,
¶ms.symbol,
¶ms.uri,
)?;
// 4. Permanent delegate for regulatory compliance
initialize_permanent_delegate(
&ctx.accounts.mint,
&ctx.accounts.regulatory_authority.key(),
)?;
// 5. Interest bearing config
initialize_interest_bearing(
&ctx.accounts.mint,
&ctx.accounts.rate_authority.key(),
params.initial_rate,
)?;
emit!(CompliantTokenCreated {
mint: ctx.accounts.mint.key(),
compliance_program: ctx.accounts.compliance_program.key(),
fee_basis_points: 10,
has_permanent_delegate: true,
});
Ok(())
}
#[derive(AnchorSerialize, AnchorDeserialize)]
pub struct CompliantTokenParams {
pub name: String,
pub symbol: String,
pub uri: String,
pub initial_rate: i16, // Interest rate in basis points
}
Client Integration Patterns
Integrating Token Extensions into client applications requires awareness of the additional accounts and instructions involved. The SPL Token library provides helpers for discovering required accounts and constructing properly-formed transactions that include all necessary extension data.
import {
getTransferHook,
getExtraAccountMetas,
createTransferCheckedWithTransferHookInstruction,
getAssociatedTokenAddressSync,
getMint,
TOKEN_2022_PROGRAM_ID,
} from "@solana/spl-token";
import {
Connection,
PublicKey,
Transaction,
TransactionInstruction,
} from "@solana/web3.js";
async function transferTokenWithHook(
connection: Connection,
mint: PublicKey,
sender: PublicKey,
recipient: PublicKey,
owner: PublicKey,
amount: bigint,
decimals: number
): Promise<TransactionInstruction> {
// Get source and destination token accounts
const sourceAta = getAssociatedTokenAddressSync(
mint,
sender,
false,
TOKEN_2022_PROGRAM_ID
);
const destinationAta = getAssociatedTokenAddressSync(
mint,
recipient,
false,
TOKEN_2022_PROGRAM_ID
);
// Fetch mint to check for transfer hook
const mintInfo = await getMint(
connection,
mint,
"confirmed",
TOKEN_2022_PROGRAM_ID
);
const transferHook = getTransferHook(mintInfo);
if (transferHook) {
// Build instruction with hook accounts
return createTransferCheckedWithTransferHookInstruction(
connection,
sourceAta,
mint,
destinationAta,
owner,
amount,
decimals,
[],
"confirmed",
TOKEN_2022_PROGRAM_ID
);
} else {
// Standard transfer without hook
return createTransferCheckedInstruction(
sourceAta,
mint,
destinationAta,
owner,
amount,
decimals,
[],
TOKEN_2022_PROGRAM_ID
);
}
}
// Reading metadata from Token-2022 mint
async function getTokenMetadata(
connection: Connection,
mint: PublicKey
): Promise<TokenMetadataInfo | null> {
const mintInfo = await getMint(
connection,
mint,
"confirmed",
TOKEN_2022_PROGRAM_ID
);
const metadataPointer = getMetadataPointerState(mintInfo);
if (!metadataPointer?.metadataAddress) {
return null;
}
// If metadata is embedded in mint
if (metadataPointer.metadataAddress.equals(mint)) {
const metadata = getTokenMetadata(mintInfo);
return {
name: metadata?.name ?? "",
symbol: metadata?.symbol ?? "",
uri: metadata?.uri ?? "",
additionalMetadata: metadata?.additionalMetadata ?? [],
};
}
// External metadata account
const metadataAccount = await connection.getAccountInfo(
metadataPointer.metadataAddress
);
// Parse external metadata...
return parseExternalMetadata(metadataAccount);
}
Security Considerations
Token Extensions introduce new security surfaces that developers must carefully consider. Transfer hooks execute with the authority of the Token-2022 program, meaning bugs in hook logic can affect all token holders. The permanent delegate extension grants irrevocable transfer authority, requiring absolute trust in the delegate.
When implementing transfer hooks, validate all inputs rigorously, implement proper access controls for administrative functions, and consider the implications of hook failures on user experience. Upgradeable hooks should use timelock mechanisms to give users time to exit before malicious updates take effect.
// Security patterns for transfer hooks
#[account]
pub struct HookConfig {
pub admin: Pubkey,
pub pending_admin: Option<Pubkey>,
pub is_paused: bool,
pub upgrade_timelock: i64,
pub pending_upgrade: Option<PendingUpgrade>,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
pub struct PendingUpgrade {
pub new_program: Pubkey,
pub effective_time: i64,
}
// Two-step admin transfer
pub fn initiate_admin_transfer(
ctx: Context<AdminAction>,
new_admin: Pubkey,
) -> Result<()> {
require!(
ctx.accounts.config.admin == ctx.accounts.admin.key(),
HookError::Unauthorized
);
ctx.accounts.config.pending_admin = Some(new_admin);
emit!(AdminTransferInitiated {
current_admin: ctx.accounts.admin.key(),
pending_admin: new_admin,
});
Ok(())
}
pub fn accept_admin_transfer(
ctx: Context<AcceptAdmin>,
) -> Result<()> {
let config = &mut ctx.accounts.config;
require!(
config.pending_admin == Some(ctx.accounts.new_admin.key()),
HookError::NotPendingAdmin
);
config.admin = ctx.accounts.new_admin.key();
config.pending_admin = None;
Ok(())
}
// Timelock for hook upgrades
pub fn propose_upgrade(
ctx: Context<AdminAction>,
new_program: Pubkey,
) -> Result<()> {
let config = &mut ctx.accounts.config;
let current_time = Clock::get()?.unix_timestamp;
config.pending_upgrade = Some(PendingUpgrade {
new_program,
effective_time: current_time + config.upgrade_timelock,
});
emit!(UpgradeProposed {
new_program,
effective_time: current_time + config.upgrade_timelock,
});
Ok(())
}
pub fn execute_upgrade(ctx: Context<ExecuteUpgrade>) -> Result<()> {
let config = &ctx.accounts.config;
let pending = config.pending_upgrade
.as_ref()
.ok_or(HookError::NoUpgradePending)?;
let current_time = Clock::get()?.unix_timestamp;
require!(
current_time >= pending.effective_time,
HookError::TimelockNotExpired
);
// Execute upgrade logic...
Ok(())
}
Conclusion
Token Extensions represent a significant evolution in Solana's token capabilities, enabling sophisticated on-chain logic that was previously only possible through complex wrapper programs. Transfer hooks provide programmable transfer validation, while embedded metadata eliminates dependencies on external programs.
When building with Token Extensions, carefully consider which extensions your use case requires, implement robust security measures for administrative functions, and thoroughly test hook logic to ensure it handles all edge cases gracefully. The combination of multiple extensions opens possibilities for entirely new token primitives—from compliant securities to dynamic gaming assets.
As the ecosystem matures, expect wallets, explorers, and DeFi protocols to improve their Token-2022 support, making these powerful features accessible to mainstream users while maintaining the security guarantees that institutional adoption requires.