Skip to content

fix(transfer-hook/account-data-as-seed): persist the per-owner transfer counter and mark its PDA mut - #11

Open
SwineCoder101 wants to merge 2 commits into
mainfrom
fix/transfer-hook-account-data-as-seed-persist
Open

fix(transfer-hook/account-data-as-seed): persist the per-owner transfer counter and mark its PDA mut#11
SwineCoder101 wants to merge 2 commits into
mainfrom
fix/transfer-hook-account-data-as-seed-persist

Conversation

@SwineCoder101

Copy link
Copy Markdown
Owner

Bug: transfer hook never persists the per-owner transfer counter

transfer_hook computes counter + 1 into a local, logs it, and returns without writing it back, and the counter PDA is not declared mut, so Anchor never serialises it on exit. The per-owner counter this example exists to demonstrate stays at 0 forever, no matter how many transfers the owner makes. Medium severity, functional bug: any client or program that reads the counter (rate limits, fees, analytics) gets a constant 0.

Affected

  • anchor/ (only variant): tokens/token-2022/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook/src/lib.rs

Functionality

initialize_extra_account_meta_list creates a CounterAccount PDA at ["counter", owner] and registers an ExtraAccountMeta that derives that PDA from the source token account's owner (AccountData { account_index: 0, data_index: 32, length: 32 }). On every transferChecked Token-2022 CPIs into transfer_hook with the resolved counter PDA, which is supposed to record how many transfers that owner has made.

The bug

lib.rs:69-76 (pre-fix):

let count = ctx.accounts.counter_account.counter.checked_add(1).ok_or(TransferError::AmountTooBig)?;
msg!("This token has been transferred {} times", count);

count is a local that is only logged; counter_account.counter is never assigned. Independently, lib.rs:167-168 declares the account without mut:

#[account(seeds = [b"counter", owner.key().as_ref()], bump)]
pub counter_account: Account<'info, CounterAccount>,

so even if the field were assigned, Anchor would skip exit() serialisation for it. The ExtraAccountMeta itself is registered is_writable = true, so the runtime hands the account in writable and nothing fails. Scenario: an owner transfers three times, then reads CounterAccount at ["counter", owner]: counter == 0. The log line claims "transferred 1 times" on every transfer.

The existing test only sent one transfer and never read the counter back, so it could not catch this.

Reproduce

From tokens/token-2022/transfer-hook/account-data-as-seed/anchor (needs port 8899 free; CI uses the same flags):

pnpm install --frozen-lockfile
anchor build --ignore-keys
anchor test --skip-build --validator legacy

Equivalent with an explicitly started validator (what was used here, because a stray surfpool was already bound to 8899):

solana-test-validator --reset --ledger /tmp/adas-ledger --quiet --rpc-port 18877 --gossip-port 18000 \
  --faucet-port 19900 --dynamic-port-range 18001-18100 --mint $(solana-keygen pubkey ~/.config/solana/id.json) \
  --bpf-program 1qahDxKHeCLZhbBU2NyMU6vQCQmEUmdeSEBrG5drffK target/deploy/transfer_hook.so
ANCHOR_PROVIDER_URL=http://127.0.0.1:18877 ANCHOR_WALLET=~/.config/solana/id.json \
  pnpm mocha --import=tsx -t 100000 'tests/**/*.ts'

The counter PDA is keyed by the wallet, so each run needs a fresh (--reset) ledger.

Test: Counter PDA records every transfer made by the source owner in anchor/tests/transfer-hook.ts sends two more transferChecked (three in total from the wallet) and reads the counter back. Against the unmodified program:

    ✔ Transfer Hook with Extra Account Meta (470ms)
    1) Counter PDA records every transfer made by the source owner
    ✔ Try call transfer hook without transfer

  5 passing (3s)
  1 failing

  1) transfer-hook
       Counter PDA records every transfer made by the source owner:

      AssertionError: expected +0 to equal 3
      + expected - actual

      -0
      +3

      at Context.<anonymous> (tests/transfer-hook.ts:220:54)

Fix

transfer_hook now assigns the incremented value back to counter_account.counter (and logs that field), and TransferHook::counter_account is marked #[account(mut, ...)] so Anchor serialises it at instruction exit. The existing check_is_transferring gate and the seed derivation are unchanged. readme.md's TransferHook snippet was updated to match. The file was also run through cargo fmt with the repository rustfmt.toml, which reflowed a few unrelated lines.

The test now confirms its transfers at confirmed before reading the counter at confirmed, so the assertion reads the post-state of the last transfer rather than a stale snapshot.

Verification

Same commands as above, fresh ledger, fixed program:

    ✔ Create Mint Account with Transfer Hook Extension (900ms)
    ✔ Create Token Accounts and Mint Tokens (502ms)
    ✔ Create ExtraAccountMetaList Account (512ms)
    ✔ Transfer Hook with Extra Account Meta (501ms)
    ✔ Counter PDA records every transfer made by the source owner (1035ms)
    ✔ Try call transfer hook without transfer

  6 passing (3s)

cargo clippy -p transfer-hook -- -D warnings (inside anchor/) and pnpm exec tsc --noEmit -p tsconfig.json are clean.

@SwineCoder101
SwineCoder101 force-pushed the fix/transfer-hook-account-data-as-seed-persist branch from 3537bf0 to 35a0fc8 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