feat(token-2022/transfer-hook/transfer-cost): add pinocchio example - #712
Conversation
Greptile SummaryAdds a Pinocchio implementation of the Token-2022 transfer-cost hook alongside the existing Anchor example.
Confidence Score: 5/5The 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
Reviews (3): Last reviewed commit: "token-2022 transfer-hook transfer-cost: ..." | Re-trigger Greptile |
| 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)])?; |
There was a problem hiding this comment.
There was a problem hiding this comment.
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.
…en configuring another mint
| 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)])?; |
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
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.
|
Audit follow-up from #714: every PDA this example creates has a publicly derivable address, and Fixed here too. PDA creation now goes through a Covered by pre-funding each derivable address with one lamport in the setup test before the creating instruction runs. |
Adds a Pinocchio implementation of the Token-2022
transfer-costtransfer 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
Executewithout 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:
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:0for a literal address,1for a PDA of the hook program, and for a PDA of another program it is0x80 | index, where the index points at that program in theExecuteaccount list. It is notindex + 2—2is 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 bareInvalidAccountData, before the hook is ever invoked.Seed::Literal → [1, len, ...bytes]andSeed::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
ExecuteExecuteis 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'sTransferHookextension 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
Executedirectly 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 = ownerconstraint 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
u64rather than au8. This crate builds withoverflow-checks, so au8counter would start aborting transfers after 255 of them.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.