Skip to content

feat(token-2022/transfer-hook/transfer-cost): add pinocchio example - #712

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

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

Conversation

@MarkFeder

Copy link
Copy Markdown
Contributor

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

What it does

Every transfer of the hooked mint charges a fee in wrapped SOL equal to the token amount, moving it from the sender's wSOL account to a delegate's, and bumps a transfer counter.

The fee cannot be signed for by whoever signed the transfer — Token-2022 CPIs into Execute without forwarding signer privileges, so nothing in the hook's account list is a signer. The sender instead approves a PDA of this program as a delegate on their wSOL account beforehand, and the hook signs as that PDA. This is also why the fee is wrapped SOL rather than lamports: a delegate can move tokens, not a wallet's SOL.

The ExtraAccountMetaList

This is the involved part, and the reason this example is worth having in Pinocchio. Seven accounts are resolved, and Token-2022 derives all of them itself from a 261-byte list written at setup:

  5  wrapped SOL mint          literal address
  6  SPL Token program         literal address
  7  associated token program  literal address
  8  delegate                  PDA of this program, [b"delegate"]
  9  delegate's wSOL account   ATA of account 8
 10  sender's wSOL account     ATA of account 3
 11  counter                   PDA of this program, [b"counter"]

There is no Pinocchio crate for Token-2022, so the list is encoded by hand (build_extra_account_metas). Two encoding details are worth recording, since neither is obvious and both are silent when wrong:

  • A meta's kind byte is 0 for a literal address, 1 for a PDA of the hook program, and for a PDA of another program it is 0x80 | index, where the index points at that program in the Execute account list. It is not index + 22 is a distinct kind that reads an address out of another account's data. Getting this wrong makes Token-2022 fail the whole transfer with a bare InvalidAccountData, before the hook is ever invoked.
  • Seed configs pack as Seed::Literal → [1, len, ...bytes] and Seed::AccountKey → [3, index], zero-padded to 32 bytes. Accounts 9 and 10 are ATAs, so their seeds are [owner, token program, mint] given as account-key references — which is how the sender's wSOL account is found without any caller naming it.

The test rebuilds the same 261 bytes independently and compares them to what the program wrote, so an encoding change fails loudly and locally rather than as an opaque transfer failure.

Security checks on Execute

Execute is a public entrypoint and this one moves money, so it rederives everything Token-2022 resolved rather than trusting the account list: the source must be a Token-2022 account naming the invoked mint and mid-transfer, the mint's TransferHook extension must name this program, the wSOL mint and both programs must be the expected addresses, the delegate and counter must be this program's PDAs, and both wSOL accounts must be the ATAs derived from the delegate and the transfer authority.

Without the mid-transfer check in particular, anyone could call Execute directly and drain the approved allowance one fee at a time; there are tests for that and for a substituted fee destination.

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

Differences from the Anchor version

  • The counter is a u64 rather than a u8. This crate builds with overflow-checks, so a u8 counter would start aborting transfers after 255 of them.
  • The counter has no 8-byte account discriminator, so its account is 8 bytes rather than 9.

Tests

10 LiteSVM tests covering the fee path, repeat transfers, and four rejection cases. Note the suite creates the native mint itself — LiteSVM does not seed So111...112. 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 11:19
@greptile-apps

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a Pinocchio implementation of the Token-2022 transfer-cost hook alongside the existing Anchor example.

  • Encodes and initializes the transfer hook’s extra-account metadata and shared counter PDAs.
  • Charges transfer amounts in wrapped SOL through a program delegate while validating the Token-2022 transfer context and resolved accounts.
  • Adds LiteSVM coverage for fee transfers, account validation, repeated transfers, pre-funded PDAs, and multiple hooked mints.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; both previously reported initialization failures are addressed by the current account-creation and counter-reuse logic.

Important Files Changed

Filename Overview
tokens/token-2022/transfer-hook/transfer-cost/pinocchio/program/src/instructions/initialize_extra_account_meta_list.rs Initializes the per-mint metadata list and safely creates or reuses the global counter, including handling pre-funded PDA addresses.
tokens/token-2022/transfer-hook/transfer-cost/pinocchio/program/src/instructions/transfer_hook.rs Validates the hook execution context, transfers the wrapped-SOL fee through the delegate PDA, and increments the shared counter.
tokens/token-2022/transfer-hook/transfer-cost/pinocchio/program/src/util.rs Creates pre-funded PDA accounts through rent top-up, allocation, and assignment rather than an incompatible CreateAccount call.
tokens/token-2022/transfer-hook/transfer-cost/pinocchio/program/src/token2022.rs Implements the Token-2022 extension parsing needed to validate active transfer-hook execution.
tokens/token-2022/transfer-hook/transfer-cost/pinocchio/tests/test.ts Exercises initialization, metadata encoding, fee collection, repeated transfers, rejection cases, pre-funded PDAs, and second-mint setup.

Reviews (3): Last reviewed commit: "token-2022 transfer-hook transfer-cost: ..." | 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