Token-2022 Transfer-Hook Scaffold
Transfer hooks are the hardest Token-2022 extension to get right: the program must expose an interface discriminator Anchor doesn't generate, lay out an
ExtraAccountMetaListwith byte-exact ordering, and resist out-of-band invocation. Small mistakes surface as opaque runtime errors. This skill is the secure-by-default path, backed by a compiling program and LiteSVM attack tests.vs the token-extensions skills:
solana-token-extensions/token-2022-extensionstreat hooks as one feature (a single static example, plus a checklist audit in one case). This is the dedicated hook tool — the only one that generates a fresh secure hook on demand (scaffold.sh) and backs its auditor with passing attack tests. Complementary: install it alongside them when you're actually building or hardening a hook.
When to use this skill
- Building a transfer hook: whitelist/KYC gating, royalty enforcement, fee routing, per-transfer accounting/limits, soulbound-ish rules.
- Debugging a hook:
AccountNotFound,NotEnoughAccountKeys,IncorrectAccountAddress, or "transfer works without the hook but fails with it." - Reviewing/auditing an existing hook program for the standard footguns.
- Creating a Token-2022 mint that points at a hook, and wiring the client transfer.
Integrator note: if you are on the consuming side (accepting a third-party mint that has a hook), that's a different problem — most integrators should reject unknown hook mints. See the companion token2022-integration-guard skill.
The mental model (read this first)
- A hook program implements
Execute(the SPL transfer-hook interface), which Token-2022 CPIs on every transfer. Anchor dispatches bysha256("global:<ix>"), which does NOT match the interface discriminator — so you add afallbackthat decodes the interface instruction and dispatches in-process (never a self-CPI). → program.md - The hook can require extra accounts. You declare them once in an
ExtraAccountMetaListPDA (["extra-account-metas", mint]); Token-2022 reads it and passes those accounts toExecute, resolving seed-based PDAs at transfer time. Order is load-bearing. → extra-account-metas.md - The client must build the transfer with
createTransferCheckedWithTransferHookInstruction(a plaintransferCheckedomits the extra accounts and reverts). → client-wiring.md - A hook is attacker-reachable: guard it. → security.md
- Prove it with tests that transfer through the hook. → testing.md
Routing table
| The user is… | Read |
|---|---|
| Writing the hook program (Execute, fallback, init meta list) | program.md |
| Fighting ExtraAccountMetaList / opaque account errors | extra-account-metas.md |
| Wiring the mint + the client transfer | client-wiring.md |
| Hardening / auditing a hook for security | security.md |
| Writing tests for a hook | testing.md |
| Scaffolding a fresh hook workspace | run /transfer-hook:scaffold |
| Auditing an existing hook program | run /transfer-hook:audit |
| Sources / further reading | resources.md |
What ships in this skill
| Artifact | Path | What it is |
|---|---|---|
| Reference hook program | examples/hook/ | A compiling Anchor 1.0 hook: whitelist gate + per-mint counter, the fallback router, and the is_transferring guard. |
| Attack tests | examples/hook/tests/ | LiteSVM tests that transfer through the hook: whitelisted passes (counter increments), non-whitelisted fails closed, direct Execute rejected. |
| Scaffold script | scripts/scaffold.sh | Emits a fresh hook workspace from the reference. |
Pinned stack (2026)
Anchor 1.0, anchor-spl 1.0.2, spl-transfer-hook-interface =2.1.0,
spl-tlv-account-resolution 0.11, @solana/spl-token 0.4.14, litesvm 1.2 (kit-native,
Token-2022 preloaded). The reference program builds and its tests pass on this stack.
Non-negotiable rules
- Add a
fallbackthat decodesTransferHookInstruction::Executeand dispatches in-process (__private::__global::transfer_hook) — neverinvokeyour own program. - Initialize the
ExtraAccountMetaListbefore the first transfer, and keep theVec<ExtraAccountMeta>order identical to yourExecuteaccounts struct. - Guard
Executewithis_transferring— reject calls outside a real transfer. - Bind every injected account to PDA seeds (
Account<T>+seeds), never a bareUncheckedAccount, or an attacker spoofs your whitelist. - Keep the hook minimal and total — it runs on every transfer; a panic/heavy CU bricks the token.
- Test the transfer path, not just unit logic — a hook that compiles can still fail at resolve time.