Transfer Hook Integration Guide

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

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 ExtraAccountMetaList account, 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 the ExtraAccountMetaList natively: Kit via getTransferCheckedWithTransferHookInstructionAsync, Web3.js via createTransferCheckedWithTransferHookInstruction.
  • 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's Execute instruction 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.
  • TransferHookAccount extension: state on a token account that includes a transferring flag, set to true only 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 of TransferChecked.

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.

send-transfer-hook-token-kit.ts
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.

send-transfer-hook-token.ts
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:

derive-extra-account-meta-list.ts
// 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 the ExtraAccountMetaList validation account's PDA.
  • getExtraAccountMetasDecoder().decode(accountData): parses the raw validation account data into a list of ExtraAccountMeta entries.
  • resolveExtraAccountMeta(meta, previousAddresses, instructionData, hookProgramAddress, rpc): resolves one entry into an AccountMeta, 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 raw ExtraAccountMetaList account data into a list of ExtraAccountMeta entries.
  • resolveExtraAccountMeta(connection, meta, previousMetas, instructionData, hookProgramId): resolves one entry into an AccountMeta, 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 ExtraAccountMetaList between 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.

Wallets and dapps

RequirementDescriptionPriority
Detect the extensionCheck getTransferHook on the mint before building a send flow for any Token-2022 asset.P0
Resolve extra accountsUse the high-level helper (or the manual resolver functions) rather than hardcoding accounts.P0
Simulate before signingRun the built transaction through simulation and surface hook rejections as a clear error, not a raw failure.P0
Surface required setupDetect and prompt for any one-time setup a hook needs (delegate approval, side-account funding) before send.P1
Size compute budget for hook executionDon't assume the default compute limit covers hook logic; request a limit sized for the observed cost.P1
Re-resolve on retryIf a previously-built transaction fails, re-fetch the ExtraAccountMetaList rather than resubmitting as-is.P1

Custodians and exchanges

RequirementDescriptionPriority
Treat send paths as per-mintA hook-enabled mint needs its own tested send path; don't assume a generic Token-2022 transfer path covers it.P0
Simulate before broadcastingEspecially important for automated or batched sends, where a hook rejection should halt the batch, not retry blindly.P0
Track hook program changesMonitor mints you custody for UpdateTransferHook / UpdateExtraAccountMetaList activity, since it changes what a valid transfer requires.P1
Pre-provision required setup accountsIf 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

RequirementDescriptionPriority
Label transfer hook mintsSurface that a mint requires a transfer hook, and which program, distinctly from a plain Token-2022 mint.P0
Show the CPI, not just the transferA hook-enabled transfer includes a CPI into the hook program; represent it in the instruction breakdown.P1
Track hook program updatesShow UpdateTransferHook / UpdateExtraAccountMetaList activity for a mint as a distinct event type.P2

Is this page helpful?

© 2026 Solana Foundation. Tutti i diritti riservati.
Transfer Hook Integration Guide | Solana