What a transfer hook is
A Token-2022 mint can name a program as its transfer hook. Every time someone moves that token, Token-2022 calls the hook program. If the hook returns an error, the whole transfer fails.
This repo's hook keeps a running total. More than 1,000,000 base units in one hour, and the transfer is rejected.
The files you will touch
Search the code for CHALLENGE and For the challenge.
Those comments mark where each change goes.
Fork the repo
When you finish this: your own copy of the assignment on GitHub and on your machine.
- Open github.com/decentra1ized/solana-fall-transfer-hook and click Fork, top right.
- Clone your fork, not the original. Put your GitHub username in the link.
git clone https://github.com/YOUR-USERNAME/solana-fall-transfer-hook.git
cd solana-fall-transfer-hook
yarn install
The repo does not have one. Without it, your first commit will
upload thousands of build files from target/.
printf "target/\nnode_modules/\n.anchor/\ntest-ledger/\n" > .gitignore
Versions this repo expects
| Tool | Version | How to get it |
|---|---|---|
| Anchor CLI | 1.2.0 | avm install 1.2.0 && avm use 1.2.0 |
| Rust | 1.98.1 | Automatic. rust-toolchain.toml pins it and rustup downloads it on first build |
| Solana CLI | 3.x | Anchor 1.x targets Solana 3 |
| Node, Yarn | LTS, 1.22 | nvm install --lts |
Push after each challenge, so your progress is saved:
git add -A && git commit -m "challenge 1" && git push
Build and test
When you finish this: all four tests pass on the untouched code. Prove this before changing anything.
anchor build
cargo test
You should see four passing tests:
test test_initialize ... ok
test test_initialize_extra_account_meta_list ... ok
test test_transfer_hook ... ok
test test_transfer_hook_rate_limit_exceeded ... ok
The tests do not compile your program. They load the finished
.so file from target/deploy/ into LiteSVM, a
fast in-memory Solana.
Skip anchor build and the tests run your old program,
not your latest change.
How it works
When you finish this: you can explain what each instruction does and in what order they run.
The program has four instructions. They run in this order:
| Instruction | What it does |
|---|---|
| initialize_mint | Creates a Token-2022 mint whose transfer hook points at this program |
| initialize | Creates the RateLimit account with a cap of 1,000,000 and an empty total |
| initialize_extra_account_meta_list | Records which extra accounts Token-2022 must pass to the hook. Here, just the rate limit PDA |
| transfer_hook | Runs on every transfer. Checks the limit, adds the amount, or rejects |
What happens on each transfer
- Confirm the hook was called by a real transfer, not directly.
- If more than one hour has passed since
window_start, reset the total to zero. - If the total plus this amount is over the cap, fail with
RateLimitExceeded. - Otherwise add the amount to the total and let the transfer through.
A transfer never moves window_start. Only a reset does.
If every transfer refreshed the timestamp, someone sending once an hour would never get a reset, and their total would stay capped forever.
"rate_limit". So there is
exactly one account for the whole program. Every user and every mint
share the same 1,000,000 budget.
Validate the mint
Goal: initialize refuses to run unless it
is given a real Token-2022 mint.
Why it matters: right now initialize never
looks at a mint. Someone could create a rate limit for a normal SPL
Token mint. Those cannot have transfer hooks, so the limit would never
be enforced.
1 · Add the mint to the context
In initialize.rs, add this field to Initialize, above rate_limit:
pub mint: InterfaceAccount<'info, Mint>,
2 · Check who owns it
At the top of the handler, before set_inner, check that the
mint's owner is the Token-2022 program. If not, return
ErrorCode::InvalidMint.
InterfaceAccount accepts mints from both token programs.
Your check is what narrows it to Token-2022 only. The imports you need
are already at the top of the file.
Hint How to check the owner
Every account knows which program owns it:
ctx.accounts.mint.to_account_info().owner.
Compare it with token_2022::ID using
require_keys_eq!, which takes the error to return as
its last argument.
3 · Update the tests
Two places build Initialize: initialize_rate_limit
in tests/helpers/mod.rs, and tests/test_initialize.rs.
Add mint: mint.pubkey(), to both, then build and test.
Store the mint
Goal: each RateLimit account records which
mint it belongs to.
Why it matters: anyone reading the account later, a wallet, an explorer, or your own code, can see what it is for without working it out from addresses.
1 · Add the field
In state/rate_limit.rs:
pub struct RateLimit {
pub authority: Pubkey,
pub mint: Pubkey, // new
pub max_amount: u64,
// ...
}
2 · Set it
In initialize.rs, inside set_inner(RateLimit { ... }):
mint: ctx.accounts.mint.key(),
#[derive(InitSpace)] recalculates the account size, so the
new 32-byte Pubkey is counted for you. This step only works
after challenge 1, because it reads the mint you added there.
One limit per user
Goal: every owner gets their own rate limit for each mint, instead of one shared bucket.
Why it matters: with one shared bucket, a single user can send 1,000,000 and block everyone for an hour. That is a free way to freeze a token.
The idea
A PDA's address comes from its seeds. Add the mint and the owner to the seeds, and every mint + owner pair gets a different address, so a different account.
// before: one account for everyone
[b"rate_limit"]
// after: one account per mint, per owner
[b"rate_limit", mint, owner]
The code change is small. The hard part is that these seeds are written in five places, and all five must use the same seeds in the same order. One mismatch and the addresses stop lining up.
1 · transfer_hook.rs
Where the account is loaded. mint and owner are already in the struct:
seeds = [b"rate_limit", mint.key().as_ref(), owner.key().as_ref()],
2 · initialize.rs
Where the account is created. There is no owner field here,
so use payer: in the tests, the payer owns the tokens being sent.
seeds = [b"rate_limit", mint.key().as_ref(), payer.key().as_ref()],
Anchor reads fields top to bottom. If mint is declared below
rate_limit, the seeds cannot see it and the build fails.
3 · init_extra_account_meta.rs
This is what Token-2022 reads to find the account at transfer time. It cannot use names, only positions in the hook's account list. You fill in the two indexes:
&[
Seed::Literal { bytes: b"rate_limit".to_vec() },
Seed::AccountKey { index: ? }, // the mint
Seed::AccountKey { index: ? }, // the owner
],
Hint Finding the indexes
Token-2022 always passes the first accounts in a fixed order, and
the TransferHook struct follows that same order. Count
from zero:
| Index | Account |
|---|---|
| 0 | source_token |
| 1 | mint |
| 2 | destination_token |
| 3 | owner |
| 4 | extra_account_meta_list |
4 · tests/helpers/mod.rs
Two functions derive the address. Match the program's seeds exactly:
// in initialize_rate_limit (mint is a Keypair here)
&[b"rate_limit", mint.pubkey().as_ref(), payer.pubkey().as_ref()],
// in build_transfer_with_hook_ix (mint and owner are Pubkeys)
&[b"rate_limit", mint.as_ref(), owner.as_ref()],
5 · tests/test_initialize.rs
This test derives the address on its own. Use the same seeds as
initialize_rate_limit. The README lists four places and
misses this one.
initialize call before their
first transfer. An owner without one has no account at that address,
and the hook fails with AccountNotInitialized.
Hint Prove it really is per user
Passing the old tests is not proof. Write one new test:
- Create a second wallet, airdrop it SOL, and mint it tokens.
- Call
initializewith the second wallet as payer. - Both wallets send 1,000,000 in the same hour.
Both should succeed. Before this challenge, the second transfer would have failed.
Transfer from a program
Goal: an on-chain instruction that moves tokens by
calling Token-2022's transfer_checked, with the rate limit
still enforced.
Why it matters: so far only wallets send tokens. Real apps like vaults, escrows and games move tokens from inside a program. If your hook breaks for them, nobody can build on your token.
The call chain
A program calling another program is a CPI (cross-program invocation). Here there are two, one inside the other:
wallet
└─ your program transfer instruction
└─ Token-2022 transfer_checked
└─ hook transfer_hook, checks the rate limit
Your program must hand Token-2022 the hook's extra accounts too, or Token-2022 cannot run the hook and the transfer fails.
1 · The re-entrancy problem
The repo gives you transfer.rs inside the hook program.
But then "your program" and "hook" in the chain above are the
same program:
hook program → Token-2022 → hook program // blocked
Solana lets a program call itself directly. It blocks a program from
being called again through another program while it is
still running. The transaction fails with
ReentrancyNotAllowed.
Try it once in transfer.rs if you want to see the error.
Then fix it the way below.
The fix: put the transfer instruction in a
second program. The hook stays where it is. Now the
chain is mover → Token-2022 → hook, and no program appears twice.
2 · Create the second program
anchor new token-mover
This adds programs/token-mover and registers it in
Anchor.toml. In its Cargo.toml, copy these from
the hook's Cargo.toml:
[features]
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
[dependencies]
anchor-spl = "1.2.0"
spl-transfer-hook-interface = "2.1.0"
3 · The accounts
Only the accounts a normal transfer needs:
#[derive(Accounts)]
pub struct TransferWithHook<'info> {
pub owner: Signer<'info>,
#[account(mut, token::mint = mint, token::authority = owner)]
pub source_token: InterfaceAccount<'info, TokenAccount>,
pub mint: InterfaceAccount<'info, Mint>,
#[account(mut, token::mint = mint)]
pub destination_token: InterfaceAccount<'info, TokenAccount>,
pub token_program: Interface<'info, TokenInterface>,
}
Do not add the hook program, the extra account meta
list or the rate limit to this struct. The caller passes them as
remaining accounts, extra accounts after the named
ones, read with ctx.remaining_accounts.
That keeps your program generic. It works with any hook, not only this one.
4 · The handler, in four moves
- Build a plain
transfer_checkedinstruction. - List the account infos that go with it.
- Append the hook's accounts with
add_extra_accounts_for_execute_cpi. It reads the extra account meta list and adds exactly what the hook asked for. - Invoke Token-2022.
use anchor_lang::solana_program::program::invoke;
use anchor_spl::token_2022::spl_token_2022;
use spl_transfer_hook_interface::onchain::add_extra_accounts_for_execute_cpi;
// Get these from ctx.accounts with .to_account_info()
let (source, mint, destination, owner) = /* ... */;
let hook_program_id = ctx.remaining_accounts[0].key();
// 1. Build
let mut ix = spl_token_2022::instruction::transfer_checked(
/* token program, source, mint, destination, owner, &[], amount, decimals */
)?;
// 2. List (same order as the instruction's accounts)
let mut infos = vec![source.clone(), mint.clone(), destination.clone(), owner.clone()];
// 3. Append the hook's accounts
add_extra_accounts_for_execute_cpi(
&mut ix, &mut infos, &hook_program_id,
source, mint, destination, owner,
amount, ctx.remaining_accounts,
)?;
// 4. Invoke
invoke(&ix, &infos)?;
Hint Why the hook program ID comes from the caller
Your program should not hard-code one hook. Taking the ID from the first remaining account works with any mint.
It is also safe: Token-2022 checks the hook against the one stored in the mint. Pass the wrong program and the transfer simply fails.
Hint Lifetime errors on remaining_accounts
The helper needs the account infos and remaining_accounts
to share one lifetime. Name it on the handler and use it in both places:
pub fn handler<'info>(ctx: Context<'info, TransferWithHook<'info>>, amount: u64) -> Result<()>
Do the same on the instruction function in lib.rs.
5 · Test it
- Run
anchor build. It builds both programs. - In
setup(), load the new program next to the hook with anothersvm.add_program(...), pointing attarget/deploy/token_mover.so. - So the tests can use its types, add it to the hook's
[dev-dependencies]:token-mover = { path = "../token-mover", features = ["no-entrypoint"] }. - Build the instruction with
token_mover::accounts::TransferWithHookandtoken_mover::instruction::TransferWithHook, the same way the tests buildInitialize. - Push the remaining accounts, hook program first. Copy the three
ix.accounts.pushlines frombuild_transfer_with_hook_ix.
Then write two tests:
| Test | Expect | What it proves |
|---|---|---|
| Send 100 through your program | Success | The CPI and extra accounts are right |
| Send 1,000,000, then 1 more | Second fails, 0x1771 | The hook still runs inside your CPI |
Troubleshooting
Error couldn't read target/deploy/…so
You ran cargo test before building. Run anchor build first.
Error My change does nothing in the tests
The tests are using the old .so. Run
anchor build again, then cargo test.
Error missing field `mint` in initializer
You added an account to a struct, and a test still builds the old
version. Search tests/ for the struct name and add the
field everywhere.
Error cannot find value `mint` in this scope
In Initialize, the seeds use mint but
mint is declared below rate_limit. Move it up.
Error ConstraintSeeds, or a missing account at transfer
Your seeds do not match somewhere. Check all five places from
challenge 3. Compare the order of the seeds, not just
the values. Then check the indexes in extra_account_metas().
Error AccountNotInitialized (0xbc4)
After challenge 3, this owner has no rate limit account yet. Call
initialize with them as payer before they transfer.
Error ReentrancyNotAllowed
Your transfer instruction and the hook are in the same program. Move the transfer instruction into a second program. See challenge 4, step 1.
Error IncorrectAccount from your program's transfer
add_extra_accounts_for_execute_cpi could not find the hook
program or the extra account meta list in
remaining_accounts. Check you pushed all three, with the
hook program first.
Error unauthorized signer or writable account
The rate limit was passed as read-only. The hook writes to it, so
push it with AccountMeta::new, not
new_readonly.
Error custom program error: 0x1771
Anchor error codes start at 6000. This program's codes are:
| Hex | Error |
|---|---|
| 0x1770 | CustomError |
| 0x1771 | RateLimitExceeded |
| 0x1772 | InvalidMint |
| 0x1773 | NotTransferring |
Error Anchor warns the program ID does not match
A fresh clone makes a new program keypair that differs from
declare_id!. Sync them and rebuild:
anchor keys sync
anchor build
Transfer hook assignment · build before you test · fork of decentra1ized/solana-fall-transfer-hook