Skip to content

fix(abl-token): block wallets from sending, not just receiving - #672

Merged
dev-jodee merged 4 commits into
solana-foundation:mainfrom
NikkiAung:fix/abl-token-sender-block
Aug 12, 2026
Merged

fix(abl-token): block wallets from sending, not just receiving#672
dev-jodee merged 4 commits into
solana-foundation:mainfrom
NikkiAung:fix/abl-token-sender-block

Conversation

@NikkiAung

@NikkiAung NikkiAung commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

allow-block-list-token's transfer hook only ever checked the destination wallet's allow/block status. A wallet explicitly blocked via init_wallet (ABWallet { allowed: false }) could still send its entire balance out through an ordinary TransferChecked to any unlisted destination — in every mint mode (Allow/Block/Threshold), since the sender's ABWallet record was never read at all.

This directly contradicts the WalletBlocked error name and the "Blocked" badge shown in the admin UI, and defeats the standard real-world reason to build a block-list token (e.g. sanctions compliance: stopping the sanctioned party from moving funds out).

Root cause: utils.rs's get_extra_account_metas() configured exactly one extra account for the hook's Execute call, seeded from account_index: 2 (the destination token account). tx_hook.rs had a source_token_account field on the accounts struct but never read it — the decision logic never had access to the sender's status in any mode.

Fix

  • utils.rs — add a second extra account seeded from the source token account's owner (account_index: 0), so Token-2022 resolves and appends both the source's and destination's ab_wallet PDAs to every Execute CPI.
  • tx_hook.rs — add source_ab_wallet to the TxHook accounts struct (ordered to match get_extra_account_metas()), and extend the decision logic so a wallet with an explicit allowed: false record is rejected regardless of which side of the transfer it's on. Allow/Threshold mode's documented "who may receive" semantics (see this example's README) are left untouched — only the block-list's own "blocked" concept, whose name and UI already imply full blocking, becomes bidirectional.
  • The decision matrix is pulled out into a standalone decide(mint_mode, source_mode, destination_mode, amount) function with unit tests, since this program has no integration-test harness that exercises tx_hook via a real hooked transfer (tests/test.rs only covers init_config/init_mint).
  • src/components/account/account-data-access.tsx — the demo frontend's useSendTokens() manually constructs the hook's extra accounts rather than relying on Token-2022's standard client-side auto-resolution. Updated it to push both the source and destination ab_wallet PDAs, in the same order the program now expects, so the demo UI stays consistent with the hardened program. This is a demo-consistency fix, not itself security-critical — any standards-conformant Token-2022 client picks up the corrected on-chain ExtraAccountMetaList automatically. Not verified via live browser/wallet testing (no validator available in this environment); it's a direct structural mirror of the pre-existing destination-account push, keyed on the sender's own public key instead.

Out of scope

block-list/pinocchio (a separate, unrelated example) has a self-documented limitation in its own README where setting up the ExtraAccountMetaList while the block list is still empty locks in "0 extra accounts" until setup-extra-metas is manually re-run after the first block. Lower severity (requires operator error, not silently wrong-by-design) and a different program — worth a follow-up PR, not bundled here.

Test plan

  • cargo checkabl-token compiles clean with the new account and function signature.
  • cargo test --lib — 9/9 pass, including the new decide() unit tests covering both sides of the block check and the existing Allow/Block/Threshold semantics.
  • Verified the key test actually catches the original bug: temporarily reintroduced the old (destination-only) logic and confirmed source_blocked_is_always_rejected fails against it, then confirmed all 9 tests pass again against the fix.
  • anchor build — IDL regenerates with exactly 7 accounts for tx_hook, in the order Token-2022 will actually append them.
  • pnpm exec tsc --noEmit on the frontend — clean, no type errors from the account-data-access.tsx change.
  • pnpm run lint / pnpm run format:check — clean (pre-existing warnings/diffs in untouched files left as-is).
  • Live end-to-end test of the frontend send flow against a real validator (not available in this environment).

get_extra_account_metas() only ever configured one extra account for
the transfer hook's Execute call, resolved from the destination token
account's owner. tx_hook() never saw the sender's ABWallet record, so
a wallet marked allowed: false could still send its full balance out
to any unlisted destination in every mint mode (Allow/Block/Threshold)
- exactly the case the WalletBlocked error and admin UI's "Blocked"
badge imply is prevented.

Add a second extra account for the source token account's owner and
extend the decision matrix so an explicitly blocked wallet is rejected
on either side, while leaving Allow/Threshold mode's documented
"who can receive" semantics untouched. The decision logic is pulled
into a standalone decide() function with unit tests covering both
sides of the block check plus the existing mode semantics, since this
program has no integration-test harness that exercises tx_hook via a
real hooked transfer.

Also fixes the frontend's manual extra-account construction in
useSendTokens() to push both the source and destination ab_wallet PDAs
in the same order the program now expects.
@NikkiAung
NikkiAung requested a review from dev-jodee as a code owner August 5, 2026 07:26
@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR now checks both source and destination wallet status during Token-2022 transfers and provides a permissionless migration for existing one-entry extra-account metadata lists.

  • Adds source and destination AB-wallet account resolution and bidirectional block enforcement.
  • Adds resize_meta_list to migrate existing mints without requiring a retained transfer-hook authority.
  • Updates the frontend’s manually constructed transfer-hook accounts and expands Rust migration and decision tests.
  • Runs the Rust tests from the Anchor test script.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the migration instruction addresses incompatible legacy metadata accounts without depending on a retained transfer-hook authority.

Important Files Changed

Filename Overview
tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/resize_meta_list.rs Adds a deterministic, permissionless migration that reallocates legacy metadata accounts and verifies the mint uses this transfer-hook program.
tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/tx_hook.rs Decodes both wallet records and rejects an explicitly blocked source or destination while preserving destination-oriented allow and threshold semantics.
tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/utils.rs Expands the extra-account list from one destination PDA to ordered source and destination PDAs.
tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/tests/test.rs Adds coverage for permissionless, idempotent, program-bound migration and verifies conversion from the legacy one-entry layout.
tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-data-access.tsx Updates the demo transfer builder to append source and destination wallet PDAs in the order expected by the hook.
tokens/token-2022/transfer-hook/allow-block-list-token/anchor/Anchor.toml Extends the Anchor test command to execute the Rust unit and LiteSVM tests before the TypeScript suite.

Reviews (4): Last reviewed commit: "fix(#672): make resize_meta_list permiss..." | Re-trigger Greptile

… new layout

Greptile review on PR solana-foundation#672 flagged that extra_metas_account is a
fixed-size PDA created once by init_mint/attach_to_mint: mints created
before the sender-side check was added are left with the old,
undersized (one-entry) account, so their transfers start failing the
hook's account-count check after the program upgrades - the fix has
no effect for them without a migration path.

Add resize_meta_list, which reallocates extra_metas_account to the
current get_meta_list_size() and rewrites its contents via
ExtraAccountMetaList::update (not ::init, which only handles the
account's first-ever write). Authorization reuses attach_to_mint's
existing pattern: a no-op transfer_hook_update CPI back to Token-2022,
which only succeeds if the caller is the mint's actual transfer-hook
authority - so this instruction needs no authority table of its own.

Tested against a litesvm account manually seeded with the old
one-entry TLV layout to confirm the migration produces byte-identical
output to a fresh init_mint, plus the idempotent (already-current-size)
case and rejection of a non-authority caller.
@NikkiAung

Copy link
Copy Markdown
Contributor Author

Hey @dev-jodee — bumping this for review when you get a chance. CI is green and Greptile came back clean (5/5, no blocking findings). Thanks!

@dev-jodee dev-jodee left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't think you ran cargo fmt as well

use super::*;

#[test]
fn source_blocked_is_always_rejected() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont think the ci is running those test, might need

cargo test -p abl-token


pub fn get_meta_list_size() -> Result<usize> {
Ok(ExtraAccountMetaList::size_of(1).map_err(|_| ProgramError::InvalidArgument)?)
Ok(ExtraAccountMetaList::size_of(2).map_err(|_| ProgramError::InvalidArgument)?)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have a const for that magic number

}

#[test]
fn test() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have a better test name

…, magic number, naming, formatting

1. The tx_hook.rs unit tests and the litesvm integration test
   (tests/test.rs) were never actually running in CI. `anchor test`
   only executes Anchor.toml's [scripts] test command, and that
   command was mocha-only - no workflow anywhere calls `cargo test`
   for anchor projects (confirmed by grepping every workflow; only
   the native/pinocchio/asm workflows do that). Wired `cargo test -p
   abl-token` into the project's own [scripts] test command, since
   `anchor/` has its own local Cargo workspace - this doesn't touch
   shared CI infra, it's picked up automatically the next time CI (or
   anyone) runs `anchor test` for this project. Verified the full
   composite command runs all 13 Rust tests plus the existing TS stub.

2. Replaced the magic number in utils.rs's
   `ExtraAccountMetaList::size_of(2)` with a named
   NUM_EXTRA_ACCOUNTS constant.

3. Renamed the generically-named `fn test()` in tests/test.rs to
   `init_config_and_init_mint_succeed`, describing what it actually
   exercises.

4. Ran `cargo fmt -p abl-token` as requested - this also reformatted
   change_mode.rs, init_config.rs, and init_mint.rs, which weren't
   touched by this PR but weren't cargo-fmt'd either (whitespace only,
   verified via `cargo check` + `cargo test` before and after).
@NikkiAung

Copy link
Copy Markdown
Contributor Author

Thanks — all addressed in `3a7a44b`:

  1. Tests weren't running in CI — confirmed you're right: `anchor test` only executes `Anchor.toml`'s `[scripts] test` command, and it was mocha-only. No workflow anywhere calls `cargo test` for anchor projects (checked every workflow file), so the `tx_hook.rs` unit tests and the litesvm integration test in `tests/test.rs` were dead weight. Wired `cargo test -p abl-token` into this project's own `[scripts] test` command — `anchor/` has its own local Cargo workspace, so this is scoped to just this project, not shared CI infra. Verified the composite command runs all 13 Rust tests plus the TS stub end-to-end.
  2. Magic number — added a `NUM_EXTRA_ACCOUNTS` const in `utils.rs`.
  3. Test name — renamed `fn test()` to `init_config_and_init_mint_succeed`.
  4. cargo fmt — ran it. It also reformatted `change_mode.rs`, `init_config.rs`, and `init_mint.rs` (untouched by this PR but never cargo-fmt'd either) — whitespace only, verified with `cargo check`/`cargo test` before and after.

Comment on lines +50 to +56
let tx_hook_accs = TransferHookUpdate {
token_program_id: self.token_program.to_account_info(),
mint: self.mint.to_account_info(),
authority: self.payer.to_account_info(),
};
let ctx = CpiContext::new(self.token_program.key(), tx_hook_accs);
transfer_hook_update(ctx, Some(crate::ID_CONST))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Revoked authority blocks migration

If an existing mint revoked its transfer-hook authority or configured an authority that cannot sign directly, resize_meta_list always fails at transfer_hook_update before rewriting the deterministic program-owned metadata account. The mint therefore remains on the incompatible one-entry layout, causing every transfer through the upgraded hook to fail with no recovery path.

Knowledge Base Used: Tokens Directory Overview

…Greptile review

The migration path required the mint's current transfer-hook authority
to sign (via a transfer_hook_update CPI used purely as an auth check).
Any mint whose authority was revoked or otherwise made unavailable - a
legitimate, common pattern for locking in a configuration - would be
permanently stuck on the old, undersized metadata list with no way to
migrate, since there would be no one left who could sign.

The content resize_meta_list writes is fully determined by the mint's
pubkey and this program's own fixed extra-account list, so nothing
about it actually needs gating behind a signature. Removed the CPI
entirely and replaced it with a direct, read-only check that the mint's
TransferHook extension already points at this program - proving the
call is meaningful without requiring anyone to sign for it.

Added a new ABListError::MintNotUsingThisHook variant (appended, not
inserted, so existing error codes keep their numbers) and three
regression tests: the permissionless case (called by a signer with no
relationship to the mint - this is the one that fails against the old,
authority-gated code and passes after), the existing idempotent-resize
case, and rejection of a mint whose transfer hook points elsewhere.
@NikkiAung

NikkiAung commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

@dev-jodee Good catch, fixed in `851211f`:

The migration was gated on the mint's transfer-hook authority signing a CPI back into Token-2022 - but that meant any mint whose authority was ever revoked (a legitimate pattern for locking in a config) would be permanently stuck on the old metadata layout, since there'd be no one left who could sign.

Removed the CPI entirely. The content this instruction writes doesn't depend on who calls it - it's fully determined by the mint's pubkey and the program's own fixed extra-account list - so there was nothing that actually needed a signature. Replaced it with a direct, read-only check that the mint's TransferHook extension already points at this program. Now genuinely permissionless: anyone can trigger the migration for any mint that's actually using this hook, authority available or not.

Added a MintNotUsingThisHook error (appended to the enum, existing codes unchanged) and 3 tests, including one that specifically fails against the old authority-gated code and passes after — proving this actually fixes the reported scenario.

@dev-jodee
dev-jodee merged commit 9cbf50f into solana-foundation:main Aug 12, 2026
20 checks passed
dev-jodee pushed a commit that referenced this pull request Aug 19, 2026
* feat(allow-block-list-token): migrate frontend to @solana/kit

Moves the webapp off @solana/web3.js + @solana/wallet-adapter-react onto
@solana/kit + @solana/connector, matching the sibling kit examples
(nft-meta-data-pointer, world-cup). Wallet connection goes through
@solana/connector/react, RPC calls use kit's typed createSolanaRpc, and
program interaction goes through a Codama-generated Kit-native client
built from the Anchor IDL (scripts/generate-client.ts) instead of the
@anchor-lang/core Program wrapper.

Rebased the migration onto origin/main's already-merged abl-token fix
(#672) rather than the stale program this branch forked from, since that
fix changes tx_hook's client-facing account layout: transfers now need
both the sender's and receiver's ab_wallet PDA (source first, then
destination), not just the receiver's. useSendTokens resolves and
appends both, in the order the program's get_extra_account_metas()
expects.

Verified: pnpm typecheck/build/lint/format:check all pass, anchor test
passes (9 unit + 5 litesvm + 1 mocha, including the source-blocked
regression test), no web3.js/wallet-adapter imports remain in src/, and
anchor/ has zero diff from origin/main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(allow-block-list-token): ignore generated IDL in root prettier check

The root `pnpm run check` script runs prettier from the repo root, which
only reads the root .prettierignore, not the app-level one - so the
app-level ignore added for idl/abl_token.json (a raw copy of the anchor
build output, regenerated on every `pnpm run generate-client`) had no
effect on CI's root-level check. Mirrors the existing
games/gacha/pinocchio/idl/ entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(allow-block-list-token): address review feedback on kit migration

- remove_wallet.rs: declare ab_wallet's PDA seeds (seeds = [AB_WALLET_SEED,
  wallet.key()]) instead of requiring the caller to pre-derive and pass the
  PDA directly, so getRemoveWalletInstructionAsync({ authority, wallet })
  resolves it the same way getInitWalletInstructionAsync already does.
  Also aligns config's seeds with the CONFIG_SEED constant, matching
  init_wallet.rs. Regenerated the IDL/client and simplified the two
  frontend callers (removeWallet, processBatchWallets) accordingly.
- Bump @solana/kit and @solana/program-client-core to ^7.1.0 (both the
  app's own deps and the generated client's peerDependencies, via a new
  dependencyVersions option on the codama renderVisitor call) and
  @solana-program/token-2022 to ^0.15.0.
- cluster-data-access.tsx: drop useClusterRpc/deriveWebsocketUrl in favor
  of @solana/connector's useSolanaClient across every consumer, and fix
  addCluster's endpoint validation, which silently accepted any string -
  createSolanaRpc doesn't parse its endpoint eagerly despite a comment
  claiming otherwise. new URL(endpoint) is the actual check.
- use-send-instruction.ts: adopt @solana/connector's useTransactionPreparer
  for blockhash + simulation-derived compute unit limit, sourcing
  rpc/rpcSubscriptions for the send-and-confirm step from useSolanaClient
  instead of the removed custom hook. (client.sendAndConfirmTransaction,
  suggested in review, doesn't actually exist in the installed - and
  latest published - @solana/connector@0.2.6, despite one JSDoc example;
  kept sendAndConfirmTransactionFactory from kit for that step.)
- account-data-access.tsx: useSendTokens now resolves the transfer-hook's
  extra accounts via @solana-program/token-2022's
  getTransferCheckedWithTransferHookInstructionAsync (reads the mint's
  on-chain extra-account-metas list) instead of hardcoding this program's
  ab_wallet PDA convention client-side. useRequestAirdrop now uses kit's
  airdropFactory, which confirms the airdrop instead of returning
  immediately after requesting it. useTransferSol now checks
  signer.address against the viewed account instead of silently signing
  with a possibly-different connected wallet than the page's address.
  (useGetBalance/useGetTokenAccounts/useGetSignatures stay on a
  cluster-scoped RPC call, not connector's useBalance/useTokens/
  useTransactions - those hooks are scoped to the connected wallet only
  and don't take an address, so they can't back the generic
  /account/[address] page, which needs to read arbitrary addresses.)
- abl-token-data-access.tsx: fixed transferHookAuthority being set to
  mintAuthority instead of the form's own transferHookAuthority field (a
  legacy bug predating this migration). mintTo now uses
  getMintToATAInstructionPlanAsync + flattenInstructionPlan instead of
  manually assembling the create-ATA and mint-to instructions.
- Added anchor/tests/basic.test.ts: LiteSVM-backed tests exercising the
  generated Kit client directly (init_config, init_wallet, the new
  seeds-based remove_wallet, and an authority-mismatch rejection case).
  This project's `anchor test` has no local-validator step to test
  against - its Anchor.toml [scripts] test command fully replaces
  Anchor's normal build+validator+deploy flow - so a real RPC connection
  isn't available; LiteSVM gives the TS client something real to run
  against without one. The old placeholder test never actually exercised
  anything.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(allow-block-list-token): correct program id resolution and hook account resolution

The Codama-generated instruction builders resolve their default PDAs through
findConfigPda()/findAbWalletPda() with no program-address argument, so those
helpers fall back to the IDL's declare_id even when the instruction itself is
built for a different program. programIdForCluster pointed devnet and testnet
at 6z68wfurCMYkZG51s1Et9BJEd9nJGUusjHXNt4dGbNNF, which made every write path
send a PDA derived from the local program id to a different program, while
getConfig (the one call site that passed the override to the PDA helper) read
the correct account. That address is a system-owned wallet copied from the
legacy-next-tailwind-basic template, not a deployment of this program, so the
override is dropped and the generated ABL_TOKEN_PROGRAM_ADDRESS is used
throughout.

- useSendTokens: getTransferCheckedWithTransferHookInstructionAsync resolves
  the hook's extra accounts from an AccountData seed over the destination token
  account, which it reads over RPC. A first-ever transfer to a recipient with
  no associated token account therefore threw during instruction construction,
  because the idempotent create-ATA instruction sat in the same unsent
  transaction. Create the ATA in its own transaction when it is missing, and
  restore the preconditions the migration dropped: an explicit error when the
  mint has no transfer hook, and one when its extra-account-metas account does
  not exist (both cases otherwise degrade to a bare transferChecked that fails
  on chain).
- Replace the `enabled` + isLoading pairs with isPending. A disabled TanStack
  query reports isLoading false with no data, so ClusterChecker,
  AccountBalanceCheck and AblTokenProgram rendered their error states on first
  paint, before the connector client existed.
- Serialize the program account with a bigint replacer. kit types lamports and
  space as bigint, and the BigInt.prototype.toJSON patch lives in layout.tsx,
  a server component, so the browser bundle never receives it.
- Classify program errors by the codes in src/generated/errors instead of
  matching Anchor variant names in log strings.
- useTransferSol: stop swallowing send failures, and report through the
  transaction toasts. ModalSend renders only on the connected wallet's own
  account page, matching the signer check.
- Give initWallet and removeWallet distinct mutation keys (both used
  'change-mode'), and invalidate get-ab-wallets after each write to the list.
- Derive the connector's localnet cluster from the endpoint host rather than
  the cluster being named 'local', and key AppProvider on the endpoint, so a
  custom cluster is no longer advertised to the wallet as devnet.
- generate-client: prefer anchor/target/idl/abl_token.json when present and
  fall back to the committed idl/. The client hardcodes the program id from the
  IDL, so a keys-synced local build otherwise left the webapp pointing at an
  address the deploy never created.
- Delete anchor/src/abl-token-exports.ts and anchor/src/index.ts, unreachable
  since the @project/anchor alias was removed, and with them the last
  @anchor-lang/core and @solana/web3.js imports; drop both dependencies.
- Drop the unused useHasTransferHookEnabled, take LAMPORTS_PER_SOL and
  lamportsToSol from @solana/connector instead of redefining them, and correct
  the .prettierignore note about how idl/ is produced.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: amilz <85324096+amilz@users.noreply.github.com>
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.

2 participants