Skip to content

feat(token-2022/transfer-hook/allow-block-list-token): add pinocchio example - #717

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

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

Conversation

@MarkFeder

Copy link
Copy Markdown
Contributor

Adds a Pinocchio implementation of allow-block-list-token, alongside the existing Anchor one. This is the last of the six transfer-hook variants.

What it does

A transfer hook enforcing an allow/block list, with the policy stored in the mint's own token metadata so it travels with the token:

  • Allow — only wallets explicitly on the list may receive.
  • Block — anyone may transact except wallets explicitly blocked.
  • Mixed — like Block, but transfers at or above a threshold need an allowed receiver.

Eight instructions: InitConfig, InitMint, AttachToMint, InitWallet, RemoveWallet, ChangeMode, ResizeMetaList, and the interface's Execute.

Reading the mode out of the mint

The mode lives in the mint's TokenMetadata extension under an AB key, with an optional threshold — exactly where the Anchor version puts it, so both implementations read each other's mints. There is no Pinocchio crate for Token-2022 or the token-metadata interface, so metadata.rs parses the variable-length TLV by hand (update_authority | mint | name | symbol | uri | additional[], all borsh strings, every read bounds-checked) and builds the two interface instructions itself.

Their discriminators are the first eight bytes of sha256("spl_token_metadata_interface:initialize_account") and …:updating_field. I computed those from the preimages rather than copying them from anywhere, and cross-checked the method against spl-transfer-hook-interface:execute, which reproduced the value already shipping in the sibling hook examples.

init_mint also builds a mint carrying three extensions — PermanentDelegate, TransferHook and MetadataPointer — which have to be initialized before InitializeMint2, since Token-2022 refuses extension setup afterwards.

The decision logic

decide() is a pure function of the decoded mint mode, both wallet states and the amount, so it is unit-testable without building accounts. It carries 8 #[cfg(test)] tests, mirroring the Anchor version's — including the one guarding that a blocked sender is rejected in every mint mode, which is the side that is easy to omit.

Validation this port adds

The Anchor TxHook struct declares every account UncheckedAccount and validates nothing. That is defensible there — the hook only reads and returns a verdict — but it means a direct call is answered on whatever accounts the caller supplies. This port refuses one: the metas list must be the mint's PDA, the mint's TransferHook extension must name this program, the source must be a Token-2022 account for that mint and mid-transfer, and both wallet records must be the PDAs derived from the owners recorded in the source and destination token accounts.

That last one matters most: the records are what the verdict is read from, so if a caller could nominate them the answer would be theirs to choose.

Differences from the Anchor version

  • No 8-byte account discriminators, so Config and each ABWallet are 33 bytes.
  • Mode travels as a u8 in instruction data and as the same string as Anchor's Display in metadata.
  • RemoveWallet moves the rent to the authority before closing — pinocchio's close() zeroes the lamports field outright, so closing first destroys them and unbalances the instruction.
  • ResizeMetaList is permissionless, as in the reference: the content is fully determined by the mint and this program's fixed list, so gating it would strand mints whose hook authority was revoked.

Tests

13 LiteSVM tests covering all three modes end to end (including the blocked-sender case and the mixed-mode threshold on both sides), plus non-authority, wrong-mint and direct-call rejections — and 8 unit tests on decide(). Verified locally: cargo test, 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 19:11
@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 allow/block-list transfer hook alongside the existing Anchor example.

  • Implements mint initialization, policy metadata, wallet records, hook execution, and meta-list management.
  • Adds unit and LiteSVM coverage for all policy modes, authorization boundaries, malformed attachment metadata, and direct-call rejection.
  • Integrates the example into the Cargo workspace, package tooling, CI-compatible scripts, and repository documentation.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the current attachment guard rejects both missing policy metadata and malformed thresholds before activating the hook.

Important Files Changed

Filename Overview
tokens/token-2022/transfer-hook/allow-block-list-token/pinocchio/program/src/instructions/mint.rs Implements extended-mint creation, policy updates, hook attachment, and meta-list resizing; current attachment validation fixes both previously reported malformed-policy paths.
tokens/token-2022/transfer-hook/allow-block-list-token/pinocchio/program/src/metadata.rs Adds bounds-checked TokenMetadata parsing and manual metadata-interface instruction construction used consistently by attachment and execution.
tokens/token-2022/transfer-hook/allow-block-list-token/pinocchio/program/src/instructions/tx_hook.rs Validates the Token-2022 transfer context and evaluates mint and wallet policy during hook execution.
tokens/token-2022/transfer-hook/allow-block-list-token/pinocchio/program/src/decide.rs Encapsulates allow, block, and threshold policy decisions as a unit-tested pure function.
tokens/token-2022/transfer-hook/allow-block-list-token/pinocchio/tests/test.ts Covers end-to-end policy behavior and verifies rejection of missing or malformed policy metadata before hook attachment.

Reviews (3): Last reviewed commit: "abl-token: validate the threshold too be..." | Re-trigger Greptile

Comment on lines +241 to +252
let mut data = Vec::with_capacity(34);
data.push(TRANSFER_HOOK_EXTENSION);
data.push(EXTENSION_UPDATE);
data.extend_from_slice(program_id.as_ref());
let accounts_meta =
[InstructionAccount::writable(mint.address()), InstructionAccount::readonly_signer(payer.address())];
invoke(
&InstructionView { program_id: &TOKEN_2022_PROGRAM_ID, accounts: &accounts_meta, data: &data },
&[*mint, *payer],
)?;

write_meta_list(program_id, payer, mint, meta_list)?;

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 AttachToMint receives an existing Token-2022 mint without the TokenMetadata extension or its AB key, it activates this hook and creates the meta list without establishing the policy that Execute requires. Every subsequent transfer then fails with InvalidMetadata, and a mint without TokenMetadata cannot be recovered through ChangeMode because that path only updates existing metadata.

Knowledge Base Used: Token-2022 extension patterns

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 1baf989c.

attach_to_mint now requires the mint to already carry a parseable AB policy, and refuses otherwise. Attaching without one switched the hook on over metadata Execute cannot read, and as you say ChangeMode only updates metadata that already exists — so a mint with no TokenMetadata was left with every transfer failing and no route back. Refusing up front makes that state unreachable rather than merely recoverable. The intended order is ChangeMode to set the mode, then attach_to_mint; that is now in the doc comment.

Verified rather than assumed: the new test Refuses to attach to a mint carrying no policy points the instruction at a bare 82-byte Token-2022 mint. Against the previous build the attach succeeds and the meta list is created — the exact bricked state you describe; with the fix it is rejected with InvalidMetadata and no list is created. 14 LiteSVM tests plus 8 unit tests passing.

Worth noting this is inherited: the Anchor attach_to_mint has no such check either, and its tx_hook also fails on a mint without TokenMetadata. I fixed it rather than documenting it because bricking a mint is a bad enough outcome to be worth diverging over, and the guard costs nothing on the successful path.

attach_to_mint switched the hook on without checking that the mint
carries an AB policy Execute can read. A mint with no TokenMetadata was
left with every transfer failing and no way back, since ChangeMode can
only update metadata that already exists.

Require a parseable AB mode before attaching. Set the mode with
ChangeMode first, then attach. Covered by a test verified to attach
successfully without the check.
Comment on lines +249 to +251
let metadata = read_ab_metadata(&mint_data, [MODE_KEY, THRESHOLD_KEY])?;
let mode = metadata.mode.ok_or(AblError::InvalidMetadata)?;
Mode::from_metadata_value(&mode)?;

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 Malformed threshold survives attachment

When an existing mint has a valid AB value but a non-decimal threshold, AttachToMint activates the hook without validating the threshold. Every subsequent transfer then fails with InvalidMetadata when Execute parses that value.

Suggested change
let metadata = read_ab_metadata(&mint_data, [MODE_KEY, THRESHOLD_KEY])?;
let mode = metadata.mode.ok_or(AblError::InvalidMetadata)?;
Mode::from_metadata_value(&mode)?;
let metadata = read_ab_metadata(&mint_data, [MODE_KEY, THRESHOLD_KEY])?;
if let Some(threshold) = metadata.threshold {
decimal_to_u64(&threshold)?;
}
let mode = metadata.mode.ok_or(AblError::InvalidMetadata)?;
Mode::from_metadata_value(&mode)?;

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.

Right — fixed in 06b900c9, and your suggestion is what I applied.

My guard validated the mode but not the threshold, which left the same brick reachable by a different route: Execute parses both, so it fails on either. The guard now checks everything the hook will later read, which is the property it should have had from the start rather than a check per field I happened to think of.

Verified: the new test Refuses to attach to a mint whose threshold is malformed writes threshold = "not-a-number" onto a mint with a valid Allow mode using a raw UpdateField, then attaches. Against the previous build the attach succeeds and the meta list is created — the bricked state; with the fix it is rejected with InvalidMetadata and no list is created. 15 LiteSVM tests plus 8 unit tests passing.

One incidental find worth recording for anyone writing metadata by hand: UpdateField grows the mint, so the test has to top up its rent in the same transaction or the write fails with InsufficientFundsForRent. That is also why init_mint and change_mode both call top_up_rent after writing.

The attach guard checked the AB mode but not the threshold, so a mint
with a valid mode and a non-decimal threshold still bricked: Execute
parses both, and fails on either.

Validate everything Execute will later read. Covered by a test that
writes a malformed threshold with a raw UpdateField and is verified to
attach successfully without the check.
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