feat(token-2022/transfer-hook/hello-world): add pinocchio example - #709
Conversation
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.
Greptile SummaryThe PR adds a Pinocchio implementation of the minimal Token-2022 transfer-hook example alongside the Anchor version.
Confidence Score: 5/5The 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
Reviews (5): Last reviewed commit: "token-2022 transfer-hook hello-world: cr..." | Re-trigger Greptile |
| 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)?; | ||
|
|
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
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()?; | ||
|
|
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
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.
|
Audit follow-up from #714: every PDA this example creates has a publicly derivable address, and Fixed here too. PDA creation now goes through a Covered by pre-funding each derivable address with one lamport in the setup test before the creating instruction runs. |
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 theTransferHookExtension(Initialize)andInitializeMint2CPIs, 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 serializedExtraAccountMetaList. This example resolves no extra accounts, so the list is a fixed 16 bytes: theExecutediscriminator, au32length of 4, and au32count of 0.Execute— the interface instruction Token-2022 CPIs during every transfer. It checks the source account'sTransferHookAccounttransferringflag, which is what stops the hook from being invoked directly, outside a transfer.Notes
spl-transfer-hook-interface(the first eight bytes ofsha256("spl-transfer-hook-interface:<ix>")), so they are matched before this example's own one-byte tag forinitialize.token2022.rsrather than depending onspl-token-2022. The same offsets serve mints and token accounts, since Token-2022 pads mints toAccount::LEN.Tests
LiteSVM, 5 passing:
TransferHookextension decodes to the expected authority and programExtraAccountMetaListaccount holds exactly the expected 16 bytesTransferCheckedmoves tokens and the hook logsHello Transfer Hook!from inside Token-2022's CPI, so the assertion fails if the hook is silently bypassedExecutecall is rejected withIsNotCurrentlyTransferring(custom error0x0), asserted on the reason rather than just on failure