Skip to content

feat: support Token-2022 transfer hooks#160

Open
dev-jodee wants to merge 1 commit into
mainfrom
feat/token-2022-transfer-hooks
Open

feat: support Token-2022 transfer hooks#160
dev-jodee wants to merge 1 commit into
mainfrom
feat/token-2022-transfer-hooks

Conversation

@dev-jodee

Copy link
Copy Markdown
Collaborator

Summary

  • Accept mints with an active Token-2022 TransferHook extension instead of rejecting them. At transfer time the program reads the hook program_id (a minimal manual TLV parse in transfer_hook_util.rs, 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/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 — this unblocks USDG.
  • The three transfer instructions (transfer_fixed_delegation, transfer_recurring_delegation, transfer_subscription) thread trailing accounts through to the CPI.
  • TS SDK resolves the extra accounts off-chain (resolveTransferHookAccounts) and the transfer overlays auto-resolve them (with an explicit transferHookAccounts override).
  • Added a vendored minimal example hook program (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/typescriptvitest LiteSVM e2e: SDK resolves hook accounts, transfer runs, hook executes (counter incremented)
  • tsc, clippy, eslint, program_autofixer clean

Notes

  • Active transfer hooks add an arbitrary-CPI surface (CPI into a mint-authority-chosen program). Mitigations: Token-2022 validates the extra accounts, the SubscriptionAuthority PDA signs only its own seeds, and state mutations precede the transfer CPI (checks-effects-interactions). Worth a security re-review.
  • TS resolver currently auto-resolves only literal ExtraAccountMeta entries; seed-derived entries must be passed via transferHookAccounts.

@vercel

vercel Bot commented Jun 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
solana-subscriptions-program Ready Ready Preview, Comment Jun 8, 2026 9:15pm

Request Review

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown

Compute Unit Report

Instruction Samples CUs Est Cost (Low) [SOL] Est Cost (Med) [SOL] Est Cost (High) [SOL] Δ Avg vs main
cancel_subscription 22 1720 0.000005000 0.000005068 0.000005860
close_subscription_authority 10 1803 0.000005000 0.000005072 0.000005901
create_fixed_delegation 42 3517 0.000005001 0.000005140 0.000006758
create_plan 97 3436 0.000005001 0.000005137 0.000006718
create_recurring_delegation 30 3550 0.000005001 0.000005142 0.000006775
delete_plan 9 359 0.000005000 0.000005014 0.000005179
init_subscription_authority 177 6226 0.000005001 0.000005249 0.000008113
resume_subscription 3 1723 0.000005000 0.000005068 0.000005861
revoke_delegation 19 255 0.000005000 0.000005010 0.000005127
subscribe 32 6485 0.000005001 0.000005259 0.000008242
transfer_fixed 10 5514 0.000005001 0.000005220 0.000007757
transfer_recurring 20 5629 0.000005001 0.000005225 0.000007814
transfer_subscription 10 5876 0.000005001 0.000005235 0.000007938
update_plan 22 424 0.000005000 0.000005016 0.000005212

🔺 increase · 🔻 decrease · – unchanged · 🆕 new · 🗑 removed (vs main)

Generated: 2026-06-08

@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown

Greptile Summary

This PR upgrades the program to accept Token-2022 mints with an active TransferHook extension (previously rejected), reading the hook program_id from a manual TLV walk at transfer time and forwarding the caller-supplied remaining accounts to the TransferChecked CPI — Token-2022 validates them, so an incomplete set safely fails the transfer. Mints with a zero program_id (e.g. USDG) take the standard path unchanged.

  • On-chain (transfer_hook_util.rs): new TLV parser + invoke_transfer_checked_with_hook helper; all three transfer instructions thread remaining accounts through transfer_with_delegate.
  • TS SDK (transfer-hook.ts): resolveTransferHookAccounts derives the validation PDA, decodes literal ExtraAccountMeta entries, and the plugin's transferFixed/Recurring/Subscription auto-resolve them (with an explicit transferHookAccounts override slot).
  • Tests: vendored minimal hook program (tests/transfer-hook-example/) and LiteSVM-backed Rust + TS tests covering the active-hook, inert-hook, and missing-accounts failure paths.

Confidence Score: 5/5

The 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

Filename Overview
program/src/instructions/helpers/transfer_hook_util.rs New file: manual TLV walker for Token-2022 mint extension data and TransferChecked CPI helper that forwards caller-supplied remaining accounts; constants and bounds checks are correct, 8 unit tests cover the key edge cases.
program/src/instructions/helpers/transfer_utils.rs Adds hook detection at transfer time: reads mint to find active hook, forks to invoke_transfer_checked_with_hook (with remaining accounts) or the standard pinocchio TransferChecked path; checks-effects ordering is maintained, signer construction is unchanged.
program/src/instructions/helpers/token.rs Removes TransferHook rejection from validate_mint_extensions and updates comments; the trailing-byte inconsistency between validate_mint_extensions and find_extension_value remains — flagged as prior P2 outside diff.
clients/typescript/src/transfer-hook.ts New TS module: resolves literal ExtraAccountMeta entries from the hook's validation PDA; skips TLV discriminator verification (noted as prior thread) and throws on seed-derived entries (also prior thread).
clients/typescript/src/plugin.ts Wires resolveTransferHookAccounts into all three transfer overlays (fixed, recurring, subscription) with explicit override support via transferHookAccounts; standalone overlays fall back to [] when override is absent.
tests/transfer-hook-example/src/lib.rs Minimal vendored hook program (Execute-only) that increments a counter byte; correctly checks the spl-transfer-hook-interface:execute discriminator and is used only in tests.
program/src/errors.rs Adds TransferHookTooManyAccounts (137); MintHasTransferHook (121) is now unreachable but preserved for backwards compatibility.
clients/typescript/test/transfer-hook-lifecycle.test.ts New LiteSVM-backed end-to-end test covering hook init acceptance, USDG-style inert hook path, active hook transfer success, and failure without forwarded accounts.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (2): Last reviewed commit: "feat: support Token-2022 transfer hooks" | Re-trigger Greptile

Comment on lines +50 to +71
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment on lines +55 to +61
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`.',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.
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