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

Comment on lines +162 to +169
CreateAccount {
from: payer,
to: counter,
lamports: Rent::get()?.try_minimum_balance(COUNTER_SIZE)?,
space: COUNTER_SIZE as u64,
owner: program_id,
}
.invoke_signed(&[Signer::from(&counter_seeds)])?;

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 When a metadata list is initialized for a second mint, this code unconditionally tries to create the already-existing global counter PDA. The System Program rejects that creation and aborts the instruction, so the second mint cannot be configured to use this hook.

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 2e6cac83. The counter is global rather than per-mint, but setup created it unconditionally, so CreateAccount failed for every mint after the first 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. 11 tests passing.

Inherited from the Anchor version, whose #[account(init, seeds = [b"counter"], bump, ...)] behaves the same way. Same fix applied to the sibling PRs #710 and #711.

Comment on lines +165 to +177
if counter.is_data_empty() {
let counter_bump_bytes = [counter_bump];
let counter_seeds = [Seed::from(COUNTER_SEED), Seed::from(&counter_bump_bytes)];

log!("Creating counter");
CreateAccount {
from: payer,
to: counter,
lamports: Rent::get()?.try_minimum_balance(COUNTER_SIZE)?,
space: COUNTER_SIZE as u64,
owner: program_id,
}
.invoke_signed(&[Signer::from(&counter_seeds)])?;

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 Pre-funded counter blocks initialization

When any account pre-funds the publicly derivable counter PDA, is_data_empty() still selects the creation branch, and the System Program rejects CreateAccount because the destination already holds lamports. This aborts setup and rolls back the new mint's extra-account metadata list, allowing an arbitrary sender to prevent this program instance from being initialized.

How this was verified: The initializer treats a publicly derivable, funded zero-data PDA as uninitialized and invokes System Program account creation without handling its existing lamports.

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.

Already fixed — this review landed against the pre-fix commit. b84d6626 routes every PDA this example creates (the metas list and the counter) through a create_pda_account helper that tops the account up to rent exemption, then Allocates and Assigns it, so an existing balance no longer aborts setup. There is no bare CreateAccount left in the file.

Same fallback Anchor's init performs, so this was a regression against the reference rather than something inherited. Covered by pre-funding both derivable addresses with one lamport in the setup test before the creating instruction runs; 11 tests passing.

Found via #714 and applied across #709#713 in the same pass.

@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