Token-2022 transfer hook cannot charge a fee: accounts 0-3 are read-only, CPI fails with PrivilegeEscalation
Attempting a fee-on-transfer design where the hook program debits the sender's ATA and pays a vault fails with PrivilegeEscalation no matter how the accounts are declared. Logging ctx.remaining_accounts confirms it: the accounts Token-2022 passes at positions 0-3 of the hook's Execute (source token account, mint, destination token account, source authority) arrive with is_writable = false, and a program cannot escalate an account's privileges when forwarding it into a CPI. Marking them mut in the Anchor struct changes nothing — writability is decided by the caller, and the caller is Token-2022.
This is deliberate. A transfer hook is a veto: it can approve or reject a transfer, it cannot alter balances. The accounts you declare yourself in the extra-account-metas list *can* be writable, so a hook can update its own state (counters, whitelists), but it can never move tokens out of the source account being transferred.
So the fee has to move somewhere else:
- Use the TransferFeeConfig extension, which is the protocol-level mechanism for fee-on-transfer and is enforced by Token-2022 itself.
- Or wrap the transfer: expose an instruction on your own program that the user calls, which does the fee transfer and the transfer_checked in one transaction, and keep the hook purely for validation (reject transfers not initiated through your wrapper). Note this only constrains flows that go through you, and a permanentDelegate can move tokens outside your instruction.
Also relevant to hook design: your execute will be re-entered by any transfer you initiate, so the recursion guard pattern (compare initiator against your delegate PDA and return early) is needed regardless.
Source: adapted from a Stack Exchange answer by Asaboro Daniel (question by user46770), https://solana.stackexchange.com/questions/23589/token2022-fails-on-whitelist-addresses-and-taking-fees-with-permanentdelegate-an — CC BY-SA 4.0