Background
The Transfer Hook extension lets a Token-2022 mint require a Cross Program Invocation (CPI) to a custom program on every token transfer. The mint stores the hook program's address, and any wallet, dapp, or custodian sending that token must include the accounts the hook program needs so the CPI can execute.
This guide is for teams integrating tokens that use a transfer hook (wallets, dapps, custodians, exchanges, explorers) rather than teams writing a hook program. If you're building a hook program, start with the Transfer Hook Interface and the Transfer Hook extension guide; this guide focuses on what a client needs to do to send, receive, and simulate transfers of a hook-enabled token correctly.
Unlike most other Token-2022 extensions, a transfer hook isn't optional at the account level. If a mint has a transfer hook configured, every transfer of that token requires the hook's extra accounts, whether or not your product does anything with the hook's logic. A client that doesn't resolve those accounts can't send the token at all; the transfer instruction fails onchain, it doesn't silently skip the hook. The complete functions to drop into your send path for this are under Sending a transfer-hook token below, for both Kit and Web3.js.
Resources
- Transfer Hook Interface reference
- Extension Rust code
@solana-program/token-2022JS client the Kit-based client, recommended for new integrations. Native transfer hook resolution viagetTransferCheckedWithTransferHookInstructionAsyncplus the lower-levelresolveExtraAccountMetasForExecute/findExtraAccountMetaListPdahelpers.@solana/spl-tokenJS client the legacy client for the deprecated@solana/web3.jslibrary. Covers the same ground (extension detection, extra account resolution, the high-levelcreateTransferCheckedWithTransferHookInstructionhelper) for teams still on web3.js.- Transfer Hook extension guide (writing a hook program, for context on what issuers configure)
TL;DR
- A transfer hook mint stores a hook program address. Every transfer CPIs into that program, and the CPI needs extra accounts beyond the standard transfer accounts.
- The extra accounts a hook needs are listed in an onchain
ExtraAccountMetaListaccount, a PDA derived from the hook program and the mint. Clients read this account to resolve which accounts to append to a transfer instruction. - Resolution isn't optional. If the extra accounts are missing or stale, the transfer instruction fails onchain. There's no fallback that silently sends the token without the hook.
- Both Kit (
@solana-program/token-2022) and Web3.js (@solana/spl-token) can send a hook-enabled transfer end-to-end — see the complete functions under Sending a transfer-hook token. Each resolves theExtraAccountMetaListnatively: Kit viagetTransferCheckedWithTransferHookInstructionAsync, Web3.js viacreateTransferCheckedWithTransferHookInstruction. - Always simulate before sending. A hook program can fail the transfer for any reason it defines (an allowlist check, a paused state, a missing delegation), and the set of extra accounts can change if the issuer updates the hook. Simulating surfaces both problems before the user signs.
- Hook execution adds compute units and, for hooks that require pre-funded or pre-approved side accounts (a delegated fee account, a counter PDA the user hasn't initialized yet), can require setup transactions before the first transfer succeeds.
Terms
- Hook program: the program a mint delegates transfer-time logic to, set via the Transfer Hook extension on the mint.
ExtraAccountMetaList: a PDA, owned by the hook program, that stores the list of additional accounts the hook'sExecuteinstruction needs. Derived from the seeds"extra-account-metas"and the mint address.ExtraAccountMeta: one entry in that list. It can reference a fixed address, a PDA off the hook program, a PDA off a different program, or a PDA seeded from data in one of the transfer's own accounts.TransferHookAccountextension: state on a token account that includes atransferringflag, set totrueonly while the token program is mid-CPI into the hook. Hook programs use it to reject calls that don't originate from an actual transfer.Execute: the instruction the token program CPIs into on every transfer. Clients never call it directly; it's invoked as part ofTransferChecked.
Sending a transfer-hook token
Every hook-enabled transfer has to do four things: detect that the mint has a transfer hook, resolve the extra accounts the hook's CPI needs, simulate, and only then send. Both functions below do all four and are meant to be dropped in wherever your app currently builds a Token-2022 transfer.
Kit
The @solana-program/token-2022 client resolves everything natively via
getTransferCheckedWithTransferHookInstructionAsync: it fetches the mint,
detects whether a transfer hook is configured, resolves the
ExtraAccountMetaList, and appends the hook's extra accounts. When the mint has
no hook it returns a plain transferChecked, so the same call covers both cases
with no bridging to the legacy client.
import {appendTransactionMessageInstructions,assertIsTransactionWithBlockhashLifetime,compileTransaction,createTransactionMessage,getBase64EncodedWireTransaction,pipe,sendAndConfirmTransactionFactory,setTransactionMessageFeePayerSigner,setTransactionMessageLifetimeUsingBlockhash,signTransactionMessageWithSigners,type Address,type Rpc,type RpcSubscriptions,type SolanaRpcApi,type SolanaRpcSubscriptionsApi,type TransactionSigner} from "@solana/kit";import { getTransferCheckedWithTransferHookInstructionAsync } from "@solana-program/token-2022";/*** Builds, simulates, and sends a Token-2022 transfer, resolving transfer* hook extra accounts when the mint requires them. Drop this in wherever* your app currently builds a Token-2022 transfer instruction with Kit.*/export async function sendTokenTransfer({rpc,rpcSubscriptions,source,mint,destination,owner,feePayer,amount,decimals}: {rpc: Rpc<SolanaRpcApi>;rpcSubscriptions: RpcSubscriptions<SolanaRpcSubscriptionsApi>;source: Address;mint: Address;destination: Address;owner: TransactionSigner; // Authority over the source token account.feePayer: TransactionSigner;amount: bigint;decimals: number;}) {// 1. Build the transfer instruction. When the mint has a transfer hook this// fetches it, resolves the ExtraAccountMetaList, and appends the accounts the// hook's CPI needs; when it doesn't, you get a plain transferChecked. Because// it re-fetches the mint on every call, don't cache the result across sends// -- the hook program and its extra accounts can both change.const instruction = await getTransferCheckedWithTransferHookInstructionAsync({ rpc },{source,mint,destination,authority: owner,amount,decimals});const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();const message = pipe(createTransactionMessage({ version: 0 }),(tx) => setTransactionMessageFeePayerSigner(feePayer, tx),(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),(tx) => appendTransactionMessageInstructions([instruction], tx));// 2. Simulate before signing, so the user is never prompted to authorize a// transfer the hook would reject. Compiling the message (rather than signing// it) is enough to simulate, and sigVerify: false lets the network run it// without signatures. This catches a hook rejecting the transfer (an// allowlist check, a paused mint, ...) or a stale ExtraAccountMetaList before// anyone signs or pays a fee.const simulation = await rpc.simulateTransaction(getBase64EncodedWireTransaction(compileTransaction(message)),{ encoding: "base64", sigVerify: false, replaceRecentBlockhash: true }).send();if (simulation.value.err) {throw new Error(`Transfer simulation failed: ${JSON.stringify(simulation.value.err)}\n` +simulation.value.logs?.join("\n"));}// 3. Sign only after a successful simulation, then send.const signedMessage = await signTransactionMessageWithSigners(message);assertIsTransactionWithBlockhashLifetime(signedMessage);await sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions })(signedMessage,{ commitment: "confirmed" });}
getTransferCheckedWithTransferHookInstructionAsync wraps the lower-level Kit
resolvers (resolveExtraAccountMetasForExecute,
findExtraAccountMetaListPda) covered under Assembling accounts
manually below. Reach for those directly only
when you're appending hook accounts to an instruction you assemble yourself.
Web3.js
The legacy @solana/spl-token client resolves everything natively — no bridging
required.
import {Connection,PublicKey,Signer,Transaction,sendAndConfirmTransaction} from "@solana/web3.js";import {createTransferCheckedInstruction,createTransferCheckedWithTransferHookInstruction,getMint,getTransferHook,TOKEN_2022_PROGRAM_ID} from "@solana/spl-token";/*** Builds, simulates, and sends a Token-2022 transfer, resolving transfer* hook extra accounts when the mint requires them. Drop this in wherever* your app currently builds a Token-2022 transfer instruction directly.*/export async function sendTokenTransfer({connection,payer,source,mint,destination,owner,amount,decimals}: {connection: Connection;payer: Signer; // Fee payer; can be the same signer as `owner`.source: PublicKey;mint: PublicKey;destination: PublicKey;owner: Signer; // Authority over the source token account.amount: bigint;decimals: number;}) {// 1. Re-check for a transfer hook on every send. The hook program and its// extra accounts can both change, so don't cache this across transfers.const mintInfo = await getMint(connection,mint,"confirmed",TOKEN_2022_PROGRAM_ID);const transferHook = getTransferHook(mintInfo);// 2. Build the transfer instruction. When a hook is configured, this also// resolves the ExtraAccountMetaList and appends the accounts the hook's// CPI needs -- there's no separate resolution step to call yourself.const instruction = transferHook? await createTransferCheckedWithTransferHookInstruction(connection,source,mint,destination,owner.publicKey,amount,decimals,[], // Additional signers, only needed for a multisig authority."confirmed",TOKEN_2022_PROGRAM_ID): createTransferCheckedInstruction(source,mint,destination,owner.publicKey,amount,decimals,[],TOKEN_2022_PROGRAM_ID);const { blockhash, lastValidBlockHeight } =await connection.getLatestBlockhash();const transaction = new Transaction({feePayer: payer.publicKey,blockhash,lastValidBlockHeight}).add(instruction);// 3. Simulate before signing, so the user is never prompted to authorize a// transfer the hook would reject. Simulating without signers runs the// transaction unsigned, which catches a hook rejecting the transfer (an// allowlist check, a paused mint, ...) or a stale ExtraAccountMetaList before// anyone signs or pays a fee.const simulation = await connection.simulateTransaction(transaction);if (simulation.value.err) {throw new Error(`Transfer simulation failed: ${JSON.stringify(simulation.value.err)}\n` +simulation.value.logs?.join("\n"));}// 4. Sign and send only after a successful simulation.return sendAndConfirmTransaction(connection, transaction, [payer, owner]);}
Detecting the extension
Both functions above re-fetch the mint and check for the hook on every send:
Web3.js explicitly via getMint, Kit inside
getTransferCheckedWithTransferHookInstructionAsync, which fetches the mint
before it resolves anything.
The hook program address on the mint can be updated by the mint's transfer
hook authority (UpdateTransferHook), and the extra accounts it requires can
change independently (UpdateExtraAccountMetaList). Don't cache either value
for longer than a single transfer flow; re-fetch when the user initiates a new
send.
The paired TransferHookAccount extension lives on token accounts, not the
mint. Integrators generally don't need to read it directly. It exists so the
hook program itself can confirm a call happened inside a real transfer, not
because a client invoked Execute directly.
Resolving the extra accounts
Every hook-enabled transfer needs the standard four transfer accounts (source,
mint, destination, owner/authority) plus whatever the ExtraAccountMetaList
account for that mint specifies. The list is a PDA derived from the hook
program:
// Kit (@solana-program/token-2022)import { findExtraAccountMetaListPda } from "@solana-program/token-2022";const [extraAccountMetaListPda] = await findExtraAccountMetaListPda({ mint: mintAddress },{ programAddress: transferHook.programId });// Web3.js (@solana/spl-token)import { getExtraAccountMetaAddress } from "@solana/spl-token";const extraAccountMetaListPda = getExtraAccountMetaAddress(mintAddress,transferHook.programId);
Each entry in that account resolves to a concrete AccountMeta one of four
ways: a fixed pubkey, a PDA off the hook program, a PDA off a different program
named earlier in the account list, or a PDA seeded with bytes read from one of
the transfer's own accounts (for example, the source token account's owner).
Resolving the data-seeded case requires fetching account data over RPC, which is
why resolution is asynchronous and can take more than one round trip.
Assembling accounts manually
If you're assembling the instruction yourself rather than using the functions above, both clients expose the lower-level pieces those functions are built from.
Kit (@solana-program/token-2022)
findExtraAccountMetaListPda({ mint }, { programAddress }): derives theExtraAccountMetaListvalidation account's PDA.getExtraAccountMetasDecoder().decode(accountData): parses the raw validation account data into a list ofExtraAccountMetaentries.resolveExtraAccountMeta(meta, previousAddresses, instructionData, hookProgramAddress, rpc): resolves one entry into anAccountMeta, given the addresses resolved so far (later entries can reference earlier ones).resolveExtraAccountMetasForExecute({ rpc, transferHookProgramAddress, source, mint, destination, owner, amount }): resolves every entry and returns the metas to append — the extra accounts, the hook program, and the validation account. Kit instructions are immutable, so it returns the metas for you to spread onto the instruction rather than mutating it in place.
Web3.js (@solana/spl-token)
getExtraAccountMetas(account): decodes the rawExtraAccountMetaListaccount data into a list ofExtraAccountMetaentries.resolveExtraAccountMeta(connection, meta, previousMetas, instructionData, hookProgramId): resolves one entry into anAccountMeta, given the accounts resolved so far (later entries can reference earlier ones).addExtraAccountMetasForExecute(connection, instruction, hookProgramId, source, mint, destination, owner, amount): resolves and appends every entry to an existing instruction in one call.
Simulating before sending
The simulate step in both functions above is why this matters: two things can go wrong that only show up at execution time.
- The hook rejects the transfer. A hook program can encode arbitrary conditions (an allowlist, a paused mint, a per-transfer cap) and fails the whole instruction, source and destination included, if the condition isn't met. There's no partial-success case: a rejected hook call rejects the transfer.
- The extra accounts are stale. If the issuer changed the hook program or
updated the
ExtraAccountMetaListbetween when your client last cached anything and when the user sends, resolving against old data produces the wrong accounts and the transfer fails with an account-validation error, not a hook-logic error.
Simulating first, then submitting only after a successful simulation, catches both cases before the user pays a fee for a failed transaction. It also lets you surface a clear error (why the transfer can't complete) instead of a raw transaction failure to the user.
Compute and setup implications
The hook program's CPI runs inside the transfer's compute budget. A hook doing nontrivial work (reading multiple accounts, running its own checks) adds real compute cost on top of the base transfer, so requesting an appropriately sized compute unit limit on hook-enabled transfers reduces avoidable failures.
Some hooks also require accounts to exist before the first transfer succeeds, not just be resolvable: a delegated fee token account the sender needs to fund and approve (as in a wSOL-fee hook), or a counter or allowlist entry the issuer's program expects to already be initialized for that owner. Client implementations that only resolve accounts and never surface "this token needs one-time setup before you can send it" to the user will see sends fail for reasons that have nothing to do with balance or network conditions.
Accounts are read-only during the hook CPI
When the token program CPIs into a hook program, it passes every account from the original transfer, including the sender's own account, as read-only, and the sender's signer privileges don't carry into the hook. A hook program therefore cannot move tokens out of the sender's accounts on its own authority mid-CPI. A hook that needs to move a side-payment, a fee in another token for example, does it through a delegate the sender pre-approved ahead of time, the same one-time setup described above.
Backwards compatibility
Transfer hooks behave differently from most other Token-2022 extensions when it comes to unsupported clients:
- A wallet or dapp that doesn't resolve transfer hook accounts cannot send a hook-enabled token. The transaction fails at the token program level, not as a silent fallback to a plain transfer.
- Receiving a hook-enabled token doesn't require any special handling. The hook only fires on the sender's transfer instruction; a wallet only needs transfer hook support once its user wants to send that token onward.
- Because the hook program can be updated by the mint's transfer hook authority, treat a transfer hook mint as something to re-check per transfer rather than a fact you learn once and cache indefinitely.
Recommended integration priorities
Wallets and dapps
| Requirement | Description | Priority |
|---|---|---|
| Detect the extension | Check getTransferHook on the mint before building a send flow for any Token-2022 asset. | P0 |
| Resolve extra accounts | Use the high-level helper (or the manual resolver functions) rather than hardcoding accounts. | P0 |
| Simulate before signing | Run the built transaction through simulation and surface hook rejections as a clear error, not a raw failure. | P0 |
| Surface required setup | Detect and prompt for any one-time setup a hook needs (delegate approval, side-account funding) before send. | P1 |
| Size compute budget for hook execution | Don't assume the default compute limit covers hook logic; request a limit sized for the observed cost. | P1 |
| Re-resolve on retry | If a previously-built transaction fails, re-fetch the ExtraAccountMetaList rather than resubmitting as-is. | P1 |
Custodians and exchanges
| Requirement | Description | Priority |
|---|---|---|
| Treat send paths as per-mint | A hook-enabled mint needs its own tested send path; don't assume a generic Token-2022 transfer path covers it. | P0 |
| Simulate before broadcasting | Especially important for automated or batched sends, where a hook rejection should halt the batch, not retry blindly. | P0 |
| Track hook program changes | Monitor mints you custody for UpdateTransferHook / UpdateExtraAccountMetaList activity, since it changes what a valid transfer requires. | P1 |
| Pre-provision required setup accounts | If a hook requires a delegate or side account per depositor, provision it as part of onboarding that asset, not at send time. | P1 |
Explorers and indexers
| Requirement | Description | Priority |
|---|---|---|
| Label transfer hook mints | Surface that a mint requires a transfer hook, and which program, distinctly from a plain Token-2022 mint. | P0 |
| Show the CPI, not just the transfer | A hook-enabled transfer includes a CPI into the hook program; represent it in the instruction breakdown. | P1 |
| Track hook program updates | Show UpdateTransferHook / UpdateExtraAccountMetaList activity for a mint as a distinct event type. | P2 |
Is this page helpful?