Skip to content

feat(token-2022/transfer-hook/account-data-as-seed): add pinocchio example - #711

Open
MarkFeder wants to merge 3 commits into
solana-foundation:mainfrom
MarkFeder:tokens-token-2022-transfer-hook-account-data-as-seed-pinocchio
Open

feat(token-2022/transfer-hook/account-data-as-seed): add pinocchio example#711
MarkFeder wants to merge 3 commits into
solana-foundation:mainfrom
MarkFeder:tokens-token-2022-transfer-hook-account-data-as-seed-pinocchio

Conversation

@MarkFeder

Copy link
Copy Markdown
Contributor

Adds a Pinocchio implementation of the Token-2022 account-data-as-seed transfer 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 ExtraAccountMetaList records an AccountData seed, 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 to Execute.

That is the only difference from the sibling counter hook, and it lives entirely in the 51-byte list encoding:

[105, 37, 101, 197, 75, 251, 102, 26]  Execute discriminator
[39, 0, 0, 0]                          value length (u32) = 4 + 1 * 35
[1, 0, 0, 0]                           account count (u32) = 1
[1]                                    address is a PDA of this program
[1, 7, b"counter", 4, 0, 32, 32, 0*19] seed config, padded to 32 bytes
[0]                                    is_signer   = false
[1]                                    is_writable = true

[1, 7, b"counter"] is Seed::Literal; [4, 0, 32, 32] is Seed::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

  • The count is persisted. Anchor computes counter.checked_add(1) but never assigns it back (counter_account is not mut), so its counter reports 1 on every transfer. This port stores it — covered by a test that transfers twice and asserts the counter reaches 2.
  • Consistent seeds. Anchor's setup instruction creates the counter at [b"counter", payer] while its TransferHook struct 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

InitializeExtraAccountMetaList creates 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 Execute

Execute is a public entrypoint, so it does not trust its accounts: the source must be a Token-2022 account naming the invoked mint, the mint's TransferHook extension must name this program, the counter must be the expected PDA and owned by this program, and the transferring flag must be set.

I did not port Anchor's token::authority = owner constraint: 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.

@MarkFeder
MarkFeder requested a review from dev-jodee as a code owner August 31, 2026 10:27
@greptile-apps

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a Pinocchio implementation of the Token-2022 account-data-as-seed transfer-hook example.

  • Encodes an owner-derived counter PDA in the per-mint extra-account-meta list.
  • Validates Token-2022 transfer state and persists per-owner transfer counts.
  • Adds LiteSVM coverage, workspace configuration, documentation, and build tooling.

Confidence Score: 3/5

The 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

Filename Overview
tokens/token-2022/transfer-hook/account-data-as-seed/pinocchio/program/src/instructions/initialize_extra_account_meta_list.rs Creates the canonical extra-account-meta list and creates or reuses the payer-scoped counter.
tokens/token-2022/transfer-hook/account-data-as-seed/pinocchio/program/src/instructions/transfer_hook.rs Validates the hook invocation and increments the counter derived from the source token account owner.
tokens/token-2022/transfer-hook/account-data-as-seed/pinocchio/program/src/token2022.rs Implements a bounds-checked parser for the Token-2022 TLV extensions used by the hook.
tokens/token-2022/transfer-hook/account-data-as-seed/pinocchio/tests/test.ts Exercises mint setup, account-meta encoding, repeated transfers, delegated transfers, counter reuse, and unsupported-owner behavior.

Reviews (3): Last reviewed commit: "token-2022 transfer-hook account-data-as..." | Re-trigger Greptile

Comment on lines +85 to +87
if !payer.is_signer() {
return Err(ProgramError::MissingRequiredSignature);
}

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 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:

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 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.

@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