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.
.gitignore. Checkpoint 0
covers both. Do not skip it.
What you are given, and what you are not
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.
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
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
4 · Build, then test
anchor build
cargo test
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.
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
| Instruction | Does | Gated to |
|---|---|---|
| initialize | Creates the global Config | the hardcoded ADMIN |
| initialize_mint | Creates the Token-2022 mint | config.admin |
| propose_admin / confirm_admin | Two-step admin handoff | current admin / new admin |
| transfer / burn | Ordinary holder actions | the token owner |
| forced_transfer / forced_burn | Admin moves or destroys anyone's tokens | config.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
| File | Why |
|---|---|
| src/instructions/mint.rs | Where the mint is created. Almost all your work is here. |
| src/lib.rs | Thin wrappers. Changing an instruction's arguments means editing here too. |
| tests/common/mod.rs | The 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 formint_toorassociated_tokenand you get nothing. Tokens can be transferred and burned, but never created. That's step 3, and it is more work than it sounds.
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.
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 ...)]:
| Extension | Declarative? | Story it tells |
|---|---|---|
| metadata_pointer | yes | Required — step 1 of the challenge |
| permanent_delegate | yes | Clawback. Already used by this repo |
| close_authority | yes | The mint can be retired once supply hits zero |
| transfer_hook | yes | Run your own program on every transfer |
| group_pointer | yes | This mint heads a collection |
| group_member_pointer | yes | This mint belongs to a collection |
| transfer_fee | no | A cut of every transfer |
| non_transferable | no | Soulbound — badges, credentials |
| default_account_state | no | Frozen until approved — allowlists |
| interest_bearing | no | Balance 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.
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.
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.
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.
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
- Add
extensions::metadata_pointer::authorityand::metadata_addressto theinitconstraint. The address is the mint itself. - Take
name,symbol,urias instruction arguments and forward them fromlib.rs. - Work out how many bytes the metadata needs, and transfer enough lamports from the payer to cover rent at the new size.
- Call
token_metadata_initialize, signed by theauthorityPDA.
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.
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
)]
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.
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
- New file
src/instructions/mint_to.rs, registered ininstructions.rsand given a wrapper inlib.rs. - Gate it to
config.admin, the same wayinitialize_mintdoes. - The recipient's ATA is created if missing —
init_if_neededwith theassociated_token::constraints. - Mint with a
new_with_signerCPI, since the mint authority is theauthorityPDA.
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.
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.
| File | What to change |
|---|---|
| tests/common/mod.rs | init_mint — the shared helper every test file uses |
| tests/initialize_mint.rs | Builds 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.
The three tests
The challenge names them, and they get progressively more interesting:
- A successful mint. The mint is created and carries the metadata you asked for.
- A base flow. Mint to two wallets, then move tokens between them. The ordinary life of your token.
- 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.
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}");
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.
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
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
| Field | Value |
|---|---|
| base repository | ASCorreia/solana-summer-t22 |
| base branch | main |
| head repository | <you>/solana-summer-t22 |
| compare branch | feat/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.
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