Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,46 @@ All notable changes to this repository are documented here.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [2026-11-07] - Transfer Hook - Whitelist becomes Transfer Hook - Allow List, with no size limit

The whitelist example kept every allowed address in one account, capped at eleven,
shared by every mint. Its setup instruction used `init_if_needed` and made whoever
called it the list's authority, so anyone could take over the list by setting up a
hook for a mint of their own. The example now lives at
`tokens/token-extensions/transfer-hook/allow-list/`, and is rebuilt around one
account per allowed wallet.

### Changed

- `whitelist/` is renamed `allow-list/`, in all three variants (Anchor v1, Anchor v2,
Quasar). The Anchor crate is `transfer-hook-allow-list` and the Quasar crate
`quasar-transfer-hook-allow-list`; the program ID is unchanged.
- Each mint has its own **allow list** account, at a PDA with seeds `"allow_list"` and
the mint, holding the list's `authority`. Each allowed wallet has an **allowed
wallet** account, at a PDA with seeds `"allowed_wallet"`, the mint and the wallet,
so the list has no size limit.
- `add_to_whitelist` is replaced by `allow_wallet`, and the new `disallow_wallet`
closes a wallet's account and returns its rent to the authority.
- `initialize_extra_account_meta_list` now requires the mint authority's signature,
and creates the allow list with `init`, so only the issuer can set up a mint's list.
- The hook checks the wallet that owns the destination token account, not the token
account's own address. Its one extra account is derived per transfer from the mint
and the destination token account's owner, and a transfer to a wallet without an
allowed wallet account fails with `RecipientNotAllowed` (Quasar: custom error 6000)
rather than a panic.
- Tests cover allowing, disallowing, a wallet allowed for one mint only, fifty
allowed wallets, and the attempts to create a mint's allow list without its mint
authority, or to allow a wallet without the list's authority.
- The lending and managed-fund docs say "allow list" for "whitelist".

### Removed

- `tokens/token-extensions/transfer-hook/block-list/`, a Pinocchio-only example with no
entry in the catalog. Allow/Block List covers the same one-account-per-blocked-wallet
design in its Block mode, in Anchor v1, Anchor v2 and Quasar.
- `whitelist/` and `block-list/` each keep a `README.md` pointing to where the example
went.

## [2026-11-06] - NFT examples get their own folder; realloc becomes resizing-accounts

### Changed
Expand Down
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,11 @@ members = [
"tokens/token-extensions/transfer-fee/anchor/programs/transfer-fee",
"tokens/token-extensions/transfer-hook/account-data-as-seed/anchor/programs/transfer-hook",
"tokens/token-extensions/transfer-hook/allow-block-list-token/anchor/programs/abl-token",
"tokens/token-extensions/transfer-hook/allow-list/anchor/programs/transfer-hook",
"tokens/token-extensions/transfer-hook/counter/anchor/programs/transfer-hook",
"tokens/token-extensions/transfer-hook/hello-world/anchor/programs/transfer-hook",
"tokens/token-extensions/transfer-hook/transfer-cost/anchor/programs/transfer-hook",
"tokens/token-extensions/transfer-hook/transfer-switch/anchor/programs/transfer-switch",
"tokens/token-extensions/transfer-hook/whitelist/anchor/programs/transfer-hook",
]
# A path dependency inside the workspace directory joins the workspace unless it
# is excluded. Both of these are named `mock_price_feed`, so leaving them in
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,12 @@ Use token account owner data as seeds to derive extra accounts in a transfer hoo

[⚓ Anchor v1](./tokens/token-extensions/transfer-hook/account-data-as-seed/anchor-v1) [⚓ Anchor v2](./tokens/token-extensions/transfer-hook/account-data-as-seed/anchor) [💫 Quasar](./tokens/token-extensions/transfer-hook/account-data-as-seed/quasar)

### Transfer Hook - Allow List

Only let tokens reach wallets on the mint's allow list. Each allowed wallet is its own account, so the list has no size limit.

[⚓ Anchor v1](./tokens/token-extensions/transfer-hook/allow-list/anchor-v1) [⚓ Anchor v2](./tokens/token-extensions/transfer-hook/allow-list/anchor) [💫 Quasar](./tokens/token-extensions/transfer-hook/allow-list/quasar)

### Transfer Hook - Allow/Block List

Restrict or allow token transfers using an onchain list managed by a list authority.
Expand All @@ -376,12 +382,6 @@ Enable or disable token transfers with an onchain switch.

[⚓ Anchor v1](./tokens/token-extensions/transfer-hook/transfer-switch/anchor-v1) [⚓ Anchor v2](./tokens/token-extensions/transfer-hook/transfer-switch/anchor) [💫 Quasar](./tokens/token-extensions/transfer-hook/transfer-switch/quasar)

### Transfer Hook - Whitelist

Restrict transfers so only whitelisted accounts can receive tokens.

[⚓ Anchor v1](./tokens/token-extensions/transfer-hook/whitelist/anchor-v1) [⚓ Anchor v2](./tokens/token-extensions/transfer-hook/whitelist/anchor) [💫 Quasar](./tokens/token-extensions/transfer-hook/whitelist/quasar)

## NFTs

### Mint NFT
Expand Down
2 changes: 1 addition & 1 deletion finance/lending/anchor-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ three limits protect the people already there:
`token_interface`, so Token Extensions mints are accepted, but a transfer-fee
extension would make the vault receive less than the recorded deposit and the
accounting would overstate `available_liquidity`. Production lending programs
whitelist mints; a market owner here must only create reserves for tokens
keep an allow list of mints; a market owner here must only create reserves for tokens
without transfer fees.
- This is an example. Deploying any program that custodies funds calls for a
professional security audit first.
Expand Down
2 changes: 1 addition & 1 deletion finance/lending/anchor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ three limits protect the people already there:
`token_interface`, so Token Extensions mints are accepted, but a transfer-fee
extension would make the vault receive less than the recorded deposit and the
accounting would overstate `available_liquidity`. Production lending programs
whitelist mints; a market owner here must only create reserves for tokens
keep an allow list of mints; a market owner here must only create reserves for tokens
without transfer fees.
- This is an example. Deploying any program that custodies funds calls for a
professional security audit first.
Expand Down
2 changes: 1 addition & 1 deletion finance/managed-fund/anchor-v1/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ A **multi-asset** fund built from single-asset vaults, transparently priced on-c

Program instructions the frontend can surface:

- **Registry / curator:** `initialize_registry`, `approve_asset` (whitelist of assets managers may use).
- **Registry / curator:** `initialize_registry`, `approve_asset` (an allow list of assets managers may use).
- **Manager:** `initialize_fund` (fee, slippage tolerance, and rebalance threshold, all fixed at creation), `add_asset`, `set_weight` (including set-to-zero to retire an asset), `collect_fees`.
- **Anyone:** `rebalance(sell_index, buy_index)`. The program sizes the trade from oracle prices and target weights, and refuses unless the asset sold is over its target by at least the fund's threshold and the asset bought is under its own (`DriftBelowThreshold`, `NotUnderweight`).
- **Depositor:** `deposit` (USDC → minted shares), `withdraw` (burn shares → in-kind slice of every vault).
Expand Down
2 changes: 1 addition & 1 deletion finance/managed-fund/anchor/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ A **multi-asset** fund built from single-asset vaults, transparently priced on-c

Program instructions the frontend can surface:

- **Registry / curator:** `initialize_registry`, `approve_asset` (whitelist of assets managers may use).
- **Registry / curator:** `initialize_registry`, `approve_asset` (an allow list of assets managers may use).
- **Manager:** `initialize_fund` (fee, slippage tolerance, and rebalance threshold, all fixed at creation), `add_asset`, `set_weight` (including set-to-zero to retire an asset), `collect_fees`.
- **Anyone:** `rebalance(sell_index, buy_index)`. The program sizes the trade from oracle prices and target weights, and refuses unless the asset sold is over its target by at least the fund's threshold and the asset bought is under its own (`DriftBelowThreshold`, `NotUnderweight`).
- **Depositor:** `deposit` (USDC → minted shares), `withdraw` (burn shares → in-kind slice of every vault).
Expand Down
11 changes: 6 additions & 5 deletions scripts/generate-quasar-readmes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ const examples = {
purpose: "Allow/block list enforced by a transfer hook program.",
concepts: ["Transfer hook", "List authority"],
},
"tokens/token-extensions/transfer-hook/allow-list/quasar": {
title: "Transfer Hook - Allow List",
purpose:
"Only wallets on the mint's allow list may receive its tokens. Each allowed wallet is its own [PDA](https://solana.com/docs/terminology#program-derived-address-pda), so the list has no size limit.",
concepts: ["Transfer hook", "One account per allowed wallet", "Mint-authority-only setup"],
},
"tokens/token-extensions/transfer-hook/counter/quasar": {
title: "Transfer Hook - Counter",
purpose: "Count transfers in hook-side state.",
Expand All @@ -236,11 +242,6 @@ const examples = {
purpose: "Globally enable or disable transfers.",
concepts: ["Transfer hook", "Admin switch"],
},
"tokens/token-extensions/transfer-hook/whitelist/quasar": {
title: "Transfer Hook - Whitelist",
purpose: "Only whitelisted accounts may receive tokens.",
concepts: ["Transfer hook", "Whitelist PDA"],
},
};

const redirects = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Transfer Hook - Allow List (Anchor)

> [!NOTE]
> This is the **Anchor v1** copy of this example, on Anchor 1.2.1, the current
> stable Anchor release. Every `anchor` command on this page needs the v1 CLI:
> `avm install 1.2.1 && avm use 1.2.1`. The Anchor v2 version of this example is in
> [`../anchor`](../anchor/).

A [Token Extensions](https://solana.com/docs/terminology#token-extensions-program) transfer hook that only lets a mint's tokens reach wallets on that mint's allow list. Every transfer of the mint calls the hook, and the hook rejects the transfer unless the recipient wallet is allowed.

See also: the [repository catalog](../../../../../README.md), and [Allow/Block List](../../allow-block-list-token/) for a list that can also block wallets or allow transfers below a threshold.

## Major concepts

- Transfer hook with one extra account, resolved per transfer
- One account per allowed wallet, so the list has no size limit
- Only the mint authority can set up a mint's allow list

## Accounts

- The **allow list** account lives at a PDA with seeds `"allow_list"` and the mint. It holds the `authority` that may allow and disallow wallets: the mint authority who created it. One per mint.
- An **allowed wallet** account lives at a PDA with seeds `"allowed_wallet"`, the mint and the wallet. Its existence is the permission. `allow_wallet` creates it and `disallow_wallet` closes it, returning the rent to the authority.

Because each allowed wallet is its own account, allowing the ten-thousandth wallet costs the same as allowing the first.

## How a transfer is checked

`initialize_extra_account_meta_list` stores one extra account in the mint's `ExtraAccountMetaList`: the allowed wallet PDA, with seeds `"allowed_wallet"`, the mint (account 1 of the transfer) and the destination token account's owner (account 2, bytes 32 to 64). On every transfer Token Extensions derives that address for the actual recipient and passes the account to the hook. The hook re-derives the address from the destination token account and fails with `RecipientNotAllowed` unless an allowed wallet account exists there.

The hook checks the wallet that owns the destination token account, not the token account itself, so allowing a wallet covers every token account it holds for that mint.

## Instruction handlers

- `initialize_extra_account_meta_list`: creates the mint's `ExtraAccountMetaList` and allow list. The signer must be the mint authority, and becomes the allow list's authority.
- `allow_wallet`: the allow list's authority adds a wallet.
- `disallow_wallet`: the allow list's authority removes a wallet.
- `transfer_hook`: called by Token Extensions on every transfer.

## Setup

From this directory (`tokens/token-extensions/transfer-hook/allow-list/anchor-v1/`):

```bash
anchor build
```

Prerequisites: [Agave](https://docs.anza.xyz/) CLI (version in `Anchor.toml` `[toolchain]`), [Anchor](https://www.anchor-lang.com/docs).

## Testing

Tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm). No local validator.

```bash
anchor test
```

This runs `cargo test` as configured in `Anchor.toml`. The tests make real Token Extensions transfers through the hook. They check that a transfer reaches an allowed wallet; that a wallet not on the list, a disallowed wallet, or a wallet allowed only for another mint is refused; that fifty wallets can be allowed; that only the mint authority can create the allow list; and that only its authority can allow a wallet.

## Usage

Read the program `programs/` source and `Anchor.toml` for deployed program IDs. For deployment, use `anchor build && anchor program deploy` against your target cluster.
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ custom-heap = []
custom-panic = []

[dependencies]
anchor-lang = { version = "1.2.1", features = ["init-if-needed"] }
anchor-lang = "1.2.1"
anchor-spl = "1.2.1"
spl-discriminator = "0.4.1"
spl-tlv-account-resolution = "0.9.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use anchor_lang::prelude::*;

use crate::{AllowList, AllowedWallet, ALLOWED_WALLET_SEED, ALLOW_LIST_SEED};

#[derive(Accounts)]
pub struct AllowWalletAccountConstraints<'info> {
#[account(mut, address = allow_list.authority)]
pub authority: Signer<'info>,
/// CHECK: only its address is used, as a seed; the allow list's own seeds
/// tie it to this mint.
pub mint: UncheckedAccount<'info>,
#[account(seeds = [ALLOW_LIST_SEED, mint.key().as_ref()], bump = allow_list.bump)]
pub allow_list: Account<'info, AllowList>,
/// CHECK: the wallet being allowed to receive the token. Any address: a
/// person's wallet or a PDA owned by another program.
pub wallet: UncheckedAccount<'info>,
#[account(
init,
seeds = [ALLOWED_WALLET_SEED, mint.key().as_ref(), wallet.key().as_ref()],
bump,
payer = authority,
space = AllowedWallet::DISCRIMINATOR.len() + AllowedWallet::INIT_SPACE
)]
pub allowed_wallet: Account<'info, AllowedWallet>,
pub system_program: Program<'info, System>,
}

pub fn handler(context: Context<AllowWalletAccountConstraints>) -> Result<()> {
context.accounts.allowed_wallet.bump = context.bumps.allowed_wallet;
Ok(())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
use anchor_lang::prelude::*;

use crate::{AllowList, AllowedWallet, ALLOWED_WALLET_SEED, ALLOW_LIST_SEED};

#[derive(Accounts)]
pub struct DisallowWalletAccountConstraints<'info> {
#[account(mut, address = allow_list.authority)]
pub authority: Signer<'info>,
/// CHECK: only its address is used, as a seed; the allow list's own seeds
/// tie it to this mint.
pub mint: UncheckedAccount<'info>,
#[account(seeds = [ALLOW_LIST_SEED, mint.key().as_ref()], bump = allow_list.bump)]
pub allow_list: Account<'info, AllowList>,
/// CHECK: the wallet losing its permission; only its address is used.
pub wallet: UncheckedAccount<'info>,
/// Closing the account is the whole operation: rent goes back to the authority.
#[account(
mut,
close = authority,
seeds = [ALLOWED_WALLET_SEED, mint.key().as_ref(), wallet.key().as_ref()],
bump = allowed_wallet.bump
)]
pub allowed_wallet: Account<'info, AllowedWallet>,
}

pub fn handler(_context: Context<DisallowWalletAccountConstraints>) -> Result<()> {
Ok(())
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ use anchor_spl::token_interface::Mint;
use spl_tlv_account_resolution::state::ExtraAccountMetaList;
use spl_transfer_hook_interface::instruction::ExecuteInstruction;

use crate::{handle_extra_account_metas, handle_extra_account_metas_count, WhiteList};
use crate::{
handle_extra_account_metas, handle_extra_account_metas_count, AllowList, ALLOW_LIST_SEED,
};

#[derive(Accounts)]
pub struct InitializeExtraAccountMetaListAccountConstraints<'info> {
/// The mint's mint authority, who becomes the allow list's authority. Only
/// the issuer can set up a mint's allow list, so nobody can claim it first.
#[account(mut)]
payer: Signer<'info>,

Expand All @@ -22,18 +26,22 @@ pub struct InitializeExtraAccountMetaListAccountConstraints<'info> {
payer = payer
)]
pub extra_account_meta_list: UncheckedAccount<'info>,
#[account(mint::authority = payer)]
pub mint: InterfaceAccount<'info, Mint>,
#[account(
init,
seeds = [ALLOW_LIST_SEED, mint.key().as_ref()],
bump,
payer = payer,
space = AllowList::DISCRIMINATOR.len() + AllowList::INIT_SPACE
)]
pub allow_list: Account<'info, AllowList>,
pub system_program: Program<'info, System>,
#[account(init_if_needed, seeds = [b"white_list"], bump, payer = payer, space = WhiteList::DISCRIMINATOR.len() + WhiteList::INIT_SPACE)]
pub white_list: Account<'info, WhiteList>,
}

pub fn handler(
mut context: Context<InitializeExtraAccountMetaListAccountConstraints>,
) -> Result<()> {
// set authority field on white_list account as payer address
context.accounts.white_list.authority = context.accounts.payer.key();
context.accounts.white_list.bump = context.bumps.white_list;
pub fn handler(context: Context<InitializeExtraAccountMetaListAccountConstraints>) -> Result<()> {
context.accounts.allow_list.authority = context.accounts.payer.key();
context.accounts.allow_list.bump = context.bumps.allow_list;

let extra_account_metas = handle_extra_account_metas()?;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
pub mod add_to_whitelist;
pub mod allow_wallet;
pub mod disallow_wallet;
pub mod initialize_extra_account_meta_list;
pub mod transfer_hook;

pub use add_to_whitelist::*;
pub use allow_wallet::*;
pub use disallow_wallet::*;
pub use initialize_extra_account_meta_list::*;
pub use transfer_hook::*;
Loading
Loading