Skip to content

feat(token-2022/transfer-hook/hello-world): add pinocchio example - #709

Open
MarkFeder wants to merge 5 commits into
solana-foundation:mainfrom
MarkFeder:tokens-token-2022-transfer-hook-hello-world-pinocchio
Open

feat(token-2022/transfer-hook/hello-world): add pinocchio example#709
MarkFeder wants to merge 5 commits into
solana-foundation:mainfrom
MarkFeder:tokens-token-2022-transfer-hook-hello-world-pinocchio

Conversation

@MarkFeder

Copy link
Copy Markdown
Contributor

Ports the minimal transfer-hook example to Pinocchio, alongside the existing Anchor version.

What it does

Three instructions, matching the Anchor example:

  • initialize — creates a Token-2022 mint that names this program as its transfer hook, hand-building the TransferHookExtension(Initialize) and InitializeMint2 CPIs, then reading the extension back to confirm the mint was configured as intended.
  • initialize_extra_account_meta_list — creates the [b"extra-account-metas", mint] PDA holding the serialized ExtraAccountMetaList. This example resolves no extra accounts, so the list is a fixed 16 bytes: the Execute discriminator, a u32 length of 4, and a u32 count of 0.
  • Execute — the interface instruction Token-2022 CPIs during every transfer. It checks the source account's TransferHookAccount transferring flag, which is what stops the hook from being invoked directly, outside a transfer.

Notes

  • The two interface discriminators are fixed by spl-transfer-hook-interface (the first eight bytes of sha256("spl-transfer-hook-interface:<ix>")), so they are matched before this example's own one-byte tag for initialize.
  • There is no Pinocchio crate for Token-2022, so the TLV extension area is walked by a small bounds-checked reader in token2022.rs rather than depending on spl-token-2022. The same offsets serve mints and token accounts, since Token-2022 pads mints to Account::LEN.
  • The program derives its PDAs from the id it is invoked with and never asserts a hardcoded one, so the tests use a generated program id.

Tests

LiteSVM, 5 passing:

  • mint is created and its TransferHook extension decodes to the expected authority and program
  • the ExtraAccountMetaList account holds exactly the expected 16 bytes
  • token accounts are created and funded
  • a real TransferChecked moves tokens and the hook logs Hello Transfer Hook! from inside Token-2022's CPI, so the assertion fails if the hook is silently bypassed
  • a direct Execute call is rejected with IsNotCurrentlyTransferring (custom error 0x0), asserted on the reason rather than just on failure
  Token-2022 Transfer Hook — Hello World (Pinocchio)
    ✔ Creates a mint with the transfer hook extension
    ✔ Creates the ExtraAccountMetaList account
    ✔ Creates token accounts and mints tokens
    ✔ Runs the hook on a transfer
    ✔ Rejects calling the hook outside a transfer

  5 passing

Ports the minimal transfer-hook example to Pinocchio, covering the three
instructions the Anchor version exposes:

- initialize: creates a Token-2022 mint naming this program as its transfer
  hook, by hand-building the TransferHookExtension(Initialize) and
  InitializeMint2 CPIs, then reading the extension back to confirm it.
- initialize_extra_account_meta_list: creates the
  [b"extra-account-metas", mint] PDA holding the serialized, empty
  ExtraAccountMetaList that Token-2022 reads before every transfer.
- Execute: the interface instruction Token-2022 CPIs during a transfer.
  It checks the source account's TransferHookAccount `transferring` flag,
  which is what stops the hook being invoked outside a transfer.

The two interface discriminators are fixed by spl-transfer-hook-interface
(the first eight bytes of sha256("spl-transfer-hook-interface:<ix>")), so
they are matched before this example's own one-byte tag. There is no
Pinocchio crate for Token-2022, so the mint and token-account TLV
extension area is walked by a small bounds-checked reader rather than
depending on spl-token-2022.

LiteSVM tests cover mint creation and its decoded extension, the meta list
bytes, a real transfer that asserts the hook logged from inside Token-2022's
CPI, and a direct Execute call rejected with IsNotCurrentlyTransferring.
@MarkFeder
MarkFeder requested a review from dev-jodee as a code owner August 30, 2026 17:01
@greptile-apps

greptile-apps Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a Pinocchio implementation of the minimal Token-2022 transfer-hook example alongside the Anchor version.

  • Creates and configures a transfer-hook mint through hand-built Token-2022 CPIs.
  • Initializes the per-mint extra-account-meta-list PDA.
  • Implements the transfer-hook interface with source-account and mint-hook identity validation.
  • Adds LiteSVM coverage for initialization, real transfers, and direct-invocation bypass attempts.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the current source-account and mint-hook checks address both previously reported invocation-boundary issues.

Important Files Changed

Filename Overview
tokens/token-2022/transfer-hook/hello-world/pinocchio/program/src/instructions/transfer_hook.rs Implements Execute validation and now fully addresses both previously reported direct-invocation paths by authenticating the Token-2022 source relationship and configured hook identity.
tokens/token-2022/transfer-hook/hello-world/pinocchio/program/src/token2022.rs Adds a bounds-checked reader for the Token-2022 TLV extensions used by the hook checks.
tokens/token-2022/transfer-hook/hello-world/pinocchio/program/src/instructions/initialize.rs Creates and initializes the extended Token-2022 mint using the required extension-before-base-mint sequence.
tokens/token-2022/transfer-hook/hello-world/pinocchio/program/src/instructions/initialize_extra_account_meta_list.rs Creates the expected per-mint PDA and serializes an empty extra-account list for Execute.
tokens/token-2022/transfer-hook/hello-world/pinocchio/program/src/processor.rs Dispatches the fixed transfer-hook interface discriminators before the example-specific initialize tag.
tokens/token-2022/transfer-hook/hello-world/pinocchio/tests/test.ts Exercises the complete mint and transfer flow and includes focused regressions for forged source accounts and foreign-hook mints.
tokens/token-2022/transfer-hook/hello-world/pinocchio/package.json Defines standalone build and test tooling backed by a committed lockfile used under frozen-lockfile CI installation.

Reviews (5): Last reviewed commit: "token-2022 transfer-hook hello-world: cr..." | Re-trigger Greptile

Comment on lines +50 to +54
fn check_is_transferring(source_token: &AccountView) -> ProgramResult {
let account_data = source_token.try_borrow()?;
let extension = get_extension_data(&account_data, TRANSFER_HOOK_ACCOUNT)
.ok_or(TransferHookError::IsNotCurrentlyTransferring)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Authenticate the transferring flag

check_is_transferring parses the caller-provided source account without verifying that Token-2022 owns it or that it belongs to the supplied mint. A direct caller can therefore provide compatible TLV bytes with transferring = 1, pass the advertised direct-invocation guard, and emit the hook's success log outside a transfer; this also makes the example unsafe to copy into a stateful hook.

How this was verified: The public Execute path passes arbitrary source-account bytes directly to the ownership-agnostic TLV reader and accepts a type-15 extension whose first byte is 1.

Knowledge Base Used:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 4b7e8721. The source account must now be owned by Token-2022 and name the mint it was invoked with. A Token-2022 account can only reference a real Token-2022 mint, so that pair pins the source to an account Token-2022 itself produced, which is what makes the transferring flag worth reading — the same guarantee the Anchor version gets from InterfaceAccount<TokenAccount> + token::mint = mint.

Added a test that forges an account carrying a type-15 TLV with transferring = 1 for the real mint under a non-Token-2022 owner: it is rejected with InvalidSourceAccount (custom error 0x3). I confirmed that exact transaction succeeds against the previous program, so it reproduces the bypass rather than just asserting the new behaviour.

I deliberately did not also bind account index 3 to the token account owner the way the Anchor example does (token::authority = owner): Token-2022 passes the authority that signed the transfer, which may be a delegate, so that constraint would reject legitimate delegated transfers.

check_is_transferring parsed whatever account the caller passed as the
source, so the `transferring` flag it relied on was only as trustworthy as
that account. Anyone may call Execute directly, and an account built by the
caller with the right bytes at the right offsets reads as a type-15
TransferHookAccount extension with transferring = 1 — passing the guard and
reaching the hook body outside any transfer.

The flag is only meaningful if Token-2022 wrote it, so the source account is
now required to be owned by Token-2022 and to name the mint it was invoked
with. A Token-2022 account can only reference a real Token-2022 mint, so the
pair pins the source to an account Token-2022 itself produced. This is the
guarantee the Anchor version gets from InterfaceAccount<TokenAccount> and its
token::mint constraint.

Adds a test that forges an account carrying transferring = 1 for the real
mint under a non-Token-2022 owner; it is rejected with InvalidSourceAccount.
Against the previous program that same transaction succeeds.
…ports

CI's tsc --noEmit step rejected the raw bigint returned by
minimumBalanceForRentExemption where EncodedAccount expects the branded
Lamports type.
}

let account_data = source_token.try_borrow()?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Mint hook identity remains unverified

When a mint configured with another transfer-hook program is mid-transfer, that hook can CPI into this public Execute path with the genuine source account. The ownership, mint, and transferring checks all pass because this handler never confirms that the mint's TransferHook extension names this program, causing the hook body to run outside its configured hook path.

How this was verified: The handler checks Token-2022 ownership, source mint, and the transfer flag but never compares the mint's configured TransferHook program ID with this program.

Knowledge Base Used:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and it was genuinely exploitable — fixed in 23ef7c03.

Execute now reads the hook program back off the mint's TransferHook extension and rejects anything that is not this program (UnexpectedTransferHookConfig, custom error 2). The other checks only established that some transfer was in flight; nothing tied that transfer to this hook.

Verified rather than assumed: the new test Rejects a mint configured with a different hook program builds a genuine Token-2022 mint pointed at another hook program, puts a real token account for it mid-transfer, and calls Execute directly. Against the previous build the transaction succeeds and logs Hello Transfer Hook!; with the fix it fails with 0x2. 7 tests passing.

…ram as its hook

Execute only checked that the source account was a genuine Token-2022
account mid-transfer. A mint configured with a *different* hook program
is mid-transfer too while that program runs, and that program can CPI
here with the genuine source account, passing every check and running
the hook body outside its configured path.

Read the hook program back off the mint's TransferHook extension and
reject anything that is not this program. Covered by a test that is
verified to succeed without the check.
@MarkFeder

Copy link
Copy Markdown
Contributor Author

Audit follow-up from #714: every PDA this example creates has a publicly derivable address, and CreateAccount refuses to create over an account that already holds lamports — so anyone could send a single lamport to one of those addresses and permanently block the instruction meant to create it.

Fixed here too. PDA creation now goes through a create_pda_account helper that tops the account up to rent exemption, then allocates and assigns it — the same fallback Anchor's init performs, so this was a regression against the reference rather than something inherited.

Covered by pre-funding each derivable address with one lamport in the setup test before the creating instruction runs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant