feat: support Token-2022 transfer hooks#160
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Compute Unit Report
🔺 increase · 🔻 decrease · – unchanged · 🆕 new · 🗑 removed (vs Generated: 2026-06-08 |
Greptile SummaryThis PR upgrades the program to accept Token-2022 mints with an active
Confidence Score: 5/5The transfer hook plumbing is safe to merge; all critical paths (TLV bounds, CPI signer, checks-effects ordering) are correct and well-tested. The on-chain TLV walker has explicit bounds checks, checked arithmetic, and 8 unit tests covering malformed data. The transfer path correctly reads hook state from a pre-validated mint, and state mutations strictly precede the CPI. The TS resolver's limitations are already captured in open review threads and are user-visible failures rather than silent corruption. No new correctness or security regressions were found. No files require special attention beyond the pre-existing open threads on token.rs and transfer-hook.ts. Important Files Changed
Sequence DiagramsequenceDiagram
participant Delegatee
participant SubscriptionsProgram
participant transfer_hook_util
participant Token2022
participant HookProgram
Delegatee->>SubscriptionsProgram: TransferFixed/Recurring/Subscription (+ remaining hook accounts)
SubscriptionsProgram->>SubscriptionsProgram: "Validate accounts & delegation state"
SubscriptionsProgram->>SubscriptionsProgram: Update delegation state (checks-effects)
SubscriptionsProgram->>transfer_hook_util: mint_transfer_hook_program_id(mint_data)
transfer_hook_util-->>SubscriptionsProgram: Some(program_id) or None
alt Active hook (program_id set)
SubscriptionsProgram->>Token2022: TransferChecked CPI (signed by SubAuth PDA, + remaining accounts)
Token2022->>Token2022: Validate extra accounts vs validation PDA
Token2022->>HookProgram: Execute CPI (source, mint, dest, authority, validation, extras)
HookProgram-->>Token2022: Ok / Err
Token2022-->>SubscriptionsProgram: Ok / Err
else No hook (inert or absent)
SubscriptionsProgram->>Token2022: TransferChecked CPI (standard, no extra accounts)
Token2022-->>SubscriptionsProgram: Ok / Err
end
SubscriptionsProgram->>SubscriptionsProgram: Emit transfer event
SubscriptionsProgram-->>Delegatee: Ok / Err
Reviews (2): Last reviewed commit: "feat: support Token-2022 transfer hooks" | Re-trigger Greptile |
| function decodeExtraAccountMetas(data: ReadonlyUint8Array): TransferHookAccount[] { | ||
| const addressDecoder = getAddressDecoder(); | ||
| const accounts: TransferHookAccount[] = []; | ||
| let offset = TLV_HEADER_LEN + POD_SLICE_COUNT_LEN; | ||
|
|
||
| while (offset + EXTRA_ACCOUNT_META_LEN <= data.length) { | ||
| const discriminator = data[offset]; | ||
| if (discriminator !== META_DISCRIMINATOR_LITERAL) { | ||
| throw new Error( | ||
| 'Transfer hook declares seed-derived extra accounts, which are not auto-resolved. ' + | ||
| 'Pass them explicitly via `transferHookAccounts`.', | ||
| ); | ||
| } | ||
| const address = addressDecoder.decode(data.subarray(offset + 1, offset + 33)); | ||
| const isSigner = data[offset + 33] === 1; | ||
| const isWritable = data[offset + 34] === 1; | ||
| accounts.push({ address, role: roleFor(isSigner, isWritable) }); | ||
| offset += EXTRA_ACCOUNT_META_LEN; | ||
| } | ||
|
|
||
| return accounts; | ||
| } |
There was a problem hiding this comment.
decodeExtraAccountMetas skips the TLV discriminator without verifying it
The function advances past the first 12 bytes (TLV_HEADER_LEN) without checking whether the 8-byte prefix matches the canonical ExtraAccountMetaList discriminant. If validationPda exists but holds data from a different program (e.g. accidentally initialized as a system account, or front-run before the hook program sets it), the raw bytes at offset 16 would be decoded as ExtraAccountMeta entries and forwarded to the on-chain transfer. Token-2022 would reject the transaction, but the client-side build would silently succeed, making the failure harder to diagnose.
| while (offset + EXTRA_ACCOUNT_META_LEN <= data.length) { | ||
| const discriminator = data[offset]; | ||
| if (discriminator !== META_DISCRIMINATOR_LITERAL) { | ||
| throw new Error( | ||
| 'Transfer hook declares seed-derived extra accounts, which are not auto-resolved. ' + | ||
| 'Pass them explicitly via `transferHookAccounts`.', | ||
| ); |
There was a problem hiding this comment.
Seed-derived hook accounts throw an untyped, undocumented error through the plugin client
decodeExtraAccountMetas throws a plain Error when it encounters a non-literal (discriminator !== 0) ExtraAccountMeta entry. This exception propagates through resolveTransferHookAccounts and the plugin client's transferFixed, transferRecurring, and transferSubscription methods without being caught or documented. Any integrator whose plan uses a hook that declares seed-derived extra accounts will get an unexpected runtime exception with no indication that a manual transferHookAccounts override is required.
Accept mints with an active TransferHook extension instead of rejecting them. At transfer time the program reads the hook program_id (a minimal manual TLV parse, since pinocchio-token-2022 has no extension-state reader) and forwards the caller-supplied hook accounts to the TransferChecked CPI; Token-2022 authoritatively resolves and validates them, so a wrong or incomplete set fails the transfer safely. Mints with only a mutable-but-unset hook (program_id zero, e.g. USDG) take the standard transfer path. The TS SDK resolves the extra accounts off-chain (resolveTransferHookAccounts) and the three transfer instructions thread trailing accounts through. A vendored example hook program and a LiteSVM-backed test (Rust + TS) exercise the active-hook path end to end.
abe9d0d to
2cc04f9
Compare
Summary
TransferHookextension instead of rejecting them. At transfer time the program reads the hookprogram_id(a minimal manual TLV parse intransfer_hook_util.rs, sincepinocchio-token-2022has no extension-state reader) and forwards the caller-supplied hook accounts to theTransferCheckedCPI. Token-2022 authoritatively resolves and validates them, so a wrong/incomplete set fails the transfer safely.program_idzero, e.g. USDG) take the standard transfer path — this unblocks USDG.transfer_fixed_delegation,transfer_recurring_delegation,transfer_subscription) thread trailing accounts through to the CPI.resolveTransferHookAccounts) and the transfer overlays auto-resolve them (with an explicittransferHookAccountsoverride).tests/transfer-hook-example/) and LiteSVM-backed tests (Rust + TS) that exercise the active-hook path end to end.Test Plan
just unit-test— 48 pass (incl. 8 TLV reader tests)just integration-test— 206 pass (incl. active-hook transfer succeeds + hook runs / fails without forwarded accounts; init now accepts active + USDG-style hooks)clients/typescript—vitestLiteSVM e2e: SDK resolves hook accounts, transfer runs, hook executes (counter incremented)tsc,clippy,eslint,program_autofixercleanNotes
ExtraAccountMetaentries; seed-derived entries must be passed viatransferHookAccounts.