Skip to content

feat(token-2022/transfer-hook/counter): add pinocchio example - #710

Open
MarkFeder wants to merge 4 commits into
solana-foundation:mainfrom
MarkFeder:tokens-token-2022-transfer-hook-counter-pinocchio
Open

feat(token-2022/transfer-hook/counter): add pinocchio example#710
MarkFeder wants to merge 4 commits into
solana-foundation:mainfrom
MarkFeder:tokens-token-2022-transfer-hook-counter-pinocchio

Conversation

@MarkFeder

Copy link
Copy Markdown
Contributor

Adds a Pinocchio implementation of the Token-2022 counter transfer hook, alongside the existing Anchor one.

What it does

Four instructions:

  • Initialize — creates a Token-2022 mint carrying the TransferHook extension pointed at this program. (The Anchor version creates the mint client-side; doing it in-program keeps the example self-contained and matches the sibling hello-world Pinocchio example.)
  • InitializeExtraAccountMetaList — writes the ExtraAccountMetaList PDA and creates the counter PDA.
  • Execute — the transfer-hook interface entrypoint. Verifies the transfer, then increments and persists the count.

There is no Pinocchio crate for Token-2022, so its instructions and TLV extension layout are built and parsed by hand (token2022.rs is a small bounds-checked TLV reader, shared with the hello-world example).

The ExtraAccountMetaList encoding

This is the first of my ports with a non-empty extra-account list. Rather than depend on the TLV encoder, the 51-byte layout is a documented constant:

[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", 0 * 23]             seed config, padded to 32 bytes
[0]                                    is_signer   = false
[1]                                    is_writable = true

This is validated end-to-end rather than by inspection: Token-2022 reads the list on-chain during the transfer, derives [b"counter"] itself, and passes the resulting account to Execute. A wrong encoding fails the transfer.

One deliberate difference from the Anchor version

The Anchor program computes the incremented count but never assigns it back (counter_account is not mut), so its counter reports 1 on every transfer and never actually advances. This port persists the new count, which is the behaviour the example is named for — covered by a test that transfers twice and asserts the counter reaches 2.

Security checks on Execute

Execute is a public entrypoint, so it does not trust the accounts it is handed:

  • the source account must be owned by Token-2022 and name the mint it was invoked with,
  • the mint's TransferHook extension must name this program (a mint hooked to a different program is mid-transfer too, and that program could otherwise CPI in),
  • the counter must be this program's counter PDA and owned by it before being written,
  • the transferring flag must be set.

The equivalent guarantees come from Anchor's InterfaceAccount / seeds constraints in the reference.

I did not port Anchor's token::authority = owner constraint: Token-2022 passes the transfer's authority, which may be a delegate, so that constraint would reject legitimate delegated transfers.

Tests

8 LiteSVM tests, including the two negative cases above and a forged-source-account case. 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:13
@greptile-apps

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a self-contained Pinocchio implementation of the Token-2022 counter transfer hook.

  • Creates and initializes a transfer-hook mint on-chain.
  • Adds prefunding-safe PDA creation and reuse of the global counter across mints.
  • Validates hook invocation state and persists the shared transfer count.
  • Adds LiteSVM coverage for initialization, transfers, forged accounts, prefunded PDAs, and multi-mint setup.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the current implementation resolves both previously reported counter-setup failures.

Important Files Changed

Filename Overview
tokens/token-2022/transfer-hook/counter/pinocchio/program/src/instructions/initialize_extra_account_meta_list.rs Creates the per-mint extra-account list and safely initializes or reuses the global counter PDA, resolving both previously reported setup failures.
tokens/token-2022/transfer-hook/counter/pinocchio/program/src/util.rs Adds a prefunding-tolerant PDA creation flow using rent top-up, signed allocation, and signed owner assignment.
tokens/token-2022/transfer-hook/counter/pinocchio/program/src/instructions/transfer_hook.rs Validates the Token-2022 hook context and increments the program-owned counter.
tokens/token-2022/transfer-hook/counter/pinocchio/program/src/token2022.rs Implements bounds-checked parsing of the Token-2022 layouts needed by initialization and hook execution.
tokens/token-2022/transfer-hook/counter/pinocchio/tests/test.ts Exercises successful and rejected hook flows, persistent counting, prefunded PDA initialization, and counter reuse across mints.

Reviews (4): Last reviewed commit: "token-2022 transfer-hook counter: create..." | Re-trigger Greptile

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

@MarkFeder

Copy link
Copy Markdown
Contributor Author

@amilz could you take a look at this one when you get a chance?

No open review threads left on it, so it is ready for maintainer review. It is one of 23 open Pinocchio ports I have up — they are independent and self-contained, so they can be reviewed and merged in any order: https://github.com/solana-developers/program-examples/pulls/MarkFeder

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