Solana Summer · Token-2022 Challenge

Ship a token that says what it is.

The mint in this repo has no name, no symbol, no picture, and no way to put tokens in anyone's hands. You will give it an identity, pick one more extension that fits the story you want it to tell, mint to two wallets, and prove all of it with tests.

Level
Intermediate
Time
4–6 hours
Stack
Anchor 1.0 · Token-2022
Tests
Rust · LiteSVM
Submit
Pull request

What Token-2022 actually is

The original SPL Token program does one thing: it tracks balances. Every token on Solana behaves identically, and anything else — a name, a fee, a freeze — has to be bolted on from outside.

Token-2022 is a second, separate token program with the same core plus a menu of opt-in extensions. You choose them when you create the mint, and from then on the token program itself enforces them. A token that charges a fee on every transfer, or that can never be transferred at all, or that carries its own name and image — none of that needs a wrapper contract. It is the token.

That's why this assignment is more open than the last two. There is no single right answer to "which extension." The interesting work is deciding what your token is, and then picking the extension that makes it that.

Read this before you run anything Two things in this repo will stop you cold in the first ten minutes: a hardcoded admin key that is not yours, and a missing .gitignore. Checkpoint 0 covers both. Do not skip it.

What you are given, and what you are not

8Instructions already written — config, admin handoff, transfer, burn, and forced versions of both.
1Extension already on the mint: permanent delegate, which powers the forced transfer.
0Ways to put tokens in a wallet. There is no mint-to instruction anywhere.
0Lines of metadata. The mint has no name, symbol, or image.

Every step below has hints you can open when you want them, and the answers unlock once you have worked through the steps before them.

00
Setup

Fork & set up

1 · Fork, clone, branch

# Fork on GitHub first (button, top right), then:
git clone https://github.com/<you>/solana-summer-t22.git
cd solana-summer-t22

git remote add upstream https://github.com/ASCorreia/solana-summer-t22.git
git remote -v   # origin = yours, upstream = theirs

git switch -c feat/token-metadata

2 · Write a .gitignore — this repo has none

There is no .gitignore at all Not empty — absent. Nothing is ignored, including target/, which after one anchor build is hundreds of megabytes. A single git add . would try to commit all of it. Create the file before you build anything.
cat > .gitignore <<'EOF'
/target
node_modules/
.anchor/
test-ledger/
*.log
EOF

Include this in your pull request. It is a genuine improvement to the repo and every future contributor benefits.

3 · Point ADMIN at your own wallet

constants.rs hardcodes a single admin address, and initialize refuses anyone else:

pub const ADMIN: Pubkey = pubkey!("AHYic562KhgtAEkb1rSesqS87dFYRcfXb4WwWus3Zc9C");

Meanwhile the test harness loads your local CLI wallet from ~/.config/solana/id.json and signs as the admin with it. Unless you happen to hold that exact key, every test fails immediately at init_config with Unauthorized — including tests you have not touched.

solana address        # copy this
# paste it into constants.rs as the ADMIN value
Revert this line before you open your PR Your wallet address is not an improvement to anyone else's repo. Keep the change local, and see checkpoint 8 for how to leave it out of the pull request.

4 · Build, then test

anchor build
cargo test
Order matters The harness embeds the compiled program with include_bytes!("../../../../target/deploy/solana_summer_t22.so"). Run cargo test first and you get a compile error about a missing file, not a test failure. Build first, every time.

All seven existing test files should pass once ADMIN is yours. If they do not, fix that before writing code — you cannot tell your breakage from the environment's otherwise.

01
Orientation

Read what you have

There is no README. So part of the work is reading an unfamiliar codebase and deciding what is in scope.

The program

InstructionDoesGated to
initializeCreates the global Configthe hardcoded ADMIN
initialize_mintCreates the Token-2022 mintconfig.admin
propose_admin / confirm_adminTwo-step admin handoffcurrent admin / new admin
transfer / burnOrdinary holder actionsthe token owner
forced_transfer / forced_burnAdmin moves or destroys anyone's tokensconfig.admin

The forced instructions are the interesting ones. They work because the mint was created with the permanent delegate extension pointing at the authority PDA — so the program can move tokens it does not own. That is an extension doing something no ordinary token program could.

The three files that matter to you

FileWhy
src/instructions/mint.rsWhere the mint is created. Almost all your work is here.
src/lib.rsThin wrappers. Changing an instruction's arguments means editing here too.
tests/common/mod.rsThe shared harness — program loading, PDAs, signing, and a helper that fabricates token accounts.

What is missing

  • No metadata. The mint has no name, symbol, or URI. That's step 1 of the challenge.
  • No way to mint. Search the whole src/ tree for mint_to or associated_token and you get nothing. Tokens can be transferred and burned, but never created. That's step 3, and it is more work than it sounds.
How the existing tests get tokens, and why it matters later fund_token_account in the harness does not mint anything — it writes a 165-byte token account straight into the validator's state with a balance already in it. That works because the current extensions do not change what a token account looks like. Keep it in mind for checkpoint 2; some extensions do.
02
Design

Choose your extension

The challenge asks for one extension besides metadata that fits your token's story, and why you picked it. So decide the story first. A concert ticket, a membership badge, a game currency, a stablecoin, a bond — each one implies different rules, and the extension is how you enforce them.

What Anchor will let you declare

This constrains your choice more than you would expect. Anchor 1.0 understands exactly six mint extensions inside #[account(init ...)]:

ExtensionDeclarative?Story it tells
metadata_pointeryesRequired — step 1 of the challenge
permanent_delegateyesClawback. Already used by this repo
close_authorityyesThe mint can be retired once supply hits zero
transfer_hookyesRun your own program on every transfer
group_pointeryesThis mint heads a collection
group_member_pointeryesThis mint belongs to a collection
transfer_feenoA cut of every transfer
non_transferablenoSoulbound — badges, credentials
default_account_statenoFrozen until approved — allowlists
interest_bearingnoBalance displays accrued yield

Why the bottom four are harder

Token-2022 requires most extensions to be initialized after the account is allocated but before InitializeMint runs. Anchor's init does allocation and initialization as one atomic step, so there is no gap to slip them into. Choosing one of those means abandoning init and creating the mint by hand — a real project, not a checkpoint.

Metadata is the exception, and understanding why is the point of checkpoint 3.

Some choices break the existing test harness fund_token_account writes a plain 165-byte token account. Extensions that change what a token account must contain — transfer fees need a TransferFeeAmount on every account, default account state changes whether new accounts start frozen — will make that helper produce accounts the token program rejects. You would have to rewrite the harness too. Another reason the declarative six are the sane picks for this assignment.
Write the "why" down now The challenge asks you to say why you picked it. Write two or three sentences before you write any code, in your PR description or a NOTES.md. If you cannot explain why the extension fits the story, the story is not specific enough yet — and that is worth discovering now rather than after the code works.
03
Metadata

Give it a name

What this does. Puts a name, symbol, and image URI on the mint — stored in the mint account itself, not in some separate registry.

This is the part of Token-2022 that replaces Metaplex for simple cases. Instead of a separate metadata account owned by another program, the token carries its own.

Two pieces, and they are not the same thing

The metadata pointer is a fixed-size extension that says "my metadata lives at this address." You declare it in init, and you point it at the mint itself.

The metadata is the actual name, symbol, and URI. It is variable length — it depends on how long your strings are — which is exactly why init cannot allocate space for it.

This is the part that will confuse you init sizes the account from the extensions you declare. It has no idea your token is called "Summer Token" and not something 40 characters long. So after init finishes, the account is too small — and you must send it more lamports to cover the extra rent before Token-2022 will grow it and write the metadata in. Skip that and you get a cryptic insufficient-funds failure, not a message about size.

Requirements

  1. Add extensions::metadata_pointer::authority and ::metadata_address to the init constraint. The address is the mint itself.
  2. Take name, symbol, uri as instruction arguments and forward them from lib.rs.
  3. Work out how many bytes the metadata needs, and transfer enough lamports from the payer to cover rent at the new size.
  4. Call token_metadata_initialize, signed by the authority PDA.
Hint Calculating the space

ExtensionType::try_calculate_account_len::<Mint>(&[...]) gives you the size with your fixed-size extensions. Then TokenMetadata { name, symbol, uri, ..Default::default() }.tlv_size_of()? gives you the variable part. Rent for the sum is what the account needs.

List every fixed extension you declared in the calculation — permanent delegate, metadata pointer, and whatever you added in checkpoint 4. Miss one and you underfund the account.

Hint Who signs the metadata call

token_metadata_initialize requires the mint authority to sign, and that is the authority PDA — not the payer. So it is a new_with_signer CPI with the seeds you already see in forced_transfer.rs:

let signer_seeds: &[&[&[u8]]] = &[&[b"authority", &[ctx.bumps.authority]]];

Pass the same PDA as both mint_authority and update_authority, and pass the mint as the metadata account — because that is where the metadata lives.

Hint CpiContext takes a Pubkey here

In Anchor 1.0, CpiContext::new and new_with_signer take the program's Pubkey, not an AccountInfo. If you have seen 0.3x tutorials, this is the change that bites first: ctx.accounts.token_program.key(), not .to_account_info(). The existing instructions in this repo already do it the right way — copy their shape.

04
Your choice

Add your extension

If you picked from the declarative six, this is one line in the init constraint. That is not a shortcut — it is the payoff for having chosen well in checkpoint 2.

#[account(
    init,
    payer = payer,
    mint::decimals = 6,
    mint::authority = authority,
    mint::token_program = token_program,
    extensions::permanent_delegate::delegate = authority,
    extensions::metadata_pointer::authority = authority,
    extensions::metadata_pointer::metadata_address = mint,
    // your extension goes here
)]
Go back and update your space calculation Every fixed-size extension adds bytes to the mint. If you added one in this checkpoint but did not add it to the try_calculate_account_len list in checkpoint 3, the account is underfunded and the metadata call fails — with an error that points at the metadata, not at the extension you just added.

Then justify it

Write down what your token is and why this extension makes it that. Two or three sentences. This is a graded part of the challenge, and it is the part that shows whether you understand what you built or just made the compiler happy.

A weak answer names the extension again in different words. A strong one describes a situation the token would face and what the extension does about it.

05
New instruction

Mint to two wallets

What this does. Creates a wallet's associated token account if it does not have one, then mints tokens into it.

There is no instruction for this in the repo — you are writing a new one. This is the largest single piece of work in the assignment.

What an ATA is

A wallet cannot hold tokens directly. Each wallet needs a separate token account per mint, and the associated token account is the one at a deterministic address derived from the wallet and the mint. That determinism is why anyone can send you tokens without asking you to create an account first — the address is computable.

Requirements

  1. New file src/instructions/mint_to.rs, registered in instructions.rs and given a wrapper in lib.rs.
  2. Gate it to config.admin, the same way initialize_mint does.
  3. The recipient's ATA is created if missing — init_if_needed with the associated_token:: constraints.
  4. Mint with a new_with_signer CPI, since the mint authority is the authority PDA.
init_if_needed needs a Cargo feature Add it in programs/solana-summer-t22/Cargo.toml or nothing compiles:
anchor-lang = { version = "1.0.2", features = ["init-if-needed"] }
Anchor makes you opt in because a careless init_if_needed can be re-run by an attacker to reset an account. Here it is safe: the instruction is admin-gated, and an ATA's address is fully determined by the wallet and mint, so there is nothing to reset and nothing to redirect.
Hint The accounts you need

payer (signer, mut), config, recipient (an UncheckedAccount — you only use it to derive the ATA), authority PDA, mint (mut), recipient_ata, plus token_program, associated_token_program and system_program.

The mint must be mut — minting changes its supply.

Hint Two wallets, one instruction

You do not need a second instruction. Call the same one twice with different recipient accounts. In a test you can even put both instructions in a single transaction, which is a nice demonstration that they either both land or neither does.

06
tests/common

Repair the harness

Adding name, symbol and uri to initialize_mint changes its serialized data, so every test that builds that instruction stops compiling. There are two places.

FileWhat to change
tests/common/mod.rsinit_mint — the shared helper every test file uses
tests/initialize_mint.rsBuilds the instruction directly, for the non-admin rejection test

Keep init_mint's signature as it is and have it pass sensible defaults, then add a second helper that takes the strings. Six test files call init_mint and do not care about metadata; only your new test does. One change, no churn in files you are not working on.

A useful habit When you change a shared helper, ask whether the existing callers actually need to know. If they do not, keep their signature intact and add a new entry point beside it. Six untouched files is a smaller diff and a much easier review.
07
tests

The three tests

The challenge names them, and they get progressively more interesting:

  1. A successful mint. The mint is created and carries the metadata you asked for.
  2. A base flow. Mint to two wallets, then move tokens between them. The ordinary life of your token.
  3. An extension-specific assertion. Prove the extension you chose is actually doing something — a state change, a fee, a UI amount, or a failure that should happen.

Reading extensions back off the mint

The existing harness reads raw byte offsets, which works for base fields but not for extensions. For those, unpack properly:

use anchor_spl::token_2022::spl_token_2022::{
    extension::{BaseStateWithExtensions, StateWithExtensions},
    state::Mint as MintState,
};

let account = svm.get_account(&mint.pubkey()).unwrap();
let state = StateWithExtensions::<MintState>::unpack(&account.data)?;

From there, state.get_extension::<T>() for fixed-size extensions, and get_variable_len_extension::<TokenMetadata>() for the metadata.

No new dependency required anchor-spl is already a dependency of the program crate, and integration tests can use it. You do not need to add spl-token-2022 to Cargo.toml to read extensions.
Hint What makes the third test good

The weak version asserts the extension exists. The strong version asserts it changed an outcome.

  • close_authority — assert the authority is your PDA, then try closing the mint while supply is non-zero and assert it fails.
  • metadata — round-trip the exact strings, then update a field and assert the new value.
  • transfer_hook — assert the hook program is set, and that a transfer invokes it.
  • group pointers — assert the group address resolves to the mint you expect.

An expected failure is often the sharpest test you can write, because it can only pass if the rule is really being enforced.

Hint Assert on the error, not just on failure

assert!(res.is_err()) passes for any failure — a missing account, a typo in your accounts struct, a wrong signer. Check the logs mention the error you actually expect:

let err = res.unwrap_err();
let logs = err.meta.logs.join("\n");
assert!(logs.contains("Unauthorized"), "got: {logs}");
Check yourself anchor build && cargo test — all seven existing test files still pass, plus your three. If an old test broke, you changed behavior you were not asked to change.
08
Submit

Open the PR

1 · Take the ADMIN change back out

You pointed ADMIN at your own wallet in checkpoint 0. That must not go upstream. Restore the original value before you commit:

git checkout upstream/main -- programs/solana-summer-t22/src/constants.rs
git diff upstream/main -- programs/solana-summer-t22/src/constants.rs   # should be empty

Your tests will fail locally again after this. That is expected and correct — put your address back to keep working, and take it out again right before you push.

2 · Look before you stage

git status
git diff
You created the .gitignore — make sure it is committed first If target/ shows up in git status, your .gitignore is missing or was written after the files were already staged. Commit the .gitignore before anything else, and if build output did get staged: git rm -r --cached target.

3 · Commit in logical pieces

git add .gitignore
git commit -m "Add gitignore for build output"

git add programs/solana-summer-t22/src programs/solana-summer-t22/Cargo.toml
git commit -m "Add token metadata and mint-to instruction"

git add programs/solana-summer-t22/tests
git commit -m "Test metadata, minting to two wallets, and the close authority"

4 · Sync, push, open

git fetch upstream
git rebase upstream/main
git push -u origin feat/token-metadata
FieldValue
base repositoryASCorreia/solana-summer-t22
base branchmain
head repository<you>/solana-summer-t22
compare branchfeat/token-metadata

5 · The description carries your reasoning

## What
Adds token metadata (name, symbol, URI) to the mint, a close-authority
extension, and a mint_to instruction that creates the recipient's ATA.

## The token
<two or three sentences: what your token is, and why this extension
fits it. This is the part the challenge actually grades.>

## Notes
- TokenMetadata is variable length, so `init` cannot size the account for
  it. The handler tops up rent for the metadata before initializing it.
- `init_if_needed` is enabled for the ATA in mint_to. Safe here: the
  instruction is admin-gated and an ATA address is fully determined.
- Added a .gitignore — the repo had none, so `target/` was untracked.

## Testing
`anchor build && cargo test` — all existing tests pass, plus three new
ones covering the mint, a two-wallet mint-and-transfer flow, and an
extension-specific assertion.
?
When it breaks

Troubleshooting

Error Every test fails with Unauthorized, before you changed anything

The hardcoded ADMIN in constants.rs is not your wallet. The harness signs as the admin using ~/.config/solana/id.json, and initialize rejects any other key. Run solana address and paste the result into constants.rs. Checkpoint 0, step 3 — and remember to take it back out before your PR.

Error couldn't read .../target/deploy/solana_summer_t22.so

The harness embeds the compiled program at compile time, so a missing .so is a build error rather than a test failure. Run anchor build first, and again after every change to the program.

Error expected `Pubkey`, found `AccountInfo`

Anchor 1.0 changed CpiContext::new and new_with_signer to take the program's Pubkey. Use ctx.accounts.token_program.key() rather than .to_account_info(). Older tutorials show the other form.

Error init_if_needed requires that anchor-lang be imported with the feature

Add it in the program's Cargo.toml:

anchor-lang = { version = "1.0.2", features = ["init-if-needed"] }

Anchor gates this deliberately — read the warning it prints. It is safe for an ATA behind an admin check; it is not safe as a habit.

Error The metadata call fails with insufficient lamports

You underfunded the mint. Either you skipped the rent top-up entirely, or your try_calculate_account_len list is missing an extension you declared — most often the one you added in checkpoint 4. Every fixed-size extension in the init constraint must appear in that list.

Error missing fields name, symbol and uri

Expected — you changed initialize_mint's arguments and the test call sites have not caught up. There are two: tests/common/mod.rs and tests/initialize_mint.rs. Checkpoint 6.

Error Your PR shows thousands of files

The repo ships without a .gitignore, so target/ was never ignored. Create it, then untrack what got staged:

git rm -r --cached target node_modules .anchor
git add .gitignore
git commit --amend --no-edit
git push --force-with-lease

Force-pushing is fine before review has started. Once someone has commented, ask first.

Error No such file or directory (os error 2)

Anchor failed to launch a binary and will not say which. Run:

which rustc cargo solana cargo-build-sbf avm agave-install

Whichever comes back empty is the answer. Install Rust through rustup rather than Homebrew — a brew Rust lands where Anchor does not look.

Solana Summer · Token-2022 · program CRbduMFSfVvUDuChQrhjhaRD8MgoaAr5Dnyr6F3knKte · submit by pull request