Skip to content

feat(token-2022/transfer-hook/transfer-switch): add pinocchio example - #713

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

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

Conversation

@MarkFeder

@MarkFeder MarkFeder commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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

What it does

A kill switch per wallet. An admin can turn a wallet's transfers of the hooked mint on or off, and the hook refuses any transfer whose sender is switched off — without touching the mint or the token accounts.

Five instructions:

  • Initialize — creates a Token-2022 mint with the TransferHook extension pointed at this program. (The Anchor version builds the mint client-side; doing it in-program keeps the example self-contained and matches the sibling Pinocchio hook examples.)
  • ConfigureAdmin — installs the admin. The first call is unchallenged; later calls must be signed by the current admin and cannot reinstall them.
  • InitializeExtraAccountMetaList — writes the list Token-2022 reads before each transfer.
  • Switch — admin-only; creates the wallet's switch account on first use and sets it on or off.
  • Execute — the transfer-hook interface entrypoint.

How the switch is found

The ExtraAccountMetaList holds a single meta: a PDA of this program whose seed config is Seed::AccountData { account_index: 0, data_index: 32, length: 32 } — 32 bytes at offset 32 of the source token account, i.e. its owner. So Token-2022 derives [owner] itself and passes the switch in, and no caller ever names it. That is the whole 51-byte list:

[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
[4, 0, 32, 32, 0 * 28]                 seed config: AccountData(acct 0, off 32, len 32)
[0]                                    is_signer   = false
[0]                                    is_writable = false

There is no Pinocchio crate for Token-2022, so this is a documented constant rather than a dependency on the TLV encoder; the test compares it byte-for-byte against what the program writes.

Why the owner and not account 3. The Anchor version keys the switch on seeds=[wallet.key().as_ref()] where wallet is account 3 — the transfer authority. That authority may be a delegate, or Token-2022's permanent delegate, so an enabled delegate can move tokens straight out of a wallet the admin has switched off. Keying on the token account's owner puts the policy where it belongs. There is a test that demonstrates the bypass against the authority-keyed version.

Default-deny

A wallet that has never been switched on has no switch account at all. The hook treats that as off rather than as an account it can skip, so a mint is unusable until the admin explicitly enables senders. There is a test for it.

Security checks on Execute

Execute is a public entrypoint, so it does not trust the accounts it is handed: the source must be a Token-2022 account naming the invoked mint and mid-transfer, the mint's TransferHook extension must name this program, and the switch must be the PDA rederived from the source account's owner and owned by this program. Without that last check, any wallet's switched-on account would authorise any other wallet's transfer — covered by the "substituted switch account" test.

The mint check matters here too: a mint hooked to a different program is mid-transfer while that program runs, and that program could otherwise CPI in and get a "transfer allowed" answer from this one.

Differences from the Anchor version

  • The admin config stores just the admin address. Anchor also stores an is_initialised flag because init_if_needed hands it a zeroed account either way and cannot otherwise tell first call from later ones; here the account is created explicitly, so its existence is the flag.
  • Neither account carries an 8-byte Anchor discriminator, so the config is 32 bytes and each switch 33.
  • The switch is keyed on the token owner rather than the transfer authority, as described above.

Tests

14 LiteSVM tests: the on/off cycle end to end, admin handover with the old admin losing access, the delegate bypass, and five rejection cases. 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:59
@greptile-apps

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a Pinocchio implementation of the Token-2022 transfer-switch hook alongside the Anchor example.

  • Creates and configures the Token-2022 mint, administrator state, wallet switches, and extra-account metadata.
  • Resolves transfer policy from the source token account owner so delegates cannot bypass a disabled owner.
  • Adds LiteSVM coverage for normal operation, administration, default denial, delegated transfers, and invalid hook inputs.

Confidence Score: 5/5

The PR appears safe to merge.

The previously reported delegate-authority bypass is fixed by deriving and validating the switch from the source token account owner, and no blocking failure remains.

Important Files Changed

Filename Overview
tokens/token-2022/transfer-hook/transfer-switch/pinocchio/program/src/instructions/transfer_hook.rs Validates the Token-2022 transfer context and checks the switch PDA derived from the genuine source token account owner; the previously reported delegate bypass is fixed.
tokens/token-2022/transfer-hook/transfer-switch/pinocchio/program/src/instructions/initialize_extra_account_meta_list.rs Serializes account-resolution metadata that derives the switch from bytes 32–63 of the source token account, matching the hook’s owner-based derivation.
tokens/token-2022/transfer-hook/transfer-switch/pinocchio/tests/test.ts Covers the owner-based delegate fix, substituted switch rejection, default denial, transfer lifecycle, and public-entrypoint validation.
tokens/token-2022/transfer-hook/transfer-switch/pinocchio/program/src/instructions/switch.rs Implements administrator-authorized creation and updates of wallet-scoped switch PDAs.
tokens/token-2022/transfer-hook/transfer-switch/pinocchio/program/src/instructions/configure_admin.rs Implements first-time administrator installation and authenticated handover.
tokens/token-2022/transfer-hook/transfer-switch/pinocchio/program/src/instructions/initialize.rs Creates and initializes the Token-2022 mint with this program configured as its transfer hook.

Reviews (3): Last reviewed commit: "token-2022 transfer-switch: create PDAs ..." | Re-trigger Greptile

…e transfer authority

The ExtraAccountMetaList resolved the switch from account 3, the transfer
authority. That authority may be a delegate or Token-2022's permanent
delegate, so a delegate whose own switch was on could move tokens out of
a wallet the admin had switched off.

Resolve the switch from the source token account's owner instead, via an
AccountData seed, and rederive the same owner in the hook.
@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