feat(token-2022/transfer-hook/counter): add pinocchio example - #710
feat(token-2022/transfer-hook/counter): add pinocchio example#710MarkFeder wants to merge 4 commits into
Conversation
Greptile SummaryThe PR adds a self-contained Pinocchio implementation of the Token-2022 counter transfer hook.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; the current implementation resolves both previously reported counter-setup failures. Important Files Changed
Reviews (4): Last reviewed commit: "token-2022 transfer-hook counter: create..." | 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.
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:
There was a problem hiding this comment.
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.
…figuring another mint
| } | ||
|
|
||
| // 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 |
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
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.
|
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
countertransfer hook, alongside the existing Anchor one.What it does
Four instructions:
Initialize— creates a Token-2022 mint carrying theTransferHookextension pointed at this program. (The Anchor version creates the mint client-side; doing it in-program keeps the example self-contained and matches the siblinghello-worldPinocchio example.)InitializeExtraAccountMetaList— writes theExtraAccountMetaListPDA 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.rsis a small bounds-checked TLV reader, shared with thehello-worldexample).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:
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 toExecute. 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_accountis notmut), so its counter reports1on 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
ExecuteExecuteis a public entrypoint, so it does not trust the accounts it is handed:TransferHookextension must name this program (a mint hooked to a different program is mid-transfer too, and that program could otherwise CPI in),transferringflag must be set.The equivalent guarantees come from Anchor's
InterfaceAccount/seedsconstraints in the reference.I did not port Anchor's
token::authority = ownerconstraint: 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.