Skip to main content
Token-2022 Transfer Hooks: Build One With Anchor 1.x - Featured image
Smart Contracts

Token-2022 Transfer Hooks: Build One With Anchor 1.x

BUILDERZ

BUILDERZ

Share

In this guide

The decisions and working steps ahead.

    How a transfer hook runs

    The extra account list

    Set up Anchor 1.x for a hook

The official Solana transfer hook guide still uses the #[interface] attribute.

On Anchor 1.0 and later, that attribute is gone. Copy the guide, and your hook won't compile.

Save this as your hook checklist. It covers 1 working program, 1 client, and 5 pitfalls.

The hook blocks transfers between wallets without KYC. It also flags large transfers with an event.

How a transfer hook runs

A transfer hook is a program that Token-2022 calls on every transfer of a mint.

You set the hook program once, on the mint, with the transfer hook extension. Here's the flow for each transfer:

  • A wallet sends a checked transfer to the Token-2022 program.
  • Token-2022 moves the tokens and marks the source account as "transferring".
  • It reads a list of extra accounts that your hook asked for.
  • It calls the execute instruction in your program with the transfer accounts, those extras, and the amount.
  • If your program returns an error, the whole transfer fails, and no tokens move.

That same guide spells out the safety rule. All accounts from the original transfer become read-only inside the hook. The sender's signer rights don't carry over either.

So your hook can say no. It can't move anyone's tokens.

That limit is the point. Holders can trust a hooked token because the hook can only check and refuse.

The extra account list

Your hook will usually need more accounts than the transfer has. A KYC record or a config account are typical. You store that list in a PDA owned by your program, derived from the text extra-account-metas and the mint address.

Each entry can be a fixed address or a PDA built from seeds. The seeds can pull bytes from other accounts in the transfer, like the owner field of a token account. Clients read this list and add the accounts for you.

Set up Anchor 1.x for a hook

The Anchor changelog tracks the change in three steps:

  • version 0.31.0 marked the interface attribute as deprecated
  • version 1.0.0 removed it
  • version 1.2.1, released on 5 October 2026, is the current release

The replacement is a custom discriminator on the instruction. You give the handler the exact bytes Token-2022 sends when it calls a hook. The interface crate exports those bytes, so you never type them by hand.

The official transfer hook counter example uses that pattern. It pins these crates:

  • Anchor and its token helpers at 1.0.2
  • the transfer hook interface crate at 0.9.0
  • the account resolution crate at 0.9.0
  • the discriminator crate at 0.4.1

The program below keeps those interface pins with Anchor 1.2.1. It passes a full type check with this manifest:

Code exampletoml
[dependencies]
anchor-lang = { version = "1.2.1", features = ["init-if-needed"] }
anchor-spl = "1.2.1"
spl-discriminator = "0.4.1"
spl-tlv-account-resolution = "0.9.0"
spl-transfer-hook-interface = "0.9.0"

The latest interface release is 2.1.0, per the Rust registry.

Test any bump on its own. The official example warns about error type mismatches between releases, and those show up as odd compile errors.

Match your Anchor CLI to the crate version too. The Anchor version manager installs a given release side by side with others, so each project can pin its own.

Write the KYC and whale alert hook

The design is small on purpose. One config account per mint holds the issuer key and a whale threshold. Each approved wallet gets a KYC record at a PDA built from a short prefix and the wallet address.

The hook logs an event for large transfers instead of blocking them. Blocking a large but legal transfer is a support ticket. An event lets your back office review it without breaking anyone's payment.

The program logic

Code examplerust
#[program]
pub mod kyc_hook {
    use super::*;

    #[instruction(discriminator = InitializeExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE)]
    pub fn initialize_extra_account_meta_list(
        ctx: Context<InitializeExtraAccountMetaList>,
        whale_threshold: u64,
    ) -> Result<()> {
        ctx.accounts.config.whale_threshold = whale_threshold;
        ctx.accounts.config.issuer = ctx.accounts.payer.key();
        ExtraAccountMetaList::init::<ExecuteInstruction>(
            &mut ctx.accounts.extra_account_meta_list.try_borrow_mut_data()?,
            &extra_account_metas()?,
        )
        .map_err(|_| ProgramError::InvalidAccountData)?;
        Ok(())
    }

    // Issuer approves or revokes a wallet.
    pub fn set_kyc(ctx: Context<SetKyc>, approved: bool) -> Result<()> {
        ctx.accounts.record.approved = approved;
        Ok(())
    }

    #[instruction(discriminator = ExecuteInstruction::SPL_DISCRIMINATOR_SLICE)]
    pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {
        // Reject direct calls: only run inside a real Token-2022 transfer.
        assert_is_transferring(&ctx.accounts.source_token.to_account_info())?;

        // KYC gate: both wallets need an approved record.
        require!(ctx.accounts.sender_kyc.approved, HookError::SenderNotApproved);
        require!(ctx.accounts.receiver_kyc.approved, HookError::ReceiverNotApproved);

        // Whale alert: emit an event, never block.
        if amount >= ctx.accounts.config.whale_threshold {
            emit!(WhaleTransfer {
                mint: ctx.accounts.mint.key(),
                from: ctx.accounts.source_token.owner,
                to: ctx.accounts.destination_token.owner,
                amount,
            });
        }
        Ok(())
    }
}

fn assert_is_transferring(source: &AccountInfo) -> Result<()> {
    let data = source.try_borrow_data()?;
    let account = PodStateWithExtensions::<PodAccount>::unpack(&data)
        .map_err(|_| ProgramError::InvalidAccountData)?;
    let ext = account
        .get_extension::<TransferHookAccount>()
        .map_err(|_| ProgramError::InvalidAccountData)?;
    require!(bool::from(ext.transferring), HookError::NotTransferring);
    Ok(())
}

The transferring check matters more than it looks. Anyone can call your execute instruction directly.

Without the check, a stranger could fake whale alerts. Your indexer would trust them.

Wire the extra accounts

Order is everything here. Token-2022 passes the source account, mint, destination account, authority, and the meta list PDA, in that order. Your extra accounts follow.

Code examplerust
// Token account layout: mint at bytes 0..32, owner at 32..64.
// Execute order: 0 source, 1 mint, 2 destination, 3 authority, 4 meta list.
fn extra_account_metas() -> Result<Vec<ExtraAccountMeta>> {
    let owner_of = |account_index: u8| Seed::AccountData { account_index, data_index: 32, length: 32 };
    let kyc = |account_index: u8| {
        ExtraAccountMeta::new_with_seeds(
            &[Seed::Literal { bytes: b"kyc".to_vec() }, owner_of(account_index)],
            false, // is_signer
            false, // is_writable
        )
    };
    Ok(vec![
        ExtraAccountMeta::new_with_seeds(
            &[Seed::Literal { bytes: b"config".to_vec() }, Seed::AccountKey { index: 1 }],
            false,
            false,
        )
        .map_err(|_| ProgramError::InvalidArgument)?,
        kyc(0).map_err(|_| ProgramError::InvalidArgument)?, // sender's wallet
        kyc(2).map_err(|_| ProgramError::InvalidArgument)?, // receiver's wallet
    ])
}

#[derive(Accounts)]
pub struct TransferHook<'info> {
    #[account(token::mint = mint)]
    pub source_token: InterfaceAccount<'info, TokenAccount>,
    pub mint: InterfaceAccount<'info, Mint>,
    #[account(token::mint = mint)]
    pub destination_token: InterfaceAccount<'info, TokenAccount>,
    /// CHECK: owner or delegate, already checked by Token-2022
    pub authority: UncheckedAccount<'info>,
    /// CHECK: validated by seeds
    #[account(seeds = [b"extra-account-metas", mint.key().as_ref()], bump)]
    pub extra_account_meta_list: UncheckedAccount<'info>,
    #[account(seeds = [b"config", mint.key().as_ref()], bump)]
    pub config: Account<'info, Config>,
    #[account(seeds = [b"kyc", source_token.owner.as_ref()], bump)]
    pub sender_kyc: Account<'info, KycRecord>,
    #[account(seeds = [b"kyc", destination_token.owner.as_ref()], bump)]
    pub receiver_kyc: Account<'info, KycRecord>,
}

The KYC seeds read the owner from each token account, not the authority. That matters, because the authority can be a delegate.

A missing KYC record also blocks the transfer. Anchor can't load an account that doesn't exist, so the hook fails before your checks run.

The full program adds two more account structs, one for setup and one for approval. The setup struct creates the meta list and config PDAs. The approval struct checks that the signer matches the issuer stored in config.

Keep the hook cheap. Every transfer of the token runs it, so each transaction pays for every account load and check. Read only the accounts the rule needs.

Revoking a wallet is one call. The issuer sets its record to not approved, and its next transfer fails at the hook.

Create the mint and send transfers from TypeScript

On the client, you need two things. One is a mint with the transfer hook extension set. The other is transfers that carry the extra accounts.

The Token-2022 JavaScript client has helpers for both. This snippet type-checks against two pinned releases:

  • the Token-2022 client at 0.20.0
  • the Kit SDK at 8.5.0

The transfer helper reads the meta list from the chain. It then works out each PDA from its seeds and adds the accounts in the right order.

Code exampletypescript
const HOOK_PROGRAM = address('1qahDxKHeCLZhbBU2NyMU6vQCQmEUmdeSEBrG5drffK');
const mint = await generateKeyPairSigner();
const extensions = [
  extension('TransferHook', { authority: authority.address, programId: HOOK_PROGRAM }),
];
const space = BigInt(getMintSize(extensions));

const createMint = [
  getCreateAccountInstruction({
    payer: authority,
    newAccount: mint,
    lamports: await rpc.getMinimumBalanceForRentExemption(space).send(),
    space,
    programAddress: TOKEN_2022_PROGRAM_ADDRESS,
  }),
  ...getPreInitializeInstructionsForMintExtensions(mint.address, extensions),
  getInitializeMintInstruction({ mint: mint.address, decimals: 6, mintAuthority: authority.address }),
];

// Later: resolves config and both KYC PDAs from the on-chain meta list.
const transferIx = await getTransferCheckedWithTransferHookInstructionAsync(
  { rpc },
  {
    source: sourceAta,
    mint: mint.address,
    destination: destinationAta,
    authority,
    amount: 250_000_000n,
    decimals: 6,
  },
);

After the mint exists, call your setup instruction once. That writes the meta list and the whale threshold. Then the issuer approves wallets before anyone can receive the token.

Test the full path

Run the whole flow on a local validator before you touch a public test network. Create the mint, run setup, and approve two wallets. Then send three transfers.

The first goes between approved wallets and should pass. The second goes to an unapproved wallet and should fail. The third sits above the threshold and should pass with a whale event in the logs.

New to mint setup with extensions? Our Token-2022 extensions tutorial walks through the same client step by step.

5 pitfalls to check before a hook ships

1. Writing to transfer accounts

The source, destination, mint, and authority are read-only in the hook. Code that tries to write to them fails. Keep any state you need in your own PDAs. Mark those writable in the meta list only if you truly write to them.

2. Self-transfers skip the hook

The Token-2022 extension guide notes the hook isn't called when source and destination are the same account. Don't count transfers in a hook and expect a full total.

3. Stale meta lists

If you change which accounts the hook needs, update the meta list too. A client that reads the old list sends the wrong accounts. Every transfer then fails.

4. Clients that skip resolution

A plain checked transfer without the extra accounts fails on a hooked mint. Wallets and apps must use a helper that knows about hooks. Test the exact wallets your users hold before launch.

5. Pairing a hook with non-transferable

The token program doesn't reject this pair when you create the mint.

It's still pointless, because a token that can't move never runs a transfer. Pick one rule per mint.

FAQ

Does Anchor 1.0 still support the interface attribute for transfer hooks?

No. The Anchor changelog lists it as removed in Anchor 1.0. Use an instruction discriminator from the transfer hook interface crate instead, as shown above.

Can a transfer hook charge a fee or move tokens?

No. The transfer accounts are read-only, and signer rights don't pass through. For fees, use the transfer fee extension, which the token program withholds on each transfer.

Do transfer hooks work with every wallet?

Only if the wallet adds the extra accounts to the transfer. Wallets that send a plain transfer will see it fail on a hooked mint. Test your mint in each wallet your users rely on.

Can I change the hook program after launch?

Yes, if you kept the transfer hook authority on the mint. That key can point the mint at a new program. Guard it well, since it controls every transfer of the token.

How do I test a transfer hook?

Test it on a local validator with real transfers, not direct calls. Direct calls skip the transferring flag, so they don't prove the hook works. Check both the pass and fail paths, plus the event logs.

Ship a hook you can trust

Our historical staking build for Empyreals was a points system for NFT staking, built with Anchor. Hooks like this one are part of our Solana development service, and apply to real-world asset tokenization. If you're still designing the hook, an Architecture Sprint ($3K-$5K) can scope it.

Planning a hooked token? Join a call about your transfer hook.

Continue reading

Want to turn your idea into reality?

Book a 20-minute intro call, or send a short project brief. We reply within one business day.