feat(token-2022/transfer-hook/allow-block-list-token): add pinocchio example - #717
Conversation
Greptile SummaryThe PR adds a Pinocchio implementation of the Token-2022 allow/block-list transfer hook alongside the existing Anchor example.
Confidence Score: 5/5The 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
Reviews (3): Last reviewed commit: "abl-token: validate the threshold too be..." | Re-trigger Greptile |
| 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)?; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| let metadata = read_ab_metadata(&mint_data, [MODE_KEY, THRESHOLD_KEY])?; | ||
| let mode = metadata.mode.ok_or(AblError::InvalidMetadata)?; | ||
| Mode::from_metadata_value(&mode)?; |
There was a problem hiding this comment.
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.
| 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)?; |
There was a problem hiding this comment.
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.
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:
Eight instructions:
InitConfig,InitMint,AttachToMint,InitWallet,RemoveWallet,ChangeMode,ResizeMetaList, and the interface'sExecute.Reading the mode out of the mint
The mode lives in the mint's
TokenMetadataextension under anABkey, with an optionalthreshold— 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, sometadata.rsparses 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 againstspl-transfer-hook-interface:execute, which reproduced the value already shipping in the sibling hook examples.init_mintalso builds a mint carrying three extensions —PermanentDelegate,TransferHookandMetadataPointer— which have to be initialized beforeInitializeMint2, 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
TxHookstruct declares every accountUncheckedAccountand 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'sTransferHookextension 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
Configand eachABWalletare 33 bytes.Modetravels as au8in instruction data and as the same string as Anchor'sDisplayin metadata.RemoveWalletmoves the rent to the authority before closing — pinocchio'sclose()zeroes the lamports field outright, so closing first destroys them and unbalances the instruction.ResizeMetaListis 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.