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

Comment on lines +115 to +122
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 Shared counter blocks setup

When setup runs after any earlier successful initialization, it unconditionally attempts to create the same global counter PDA, causing CreateAccount to fail and roll back the new mint's ExtraAccountMetaList. Because the handler accepts any mint and only requires a payer signature, an unauthorized caller can initialize first with an arbitrary mint and prevent the intended mint from being configured. How this was verified: The public handler derives the counter solely from b"counter" and invokes CreateAccount for that address on every call.

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 dcb7b166. 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. 9 tests passing.

Worth noting this is inherited — the Anchor version's #[account(init, seeds = [b"counter"], bump, ...)] has the same single-use behaviour. Same fix applied to the sibling PRs #711 and #712.

}

// Because that counter is global rather than per-mint, setting up a second
// mint finds it already there. Creating it again would fail and take the

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 Prefunded counter still blocks setup

When any caller transfers lamports to the deterministic counter PDA before setup, the account still has empty data, so this branch invokes plain CreateAccount. The System Program rejects the already-funded destination with AccountAlreadyInUse, rolling back the mint's ExtraAccountMetaList creation and allowing setup to be blocked globally.

How this was verified: The complete handler has no prefund-aware creation or allocation path, and every empty-data counter is passed to plain CreateAccount.

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. 5bdbf238 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; 9 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