Skip to content

fix(transfer-switch): key the transfer switch on the source token account owner - #13

Open
SwineCoder101 wants to merge 2 commits into
mainfrom
fix/transfer-hook-transfer-switch-source-owner
Open

fix(transfer-switch): key the transfer switch on the source token account owner#13
SwineCoder101 wants to merge 2 commits into
mainfrom
fix/transfer-hook-transfer-switch-source-owner

Conversation

@SwineCoder101

Copy link
Copy Markdown
Owner

Bug: transfer switch is resolved from the transfer authority, so a delegate bypasses the sender's switch

The transfer hook is meant to gate every outgoing transfer on the sender's per-wallet switch, but it derived the switch PDA from the transfer authority (account index 3 of the transfer-hook execute layout) instead of the source token account's owner. Because the authority may be a delegate, any wallet whose switch is off can approve a delegate whose own switch is on and the delegate's transfer_checked moves the tokens. This is a correctness/security bug (medium): the admin-controlled switch is trivially bypassed by the very wallet it is supposed to block, since Approve never invokes the hook.

Affected

  • anchor/ (only variant)
    • anchor/programs/transfer-switch/src/instructions/initialise_extra_account_metas_list.rs
    • anchor/programs/transfer-switch/src/instructions/transfer_hook.rs
    • anchor/tests/litesvm.test.ts

Functionality

switch(on) lets an admin create/toggle a TransferSwitch PDA seeded by a wallet key. transfer_hook (the Token-2022 Execute hook) is supposed to look up the switch of the wallet that owns the source token account and reject the transfer with SwitchNotOn when it is off. initialize_extra_account_metas_list stores the seed rule that Token-2022 uses to resolve which switch account to pass to the hook.

The bug

  • initialise_extra_account_metas_list.rs:43 declared the extra account as Seed::AccountKey { index: 3 }. In the execute layout [source, mint, destination, authority, extra_metas, ...], index 3 is the transfer authority, which is the owner or an approved delegate.
  • transfer_hook.rs:43 bound wallet_switch with seeds = [wallet.key().as_ref()], i.e. the same authority account, and source_token_account (line 20) was an UncheckedAccount never tied to wallet.

Scenario: sender A has switch = off. A calls Token-2022 ApproveChecked for delegate D (the hook is not invoked on approve). D has switch = on (or gets one; any wallet can be switched on by the admin for a different reason). D signs TransferChecked from A's token account. Token-2022 resolves the extra account as PDA([D]), the hook checks D's switch, finds it on, and A's tokens move despite A's switch being off.

Reproduce

cd tokens/token-2022/transfer-hook/transfer-switch/anchor
pnpm install --frozen-lockfile
anchor build && pnpm test

Test: Transfer switch > delegate transfers > Delegate transfer while sender switch is off, should fail!

Output against the unfixed program:

    delegate transfers
      ✔ turn transfers off for sender, on for delegate
      ✔ sender approves the delegate
      1) Delegate transfer while sender switch is off, should fail!
      ✔ turn on for sender, delegate transfer succeeds

  11 passing (178ms)
  1 failing

  1) Transfer switch
       delegate transfers
         Delegate transfer while sender switch is off, should fail!:
     AssertionError: delegate transfer succeeded with sender switch off

Fix

  • initialise_extra_account_metas_list.rs: the extra account is now seeded with Seed::AccountData { account_index: 0, data_index: 32, length: 32 }, the owner field of the source token account. Token-2022's resolver (and @solana/spl-token's createTransferCheckedWithTransferHookInstruction) derive the switch from the account owner regardless of who signs.
  • transfer_hook.rs: source_token_account is now an InterfaceAccount<TokenAccount> and wallet_switch is constrained with seeds = [source_token_account.owner.as_ref()], so even a hand-built instruction cannot substitute a different wallet's switch. The transferring-flag check is unchanged.

The test helper expectRevert also swallowed its own "Expected a revert" error; it now asserts outside the try.

Verification

cd tokens/token-2022/transfer-hook/transfer-switch/anchor
anchor build && pnpm test
  Transfer switch
    ✔ Create Mint Account with Transfer Hook Extension
    ✔ Create Token Accounts and Mint Tokens
    ✔ Create ExtraAccountMetaList Account
    ✔ Configure an admin
    ✔ turn transfers off for sender
    ✔ Try transfer, should fail!
    ✔ turn on for sender!
    ✔ Send successfully
    delegate transfers
      ✔ turn transfers off for sender, on for delegate
      ✔ sender approves the delegate
      ✔ Delegate transfer while sender switch is off, should fail!
      ✔ turn on for sender, delegate transfer succeeds

  12 passing (88ms)

cargo clippy --manifest-path anchor/Cargo.toml -p transfer-switch -- -D warnings is clean.

…der's switch

A sender whose transfer switch is off can approve a delegate whose switch
is on; the delegate's transfer_checked then succeeds because the hook
resolves the switch from the transfer authority rather than the source
token account owner.
…ount owner

The extra account meta used Seed::AccountKey { index: 3 } (the transfer
authority) and the hook derived wallet_switch from that same account, so
a delegate with its own switch on could move tokens out of a wallet whose
switch was off. Seed the switch from the source token account's owner
field (AccountData at offset 32) and constrain wallet_switch to
source_token_account.owner in the hook.
@SwineCoder101
SwineCoder101 force-pushed the fix/transfer-hook-transfer-switch-source-owner branch from f0d7ec5 to 82fd22c Compare August 27, 2026 12:23
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