+ + + +

Three Solana Frameworks, One Token-2022 Transfer Hook

DEV

The escrow comparison tested Anchor, Pinocchio, and Quasar on a toy contract where the frameworks mostly differ in ergonomics and compute cost. Transfer hooks are closer to something you’d actually ship: permissioned tokens (RWA issuance, compliance-gated stablecoins, restricted security tokens) need enforcement at the transfer level, where every transfer goes through your program’s check and wallets and DEXs don’t need to opt in or even know the hook exists.

Token-2022 requires your program to declare its extra accounts in a specific binary layout (type-length-value encoded on a PDA), and at transfer time the runtime walks those bytes to resolve addresses and build the CPI into your hook. If any byte is wrong you get InvalidAccountData with no indication of which byte or why.

I built a recipient-whitelist hook (two programs: controller for admin operations, transfer_hook for the per-transfer check) in all three frameworks against the same SPEC. Anchor was familiar ground but the other two took more effort.

How Token-2022 encodes extra accounts

The hook’s extra accounts live on a PDA as a type-length-value blob that Token-2022’s resolver parses at transfer time. The layout:

[0..8]    SplDiscriminate: sha256("spl-transfer-hook-interface:execute")[..8]
          = [0x69, 0x25, 0x65, 0xc5, 0x4b, 0xfb, 0x66, 0x1a]
[8..12]   u32 LE value_length = 4 + (N * 35)
[12..16]  u32 LE count = N
[16..]    N entries, each 35 bytes:
            [0]      discriminator: 0=fixed pubkey, 1=PDA (same program),
                     (program_index | 0x80)=external PDA
            [1..33]  address_config: either a raw pubkey (disc=0) or packed seeds
            [33]     is_signer
            [34]     is_writable

Packed seeds use type tags Literal=1, InstructionData=2, AccountKey=3, AccountData=4 with variable-length entries laid sequentially in the 32-byte address_config field. For an external PDA the discriminator encodes the account-list index of the program to derive under, OR’d with 0x80.

Our hook declares two extras: the controller program as a fixed pubkey at idx 5, and the recipient’s whitelist PDA (derived under the controller, seeds = ["whitelist", mint, dest_token.owner]) at idx 6.

What Anchor does right

Anchor pulls in spl-tlv-account-resolution which provides ExtraAccountMeta::new_with_pubkey and ExtraAccountMeta::new_external_pda_with_seeds. The init body is:

let extra_metas: [ExtraAccountMeta; 2] = [
    ExtraAccountMeta::new_with_pubkey(&controller_program_key, false, false)?,
    ExtraAccountMeta::new_external_pda_with_seeds(
        5, // controller program is at account-list index 5
        &[
            Seed::Literal { bytes: b"whitelist".to_vec() },
            Seed::AccountKey { index: 1 },              // mint
            Seed::AccountData { account_index: 2, data_index: 32, length: 32 }, // dest owner
        ],
        false, false,
    )?,
];
ExtraAccountMetaList::init::<ExecuteInstruction>(&mut data, &extra_metas)?;

This writes the correct TLV since the Seed enum handles the type-tag packing, the external-PDA discriminator computation (5 | 0x80 = 0x85), and the variable-length seed layout. The hook’s execute handler uses seeds::program = controller_program.key() to let Anchor’s constraint system re-derive and validate the whitelist PDA, so transfers work immediately.

How the no_std implementations broke

spl-tlv-account-resolution depends on std, so for Pinocchio and Quasar I had to hand-roll the TLV writer. I got it wrong both times, which only surfaced during a real TransferChecked.

Three categories of bug I hit across the two implementations:

Seed type tags. The spec uses 1-indexed tags (0=Uninitialized, 1=Literal, 2=InstructionData, 3=AccountKey, 4=AccountData). In the Pinocchio version I got Literal=1 right but shifted the rest down by one (AccountKey=2, AccountData=3, skipping InstructionData). In Quasar I zero-indexed everything (Literal=0, AccountKey=1, AccountData=2). Both produce address_config blobs that the resolver interprets as different seed types, deriving wrong addresses.

ExtraAccountMeta discriminator. For the external PDA entry, the discriminator should be program_index | 0x80 where program_index is the account-list position of the program to derive under (5 in our case, so 0x85). In Pinocchio I used 1 (same-program PDA), which derives the whitelist under the hook program instead of the controller. In Quasar I used 0x80 | n_seeds, a format that doesn’t exist in the spec but occasionally produces values that overlap with valid external-PDA indices.

Missing entries. Both impls initially declared only the whitelist PDA as an extra (1 entry). The controller program itself needs to be entry 0, since that’s what gives it an account-list index for the external-PDA discriminator to reference. Without it, there’s no program at idx 5 for the resolver to derive under.

Every one of these bugs produces the same symptom (InvalidAccountData from Token-2022 before the hook runs) with no log line identifying which byte is wrong, so the only debugging path is comparing emitted bytes against a known-good reference byte-by-byte.

Why unit tests didn’t catch it

I initially tested controller instructions in isolation, and all passed since create_mint CPI’s into init_extra_account_metas which happily accepts whatever bytes you give it. The TLV is only validated when Token-2022’s resolver tries to parse it during TransferChecked, and my transfer tests used a catch-all expect_err that passed regardless of which program rejected the transfer. So Token-2022 was rejecting every transfer (happy-path included) with InvalidAccountData because the TLV was garbage, but the test saw “error occurred” and called it a pass.

The fix was a targeted assertion that checks the failing program’s pubkey in the logs:

pub fn run_ix_expect_program_error(svm: &mut QuasarSvm, ix: &Instruction, expected_program: &Pubkey, label: &str) {
    let result = run_ix_expect_err(svm, ix, label);
    let needle = format!("Program {expected_program} failed:");
    assert!(result.logs.iter().any(|l| l.contains(&needle)),
        "{label}: expected failure inside {expected_program}, got:\n{}", result.logs.join("\n"));
}

With this in place, every Pinocchio and Quasar transfer test (happy path and rejection alike) immediately failed with “expected failure inside hook program, got failure inside Token-2022”, pointing straight at the TLV.

The fix

For both frameworks the rewrite was conceptually identical: emit the canonical bytes. ~80 lines of write_extra_account_meta_list per impl, using the constants from the spec:

buf[0..8].copy_from_slice(&[0x69, 0x25, 0x65, 0xc5, 0x4b, 0xfb, 0x66, 0x1a]); // SplDiscriminate
buf[8..12].copy_from_slice(&((4 + 2*35) as u32).to_le_bytes());                  // value_length
buf[12..16].copy_from_slice(&2u32.to_le_bytes());                                 // count = 2
// Entry 0: fixed pubkey (controller program)
buf[16] = 0x00; // disc = fixed pubkey
buf[17..49].copy_from_slice(controller_program_id.as_ref());
// Entry 51: external PDA (disc = 5 | 0x80 = 0x85), packed seeds...

The harder part was threading the controller program through the account lists, since the TLV references the controller by account-list index:

  1. create_mint takes a controller_program field (its own pubkey, supplied by the caller)
  2. The CPI to init_extra_account_metas forwards it
  3. init_extra_account_metas passes it to the TLV writer
  4. At transfer time, Token-2022 appends both extras to the hook invocation’s account list
  5. execute reads controller from accounts[5], derives whitelist under it, validates accounts[6]

I also hit a Pinocchio-specific issue: the slice-pattern destructure (let [a, b, c, d, e, f] = accounts) was sized for 6 accounts. After Token-2022 started appending the two extras the runtime passed 7, and the pattern silently didn’t match, returning NotEnoughAccountKeys at 97 CU. Bumping to 7 elements fixed it, but a slice pattern that’s one element short just hits the else branch with no indication of why, which is the kind of positional bug that Anchor’s named-field structs eliminate entirely.

Framework-specific issues

Quasar’s 1-byte discriminator shifts all field offsets. I initially read the whitelist’s expiration field at byte 40 in the hook’s execute (assuming 8-byte Anchor-style discriminator + 32 holder). Real layout with #[account(discriminator = 2)]: 1-byte disc + 32 holder + 8 expiration + 1 disabled + 1 bump = 43 bytes, expiration at offset 33. The size check (data_len() < 50) also rejected every valid 43-byte whitelist account.

Token-2022 mint sizing is 234 bytes, not 233 or 151. BASE_ACCOUNT_AND_TYPE_LENGTH (166) + TLV header (4) + TransferHook extension data (64) = 234. Off-by-one (233) misses the AccountType byte; the much smaller 151 misses the 83-byte padding region between base mint and AccountType entirely. Token-2022 rejects undersized mints inside InitializeTransferHookMint.

Token-2022 ATAs need the TransferHookAccount extension (type=15, length=1, transferring=0) when the mint has TransferHook, bringing the ATA size to 171 bytes. Without it, TransferChecked rejects with InvalidAccountData before the hook runs.

Anchor 1.0 dropped the [0xff; 8] closed-account sentinel. I was zeroing data + draining lamports to close accounts, which causes AccountDiscriminatorMismatch on the next init_if_needed. The correct close pattern: zero data, assign owner to system_program. Then init_if_needed sees a system-owned zero-balance account and treats it as fresh.

Compute benchmarks

InstructionAnchorPinocchioQuasar
init7,7154,8852,127
create_mint29,82516,5409,647
set_whitelist12,9114,5422,151
mint_to16,0958,7443,527
burn8,9622,6002,873
transfer21,02015,85014,696

The controller instructions (init, set_whitelist, mint_to) show the widest gaps: Quasar is 3-6x cheaper than Anchor, with Pinocchio in between. These are the instructions where Anchor’s deserialization + constraint validation machinery dominates relative to the actual business logic.

transfer converges: 21k vs 16k vs 15k. Most of the CU here is Token-2022’s own resolver walking the TLV, deriving addresses, and CPI-ing into the hook. quasar profile --expand confirms this:

BinaryTotal CU[unknown]entrypoint
controller4,56176.5%23.5%
transfer_hook1,78572.7%27.3%

The hook’s execute is a PDA derivation + a few field reads, so framework overhead is a small fraction of the total instruction cost. This is where Anchor’s abstraction tax matters least.

Binary sizes (controller + transfer_hook combined):

Frameworkcontrollertransfer_hooktotal
Anchor262 KB155 KB417 KB
Pinocchio32 KB14 KB46 KB
Quasar41 KB17 KB58 KB

Anchor’s binaries are 7-9x larger, likely from spl-tlv-account-resolution, the IDL, and generated constraint code. The same crate that gives Anchor correct TLV on the first try probably accounts for most of the size gap.

What this means for framework choice

spl-tlv-account-resolution is doing real work here: it encodes the wire format correctly, it’s tested upstream, and it eliminates a class of bugs (wrong seed tags, wrong discriminators, wrong packing) that are invisible until you run an end-to-end transfer through Token-2022’s resolver. Anchor gets this for free because it can pull std dependencies, while with Pinocchio and Quasar I was reimplementing a wire format by reading the upstream Rust source and hoping I got every byte right.

A no_std-compatible TLV crate (even just the constants and packing logic) would close most of this gap. Until that exists, any no_std transfer hook implementation means byte-level debugging against a format with no self-describing error messages.

The compute savings from Pinocchio and Quasar are real, and on simpler contracts (like the escrow) the ergonomics are already competitive. But the no_std constraint bites hard when you need to interact with Token-2022’s more complex interfaces, and right now there’s no way around hand-rolling what Anchor gets from upstream crates. Quasar is still pre-1.0, Pinocchio’s companion crates don’t cover transfer hooks, and documentation and samples for both are thin. These frameworks are worth watching, but for anything touching Token-2022 extensions today, Anchor is the pragmatic choice.


Code for the contracts is available on github: https://github.com/Wally869/SolanaPermissionedToken.