feat(token-2022/transfer-hook/account-data-as-seed): add pinocchio example - #711
Conversation
Greptile SummaryAdds a Pinocchio implementation of the Token-2022 account-data-as-seed transfer-hook example.
Confidence Score: 3/5The PR is not safe to merge until initialization of the canonical per-mint meta-list is restricted to the mint's configured transfer-hook authority. The reply from the unnamed author says the prior issue was fixed, but the change only reuses an existing owner counter across multiple mints; any unrelated signer can still create the mint's one-time meta-list first, preventing legitimate counter setup and causing subsequent owner transfers to fail. Files Needing Attention: tokens/token-2022/transfer-hook/account-data-as-seed/pinocchio/program/src/instructions/initialize_extra_account_meta_list.rs Important Files Changed
Reviews (3): Last reviewed commit: "token-2022 transfer-hook account-data-as..." | Re-trigger Greptile |
| if !payer.is_signer() { | ||
| return Err(ProgramError::MissingRequiredSignature); | ||
| } |
There was a problem hiding this comment.
Unauthenticated meta-list initialization
When an unrelated signer initializes a mint's canonical meta-list first, this handler permanently occupies the one-time PDA and creates only that signer's counter. The intended owner's transfers then resolve a nonexistent owner counter and fail, while the legitimate initializer cannot recreate the occupied meta-list; require the payer to match the mint's configured transfer-hook authority before creating these accounts.
How this was verified: The public initializer checks only the payer signature, creates the unique mint PDA once, and creates a payer-scoped counter while Execute requires the source owner's counter.
Knowledge Base Used:
There was a problem hiding this comment.
Good catch — fixed in 2d6e5152. The counter is keyed by owner rather than by mint, but setup created it unconditionally, so configuring a second mint for the same payer failed on CreateAccount and rolled back that mint's ExtraAccountMetaList with it. Setup now reuses an existing counter (and rejects one that is not this program's, correctly sized).
Verified: the new test Configures a second mint against the existing counter fails against the previous build with AccountAlreadyInUse and passes with the fix. 10 tests passing.
Inherited from the Anchor version, whose #[account(init, seeds = [b"counter", payer.key().as_ref()], bump, ...)] behaves the same way. Same fix applied to the sibling PRs #710 and #712.
…nter when configuring another mint
|
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. |
Adds a Pinocchio implementation of the Token-2022
account-data-as-seedtransfer hook, alongside the existing Anchor one.What the example demonstrates
The counter PDA is keyed by the token owner, and nobody ever passes the owner in. The
ExtraAccountMetaListrecords anAccountDataseed, so Token-2022 reads 32 bytes at offset 32 of the source token account — its owner field — derives[b"counter", owner]against this program, and hands the resulting account toExecute.That is the only difference from the sibling
counterhook, and it lives entirely in the 51-byte list encoding:[1, 7, b"counter"]isSeed::Literal;[4, 0, 32, 32]isSeed::AccountData { account_index: 0, data_index: 32, length: 32 }. There is no Pinocchio crate for Token-2022, so this is a documented constant rather than a dependency on the TLV encoder — and it is validated end-to-end, since a wrong encoding makes Token-2022 resolve a different account and the transfer fails.The program rederives the same owner bytes from the source account rather than trusting account index 3. Index 3 is the transfer authority, which may be a delegate and would then key a different counter than the one Token-2022 resolved.
Deliberate differences from the Anchor version
counter.checked_add(1)but never assigns it back (counter_accountis notmut), so its counter reports1on every transfer. This port stores it — covered by a test that transfers twice and asserts the counter reaches 2.[b"counter", payer]while itsTransferHookstruct declares[b"counter", owner]; those coincide only because the test's payer is the token owner. This port derives from the source account's owner in the hook, matching what the resolver actually does.Known limitation, inherited from the reference
InitializeExtraAccountMetaListcreates exactly one counter — the payer's — and cannot be called twice for the same mint, so a second token owner has no counter and cannot transfer. The Anchor version has the same single-counter setup instruction. I kept parity rather than adding an instruction the reference does not have; the last test documents the behaviour explicitly.Security checks on
ExecuteExecuteis a public entrypoint, so it does not trust its accounts: the source must be a Token-2022 account naming the invoked mint, the mint'sTransferHookextension must name this program, the counter must be the expected PDA and owned by this program, and thetransferringflag must be set.I did not port Anchor's
token::authority = ownerconstraint: Token-2022 passes the transfer's authority, which may be a delegate, so it would reject legitimate delegated transfers.Tests
9 LiteSVM tests. Verified locally:
tsc --noEmit,pnpm test,prettier --check,cargo fmt --check,cargo clippy -D warnings.