Solana Fall School · Assignment

Put a speed limit on token transfers.

This repo is a Token-2022 transfer hook that blocks a transfer when too many tokens move in one hour. It works, but it cuts corners. Your job is to fix those corners in four challenges.

Start
Fork the repo
Anchor
1.2.0
Tests
Rust · LiteSVM
Challenges
4

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.

Base units, not whole tokens The mint has 9 decimals, so 1,000,000 base units is 0.001 of a token. All amounts in the code and tests are base units.

The files you will touch

initialize.rsCreates the rate limit account. Challenges 1, 2, 3.
rate_limit.rsThe account struct and its helper methods. Challenge 2.
init_extra_account_meta.rsTells Token-2022 which extra accounts the hook needs. Challenge 3.
transfer_hook.rsThe check that runs on every transfer. Challenge 3.
tests/helpers/mod.rsBuilds the test transactions. Update it with every change.
transfer.rsEmpty. Challenge 4 starts here.

Search the code for CHALLENGE and For the challenge. Those comments mark where each change goes.

00
Setup

Fork the repo

When you finish this: your own copy of the assignment on GitHub and on your machine.

  1. Open github.com/decentra1ized/solana-fall-transfer-hook and click Fork, top right.
  2. 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
Add a .gitignore first

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

ToolVersionHow to get it
Anchor CLI1.2.0avm install 1.2.0 && avm use 1.2.0
Rust1.98.1Automatic. rust-toolchain.toml pins it and rustup downloads it on first build
Solana CLI3.xAnchor 1.x targets Solana 3
Node, YarnLTS, 1.22nvm install --lts

Push after each challenge, so your progress is saved:

git add -A && git commit -m "challenge 1" && git push
01
Starting point

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
Always build before you test

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.

02
Read before you write

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:

InstructionWhat it does
initialize_mintCreates a Token-2022 mint whose transfer hook points at this program
initializeCreates the RateLimit account with a cap of 1,000,000 and an empty total
initialize_extra_account_meta_listRecords which extra accounts Token-2022 must pass to the hook. Here, just the rate limit PDA
transfer_hookRuns on every transfer. Checks the limit, adds the amount, or rejects

What happens on each transfer

  1. Confirm the hook was called by a real transfer, not directly.
  2. If more than one hour has passed since window_start, reset the total to zero.
  3. If the total plus this amount is over the cap, fail with RateLimitExceeded.
  4. Otherwise add the amount to the total and let the transfer through.
Why the window never slides

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.

The shortcut you are about to fix The rate limit PDA uses one seed, "rate_limit". So there is exactly one account for the whole program. Every user and every mint share the same 1,000,000 budget.
03
Challenge 1 · warm-up

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.

04
Challenge 2 · warm-up

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(),
No size maths needed #[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.
05
Challenge 3 · hard

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:

IndexAccount
0source_token
1mint
2destination_token
3owner
4extra_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.

New rule after this challenge Every owner needs their own 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:

  1. Create a second wallet, airdrop it SOL, and mint it tokens.
  2. Call initialize with the second wallet as payer.
  3. Both wallets send 1,000,000 in the same hour.

Both should succeed. Before this challenge, the second transfer would have failed.

You are done when All tests pass, including your new one. A seeds error means one of the five places does not match the others.
06
Challenge 4 · hardest

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
This is the re-entrancy in the challenge

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>,
}
Where the hook's accounts go

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

  1. Build a plain transfer_checked instruction.
  2. List the account infos that go with it.
  3. 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.
  4. 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

  1. Run anchor build. It builds both programs.
  2. In setup(), load the new program next to the hook with another svm.add_program(...), pointing at target/deploy/token_mover.so.
  3. So the tests can use its types, add it to the hook's [dev-dependencies]: token-mover = { path = "../token-mover", features = ["no-entrypoint"] }.
  4. Build the instruction with token_mover::accounts::TransferWithHook and token_mover::instruction::TransferWithHook, the same way the tests build Initialize.
  5. Push the remaining accounts, hook program first. Copy the three ix.accounts.push lines from build_transfer_with_hook_ix.

Then write two tests:

TestExpectWhat it proves
Send 100 through your programSuccessThe CPI and extra accounts are right
Send 1,000,000, then 1 moreSecond fails, 0x1771The hook still runs inside your CPI
You are done when Both new tests pass next to the old ones. The second test matters most: a transfer that skips the hook would pass the first test too.
?
When it breaks

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:

HexError
0x1770CustomError
0x1771RateLimitExceeded
0x1772InvalidMint
0x1773NotTransferring
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