Skip to content

fix(transfer-hook/counter): persist the transfer count and key the counter PDA per mint - #12

Open
SwineCoder101 wants to merge 2 commits into
mainfrom
fix/transfer-hook-counter-persist
Open

fix(transfer-hook/counter): persist the transfer count and key the counter PDA per mint#12
SwineCoder101 wants to merge 2 commits into
mainfrom
fix/transfer-hook-counter-persist

Conversation

@SwineCoder101

Copy link
Copy Markdown
Owner

Bug: transfer counter never increments and the counter PDA blocks every mint after the first

The counter transfer hook exists to count how many times a token has been transferred, but the
transfer_hook instruction never writes the incremented value back and Anchor never serialises the
account, so the on-chain counter is 0 forever. Separately, the counter PDA is derived from the
literal seed "counter" alone and created with init, so initialize_extra_account_meta_list
succeeds exactly once per program deployment: the second mint that tries to use the hook fails with
AccountAlreadyInUse. This is a correctness bug (medium: the example's only feature does not work;
low: the hook is single-use), not a style issue, and it hits anyone who follows the example.

Affected

  • anchor/ (the only variant)
    • tokens/token-2022/transfer-hook/counter/anchor/programs/transfer-hook/src/lib.rs
    • tokens/token-2022/transfer-hook/counter/anchor/tests/transfer-hook.ts

Functionality

  • initialize_extra_account_meta_list creates the ExtraAccountMetaList PDA for a mint and the
    counter PDA that Token-2022 must pass to the hook on every transfer.
  • transfer_hook (the Execute interface instruction, invoked by Token-2022 via CPI during
    transfer_checked) verifies the source account is mid-transfer and increments the counter.

The bug

  1. Counter never persists (lib.rs on main, lines 69-76 and 156-157):
    • transfer_hook computes count = counter_account.counter.checked_add(1) into a local, logs it
      and returns. counter_account.counter is never assigned.
    • TransferHook::counter_account is declared #[account(seeds = [b"counter"], bump)] without
      mut, so even if the field were assigned Anchor would not write the account back on exit.
    • Result: after any number of transfer_checked calls the counter account still reads 0.
      The on-chain log "transferred N times" always says 1.
  2. Counter PDA is a global singleton (lib.rs on main, lines 113 and 126-128):
    • InitializeExtraAccountMetaList::counter_account uses init, seeds = [b"counter"]. The PDA
      does not depend on the mint, so the first initialize_extra_account_meta_list creates it and
      every later call, for any other mint, fails inside the init CPI with system program error
      AccountAlreadyInUse (Custom(0)). The hook can only ever serve one mint per deployment.

Reproduce

cd tokens/token-2022/transfer-hook/counter/anchor
pnpm install --frozen-lockfile
anchor test --validator legacy

Tests: Counter PDA records every transfer, Set up the transfer hook for a second mint
(tests/transfer-hook.ts). Against the unmodified program:

    ✔ Transfer Hook with Extra Account Meta (458ms)
    1) Counter PDA records every transfer
    2) Set up the transfer hook for a second mint
    ✔ Try call transfer hook without transfer

  5 passing (4s)
  2 failing

  1) transfer-hook
       Counter PDA records every transfer:

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

      -0
      +4

  2) transfer-hook
       Set up the transfer hook for a second mint:
     Error: Transaction 2fs4S88Y3Xqc2ftJP2izHsSJQomtfc9Mcp9YtbQcVEPL9c5C1d6cs7cUaCaMjbKGriXeFbkPrtMwfAWho5qMwiGs resulted in an error.
Status: ({"err":{"InstructionError":[0,{"Custom":0}]}}).

Fix

programs/transfer-hook/src/lib.rs:

  • transfer_hook now assigns the incremented value: counter_account.counter = counter_account.counter.checked_add(1)... and logs the stored value.
  • TransferHook::counter_account is #[account(mut, seeds = [b"counter", mint.key().as_ref()], bump)]
    so Anchor serialises it on exit.
  • InitializeExtraAccountMetaList::counter_account is seeded per mint:
    seeds = [b"counter", mint.key().as_ref()], so every mint gets its own counter and the hook can be
    set up for any number of mints.
  • The stored ExtraAccountMeta seeds are now [Seed::Literal("counter"), Seed::AccountKey { index: 1 }].
    Index 1 is the mint in the Execute account layout (source, mint, destination, owner,
    extra_account_meta_list, extras...), so Token-2022 / the client resolver derive the per-mint
    counter at transfer time.

The existing transferring-flag check is unchanged.

tests/transfer-hook.ts: the counter PDA is derived with the mint key, and the second-mint test
additionally asserts the second mint's counter reads 1 while the first mint's counter is untouched.

Verification

cd tokens/token-2022/transfer-hook/counter/anchor
anchor build --ignore-keys && anchor test --validator legacy
cargo fmt --check && cargo clippy -p transfer-hook -- -D warnings
  transfer-hook
    ✔ Create Mint Account with Transfer Hook Extension (112ms)
    ✔ Create Token Accounts and Mint Tokens (516ms)
    ✔ Create ExtraAccountMetaList Account (586ms)
    ✔ Transfer Hook with Extra Account Meta (508ms)
    ✔ Counter PDA records every transfer (1738ms)
    ✔ Set up the transfer hook for a second mint (1602ms)
    ✔ Try call transfer hook without transfer

  7 passing (5s)

…nd blocks a second mint

Two new cases in tests/transfer-hook.ts:
- 'Counter PDA records every transfer' performs four hook-gated transfers
  and asserts the counter account holds 4; today it stays at 0.
- 'Set up the transfer hook for a second mint' initialises the
  ExtraAccountMetaList for a fresh mint; today it fails with
  AccountAlreadyInUse because the counter PDA is a global singleton.
…unter PDA per mint

transfer_hook computed counter + 1 into a local and never wrote it back,
and counter_account was not mut, so the on-chain counter stayed at 0.
The counter PDA was also seeded with the literal "counter" only and
created with init, so initialize_extra_account_meta_list could only ever
succeed for one mint per deployment.

- assign the incremented value and mark counter_account mut
- seed the counter with [b"counter", mint] in both account structs and
  store Seed::AccountKey { index: 1 } (the mint) in the ExtraAccountMeta
  so the resolver finds the per-mint counter at transfer time
- tests derive the per-mint PDA and check the second mint's counter
@SwineCoder101
SwineCoder101 force-pushed the fix/transfer-hook-counter-persist branch from bcd5855 to 37de182 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