Solana Fall School · Assignment

Mint an NFT that can never leave your wallet.

You will mint a soulbound NFT with Metaplex Core, first in TypeScript, then from your own Anchor program. The bonus: numbered print editions, each with its own royalty.

Time
1.5 – 2 hr
Level
Beginner → Intermediate
Network
Devnet
Checkpoints
7

What you are building

A Metaplex Core asset is an NFT stored in one account. You add behaviour to it with plugins, small pieces of logic that Metaplex Core checks every time someone uses the asset.

A soulbound NFT cannot be sent or sold. Think of a diploma: it proves something about you, so it should stay in your wallet.

How one plugin makes it soulbound

You attach the PermanentFreezeDelegate plugin with two settings:

SettingWhat it does
frozen: trueThe asset is frozen from the moment it is created. Metaplex Core rejects every transfer and burn.
authority: NoneNobody is allowed to change the plugin. So nobody can ever unfreeze it.
You need both Frozen, but with an authority, is only temporarily locked: whoever holds the authority can unfreeze it later. The None authority is what makes it permanent.

The three tracks

01-easy-trackTypeScript only. Mint the NFT with a script. Everyone does this one.Beginner ~30 min
02-anchor-trackRust. The same mint, done by your own on-chain program.Intermediate 1.5–2 hrs
03-bonus-editionsTypeScript. Three numbered prints with different royalties.Intermediate ~45 min

Only track 1 is required; 2 and 3 are for going further. The Anchor time includes installing Rust, Solana and Anchor if you don't have them yet. Each track has a solution/ folder. Try it yourself first; you will learn far more from the errors than from the answer.

00
Setup

Fork and install

When you finish this: your own copy of the repo, with packages installed.

  1. Open the repo on GitHub and click Fork.
  2. The assignment lives on the main branch, so you can keep "Copy the main branch only" ticked.
  3. Clone your fork:
git clone https://github.com/YOUR-USERNAME/fall-school-metaplex-core.git
cd fall-school-metaplex-core
npm install

Run npm install once, at the repo root. It installs the packages for the TypeScript tracks (01 and 03) in one go.

Node 22.12 or newer Check with node --version. On an older Node the scripts fail with confusing import errors. Fix it with nvm install --lts.
01
Devnet money

Wallet and SOL

When you finish this: a devnet wallet with SOL in it.

cd 01-easy-track
npm run setup

This creates wallet.json at the repo root and asks devnet for 1 SOL. The TypeScript tracks share this wallet, so the bonus uses it too.

If it prints Airdrop failed, copy the address it printed into faucet.solana.com. Sign in with GitHub for a higher limit.

Never commit wallet.json It is your private key. .gitignore already skips it; do not force-add it, and do not put it in a pull request.
02
Track 1 · main task

Mint in TypeScript

Goal: npm run mint creates a soulbound NFT on devnet and prints its address.

Open 01-easy-track/scripts/2-mint-soulbound.ts. First set NAME to something with your name in it. Then complete the three TODOs.

TODO 1 · A fresh address for the asset

Every Core asset lives at its own new address. Make a signer for it:

const asset = generateSigner(umi);

TODO 2 · Create it with the plugin

This is the important part. Fill in the plugin's two settings from the table at the top:

await create(umi, {
  asset,
  name: NAME,
  uri: URI,
  plugins: [
    {
      type: "PermanentFreezeDelegate",
      frozen: ?,
      authority: { type: ? },
    },
  ],
}).sendAndConfirm(umi);

TODO 3 · Print the result

Log asset.publicKey.toString() and pass it to explorerAddress(...) for a clickable link. Delete the throw new Error(...) line, then run:

npm run mint
Optional Use your own image

Copy metadata-template.json, set your name and an image link, and upload it as a public GitHub Gist. Click Raw and use that link as URI.

03
Track 1 · prove it

Verify and submit

When you finish this: proof that your NFT really cannot move.

npm run verify -- YOUR_ASSET_ADDRESS

The script reads the asset from devnet, checks the plugin, then actually tries to send your NFT away. You want five PASS lines:

PASS  Asset exists: ...
PASS  PermanentFreezeDelegate plugin attached
PASS  Asset is frozen
PASS  Plugin authority is None (found: None)
PASS  Transfer attempt was rejected on-chain by MPL Core's freeze check
The failed transfer is the proof

A transfer can also fail because you have no SOL or devnet is slow. That proves nothing, so the script only passes when Metaplex Core itself rejects it for being frozen.

Keep at least 0.001 SOL in the wallet for this test.

Submit

Hand in your asset's explorer link:

https://explorer.solana.com/address/YOUR_ASSET_ADDRESS?cluster=devnet

Be ready to explain in one or two sentences why it cannot be transferred.

04
Track 2 · Rust

Finish the Anchor program

Goal: your own program mints the same soulbound NFT, by calling Metaplex Core from Rust.

A program calling another program is a CPI (cross-program invocation). Your program builds the "create asset" instruction and hands it to Metaplex Core:

client
 └─ your program      mint_soulbound_nft(name, uri)
     └─ Metaplex Core  CreateV2, with the freeze plugin

What you need installed

ToolVersionHow to get it
Anchor CLI1.2.0avm install 1.2.0 && avm use 1.2.0
Solana CLI4.1.2sh -c "$(curl -sSfL https://release.anza.xyz/v4.1.2/install)"
Surfpool1.6.0+Anchor 1.x runs tests on it by default
Node, Yarn22.12+, 1.22nvm install --lts

This track has its own packages. Install them inside the folder:

cd 02-anchor-track
yarn install
anchor keys sync

anchor keys sync updates the program ID to match your own keypair. Anchor 1.x refuses to build until they match.

The TODOs

Open programs/soulbound-nft/src/instructions/mint_soulbound_nft.rs. Everything is already wired up except the empty .plugins(vec![]). It is the Rust version of what you wrote in TypeScript:

TypeScriptRust
{ type: "PermanentFreezeDelegate", ... }Plugin::PermanentFreezeDelegate(...)
frozen: truePermanentFreezeDelegate { frozen: ? }
authority: { type: "None" }PluginAuthority::?

Each entry in the vec pairs a plugin with its authority:

.plugins(vec![PluginAuthorityPair {
    plugin: /* the frozen plugin */,
    authority: Some(/* "nobody" */),
}])

The types you need are already imported at the top of the file.

Build and test

anchor build
anchor test
You are done when Both tests pass: mints a soul-bound Core NFT and cannot be transferred by its owner. Before your change, both fail.
Hint Why the tests can use Metaplex Core locally

Metaplex Core is a program that already lives on mainnet. anchor test runs on Surfpool, a local copy of mainnet, so the program is already there.

With anchor test --validator legacy, the [[test.validator.clone]] lines in Anchor.toml copy it in instead.

05
Track 2 · devnet

Deploy and mint

Goal: your program live on devnet, plus a script that calls it to mint a real asset.

1 · Wallet and SOL for deploying

Anchor uses the Solana CLI wallet, not wallet.json. Create it if you don't have one, then fund it:

solana-keygen new          # skip if ~/.config/solana/id.json exists
solana config set --url devnet
solana address             # paste this into faucet.solana.com
solana balance
Get 5 SOL, not 2 Storing a program on-chain costs a few SOL, more than solana airdrop 2 gives you. Sign in to the faucet with GitHub and request 5.

2 · Deploy

anchor build
anchor deploy --provider.cluster devnet

It prints your program ID. Save it for your submission.

If the deploy fails halfway, run solana program close --buffers before retrying. A failed deploy leaves SOL stuck in temporary accounts.

3 · Write the client script

This part has no starter file. Create submissions/YOUR-HANDLE/mint.ts. It does what the first test does, but on devnet:

import * as anchor from "@anchor-lang/core";
import { Keypair, PublicKey, SystemProgram } from "@solana/web3.js";
import { SoulboundNft } from "../../target/types/soulbound_nft";

const MPL_CORE = new PublicKey("CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d");

async function main() {
  // Reads ANCHOR_PROVIDER_URL and ANCHOR_WALLET (see the run command below)
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);
  const program = anchor.workspace.SoulboundNft as anchor.Program<SoulboundNft>;

  const asset = Keypair.generate();   // the new NFT's address

  const sig = await program.methods
    .mintSoulboundNft("YOUR NAME's Diploma", "YOUR METADATA URI")
    .accountsPartial({
      // payer, asset, owner, mplCoreProgram, systemProgram
    })
    .signers([asset])
    .rpc();

  // print the asset address and the transaction signature
}

main();

Three things to get right in accountsPartial:

  1. asset is the new keypair's public key, and the keypair goes in .signers([...]). Metaplex Core requires the new asset to sign.
  2. owner is who the NFT is bound to forever. Use your own wallet: provider.wallet.publicKey.
  3. payer is also provider.wallet.publicKey. The wallet signs automatically, so don't add it to signers.

Run it from inside 02-anchor-track, so Anchor can find your program:

ANCHOR_PROVIDER_URL=https://api.devnet.solana.com \
ANCHOR_WALLET=$HOME/.config/solana/id.json \
npx ts-node submissions/YOUR-HANDLE/mint.ts
Hint How the script knows your program ID

anchor.workspace.SoulboundNft reads target/idl/soulbound_nft.json, which anchor build made. The IDL holds your program ID, so the script calls the program you just deployed.

If you run anchor keys sync later, rebuild and redeploy, or the script will call an old address.

4 · Check it and open a pull request

  1. Open the asset's explorer link with ?cluster=devnet at the end.
  2. Copy submissions/SUBMISSION-template.md into your folder as SUBMISSION.md and fill in the program ID, asset and transaction links.
  3. Commit, push to your fork, and open a pull request against the main branch.
02-anchor-track/submissions/YOUR-HANDLE/
├── mint.ts
└── SUBMISSION.md
06
Bonus · TypeScript

Print editions

Goal: one "original" collection and three numbered prints, each earning a different royalty when resold.

Think of a painting and its numbered prints. The collection is the painting. Each asset inside it is print #1, #2 or #3.

The plugins you will use

PluginGoes onWhat it does
MasterEditionCollectionMarks it as the original. maxSupply: 3 says there are 3 prints.
EditionEach assetGives the print its number: 1, 2 or 3.
RoyaltiesBothThe cut the creator gets on each resale.
Royalties are in basis points

100 basis points = 1%. So 250 = 2.5%, 500 = 5%, 1000 = 10%.

If an asset has its own Royalties plugin, it overrides the collection's. That is how each print gets a different royalty.

Do not make these soulbound Royalties are paid when an NFT is sold. A frozen NFT can never be sold, so leave the freeze plugin out here.

Work in 03-bonus-editions/editions.ts. It uses the same wallet.json as track 1.

TODO 1 · The collection

const collectionSigner = generateSigner(umi);

await createCollection(umi, {
  collection: collectionSigner,
  name: "...",
  uri: URI,
  plugins: [
    { type: "MasterEdition", maxSupply: ? },
    {
      type: "Royalties",
      basisPoints: 500,
      creators: [{ address: umi.identity.publicKey, percentage: 100 }],
      ruleSet: ruleSet("None"),
    },
  ],
}).sendAndConfirm(umi);

creators lists who gets paid. The percentages must add up to 100. ruleSet("None") means no marketplace is blocked.

TODO 2 · Three prints in a loop

Load the collection first, because create needs the full collection, not just its address:

const collection = await fetchCollection(umi, collectionSigner.publicKey);
const ROYALTIES = [250, 500, 1000];

for (let i = 1; i <= 3; i++) {
  const asset = generateSigner(umi);
  await create(umi, {
    asset,
    collection,
    name: `... #${i}`,
    uri: URI,
    plugins: [
      // 1. the Edition plugin with this print's number
      // 2. a Royalties plugin using ROYALTIES[i - 1]
    ],
  }).sendAndConfirm(umi);
  // TODO 3: print the explorer link for this asset
}

Remove the throw new Error(...) line, then run:

cd 03-bonus-editions
npm run editions

You should get 4 explorer links: the collection and 3 prints.

Hint Check the override yourself

After creating each print, read it back and log its royalty. You should see 250, 500 and 1000, not the collection's 500 three times.

const onChain = await fetchAsset(umi, asset.publicKey);
console.log("royalty:", onChain.royalties?.basisPoints);

Import fetchAsset from @metaplex-foundation/mpl-core.

Submit

Open a pull request with this folder:

03-bonus-editions/submissions/YOUR-HANDLE/
├── editions.ts
└── SUBMISSION.md     # from SUBMISSION-template.md: 4 links

The template asks: which royalty applies to Edition #2, and why? The note on basis points above has the answer.

?
When it breaks

Troubleshooting

Error Not implemented yet: replace this with your code!

You left the throw new Error(...) line in. Delete it.

Error Airdrop failed, or 429 Too Many Requests

The devnet faucet is rate-limited. Use faucet.solana.com signed in with GitHub, or ask an instructor for devnet SOL.

Error fetch failed, or a timeout

Devnet hiccup. Retry, or use another RPC for that one command:

RPC_URL=https://your-rpc npm run mint
Error Verify says the transfer SUCCEEDED

The asset is not soulbound. Check both settings: frozen must be true and the authority type must be "None". You cannot fix an existing asset; mint a new one.

Error Transfer failed "NOT because of the freeze"

The test could not run properly, usually no SOL or a slow RPC. Fund the wallet and run verify again.

Error anchor build says the program ID does not match
anchor keys sync
anchor build
Error Deploy fails with insufficient funds

Close leftover buffers, check your balance, then top up from the faucet before retrying:

solana program close --buffers
solana balance
Error Cannot find module '../../target/types/soulbound_nft'

Run anchor build first; it generates that file. Also run the script from inside 02-anchor-track.

Error Account not found on the explorer

The explorer is looking at mainnet. Add ?cluster=devnet to the end of the link.

Metaplex Core assignment · never commit a keypair · based on ASCorreia/fall-school-metaplex-core (main), September 2026