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:
| Setting | What it does |
|---|---|
| frozen: true | The asset is frozen from the moment it is created. Metaplex Core rejects every transfer and burn. |
| authority: None | Nobody is allowed to change the plugin. So nobody can ever unfreeze it. |
None authority is what makes it permanent.
The three tracks
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.
Fork and install
When you finish this: your own copy of the repo, with packages installed.
- Open the repo on GitHub and click Fork.
- The assignment lives on the
mainbranch, so you can keep "Copy the main branch only" ticked. - 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 --version. On an older Node the scripts
fail with confusing import errors. Fix it with nvm install --lts.
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.
.gitignore already skips it; do not
force-add it, and do not put it in a pull request.
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.
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
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.
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
| Tool | Version | How to get it |
|---|---|---|
| Anchor CLI | 1.2.0 | avm install 1.2.0 && avm use 1.2.0 |
| Solana CLI | 4.1.2 | sh -c "$(curl -sSfL https://release.anza.xyz/v4.1.2/install)" |
| Surfpool | 1.6.0+ | Anchor 1.x runs tests on it by default |
| Node, Yarn | 22.12+, 1.22 | nvm 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:
| TypeScript | Rust |
|---|---|
| { type: "PermanentFreezeDelegate", ... } | Plugin::PermanentFreezeDelegate(...) |
| frozen: true | PermanentFreezeDelegate { 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
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.
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
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:
assetis the new keypair's public key, and the keypair goes in.signers([...]). Metaplex Core requires the new asset to sign.owneris who the NFT is bound to forever. Use your own wallet:provider.wallet.publicKey.payeris alsoprovider.wallet.publicKey. The wallet signs automatically, so don't add it tosigners.
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
- Open the asset's explorer link with
?cluster=devnetat the end. - Copy
submissions/SUBMISSION-template.mdinto your folder asSUBMISSION.mdand fill in the program ID, asset and transaction links. - Commit, push to your fork, and open a pull request against the
mainbranch.
02-anchor-track/submissions/YOUR-HANDLE/
├── mint.ts
└── SUBMISSION.md
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
| Plugin | Goes on | What it does |
|---|---|---|
| MasterEdition | Collection | Marks it as the original. maxSupply: 3 says there are 3 prints. |
| Edition | Each asset | Gives the print its number: 1, 2 or 3. |
| Royalties | Both | The cut the creator gets on each resale. |
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.
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.
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