Dynamic metadata NFTs using Token Extensions
With the Token Extension program, you can create NFTs and digital assets using the metadata extensions. Together, these extensions (metadata pointer and token metadata) allow you to put any desired metadata natively on-chain. All within a customizable key-value data store directly on the token's mint account, reducing costs and complexity.
These can be especially great for web3 games since we can now have these "additional metadata fields" within an on-chain key-value store, allowing games to save/access unique state within the NFT itself (like for a game character's stats or inventory).
Building the on-chain program
In this developer guide, we will demonstrate how to build these Token Extension based NFTs and custom metadata using an Anchor program. This program will save the level and the collected resources of a game player within an NFT.
This NFT will be created by the Anchor program so it is very easy to mint from the JavaScript client. Each NFT will have some basic structure provided via the Token Metadata interface:
- default on-chain fields -
name
,symbol
anduri
- the
uri
is a link to an off-chain json file which contains the off chain metadata of the NFT
- the
- we will also have custom "additional fields" that we define
All of these fields are saved using the metadata extension which is pointed to the NFT's mint account, making them accessible to anyone or any program.
You can find a video walkthrough of this example on the Solana Foundation Youtube channel:
Other use case within games
These types of NFTs with customizable on-chain metadata open up many interesting possibilities for game developers. Especially since this metadata can be directly interacted with or managed by an on-chain program.
Some of these gaming related use cases include:
- save the level and XP of the player
- the current weapon and armor
- the current quest
- the list goes on!
Minting the NFT
In order to create the NFT we need to perform a following steps:
- Create a mint account
- Initialize the mint account
- Create a metadata pointer account
- Initialize the metadata pointer account
- Create the metadata account
- Initialize the metadata account
- Create the associated token account
- Mint the token to the associated token account
- Freeze the mint authority
Rust program code
Here is the rust code used to mint the NFT using the Token extension program:
// calculate the space need for the mint account with the desired extensions
let space = ExtensionType::try_calculate_account_len::<Mint>(
&[ExtensionType::MetadataPointer])
.unwrap();
// This is the space required for the metadata account.
// We put the metadata into the mint account at the end so we
// don't need to create and additional account.
// Then the metadata pointer points back to the mint account.
// Using this technique, only one account is needed for both the mint
// information and the metadata.
let meta_data_space = 250;
let lamports_required = (Rent::get()?).minimum_balance(space + meta_data_space);
msg!(
"Create Mint and metadata account size and cost: {} lamports: {}",
space as u64,
lamports_required
);
system_program::create_account(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
system_program::CreateAccount {
from: ctx.accounts.signer.to_account_info(),
to: ctx.accounts.mint.to_account_info(),
},
),
lamports_required,
space as u64,
&ctx.accounts.token_program.key(),
)?;
// Assign the mint to the token program
system_program::assign(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
system_program::Assign {
account_to_assign: ctx.accounts.mint.to_account_info(),
},
),
&token_2022::ID,
)?;
// Initialize the metadata pointer (Need to do this before initializing the mint)
let init_meta_data_pointer_ix =
spl_token_2022::extension::metadata_pointer::instruction::initialize(
&Token2022::id(),
&ctx.accounts.mint.key(),
Some(ctx.accounts.nft_authority.key()),
Some(ctx.accounts.mint.key()),
)
.unwrap();
invoke(
&init_meta_data_pointer_ix,
&[
ctx.accounts.mint.to_account_info(),
ctx.accounts.nft_authority.to_account_info()
],
)?;
// Initialize the mint cpi
let mint_cpi_ix = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
token_2022::InitializeMint2 {
mint: ctx.accounts.mint.to_account_info(),
},
);
token_2022::initialize_mint2(
mint_cpi_ix,
0,
&ctx.accounts.nft_authority.key(),
None).unwrap();
// We use a PDA as a mint authority for the metadata account because
// we want to be able to update the NFT from the program.
let seeds = b"nft_authority";
let bump = ctx.bumps.nft_authority;
let signer: &[&[&[u8]]] = &[&[seeds, &[bump]]];
msg!("Init metadata {0}", ctx.accounts.nft_authority.to_account_info().key);
// Init the metadata account
let init_token_meta_data_ix =
&spl_token_metadata_interface::instruction::initialize(
&spl_token_2022::id(),
ctx.accounts.mint.key,
ctx.accounts.nft_authority.to_account_info().key,
ctx.accounts.mint.key,
ctx.accounts.nft_authority.to_account_info().key,
"Beaver".to_string(),
"BVA".to_string(),
"https://arweave.net/MHK3Iopy0GgvDoM7LkkiAdg7pQqExuuWvedApCnzfj0".to_string(),
);
invoke_signed(
init_token_meta_data_ix,
&[ctx.accounts.mint.to_account_info().clone(), ctx.accounts.nft_authority.to_account_info().clone()],
signer,
)?;
// Update the metadata account with an additional metadata field in this case the player level
invoke_signed(
&spl_token_metadata_interface::instruction::update_field(
&spl_token_2022::id(),
ctx.accounts.mint.key,
ctx.accounts.nft_authority.to_account_info().key,
spl_token_metadata_interface::state::Field::Key("level".to_string()),
"1".to_string(),
),
&[
ctx.accounts.mint.to_account_info().clone(),
ctx.accounts.nft_authority.to_account_info().clone(),
],
signer
)?;
// Create the associated token account
associated_token::create(
CpiContext::new(
ctx.accounts.associated_token_program.to_account_info(),
associated_token::Create {
payer: ctx.accounts.signer.to_account_info(),
associated_token: ctx.accounts.token_account.to_account_info(),
authority: ctx.accounts.signer.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
token_program: ctx.accounts.token_program.to_account_info(),
},
))?;
// Mint one token to the associated token account of the player
token_2022::mint_to(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
token_2022::MintTo {
mint: ctx.accounts.mint.to_account_info(),
to: ctx.accounts.token_account.to_account_info(),
authority: ctx.accounts.nft_authority.to_account_info(),
},
signer
),
1,
)?;
// Freeze the mint authority so no more tokens can be minted to make it an NFT
token_2022::set_authority(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
token_2022::SetAuthority {
current_authority: ctx.accounts.nft_authority.to_account_info(),
account_or_mint: ctx.accounts.mint.to_account_info(),
},
signer
),
AuthorityType::MintTokens,
None,
)?;
JavaScript client code
Calling mint NFT from the client is very easy:
const nftAuthority = PublicKey.findProgramAddressSync(
[Buffer.from("nft_authority")],
program.programId,
);
const mint = new Keypair();
const destinationTokenAccount = getAssociatedTokenAddressSync(
mint.publicKey,
publicKey,
false,
TOKEN_2022_PROGRAM_ID,
);
const transaction = await program.methods
.mintNft()
.accounts({
signer: publicKey,
systemProgram: SystemProgram.programId,
tokenProgram: TOKEN_2022_PROGRAM_ID,
tokenAccount: destinationTokenAccount,
mint: mint.publicKey,
rent: web3.SYSVAR_RENT_PUBKEY,
associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
nftAuthority: nftAuthority[0],
})
.signers([mint])
.transaction();
console.log("transaction", transaction);
const txSig = await sendTransaction(transaction, connection, {
signers: [mint],
skipPreflight: true,
});
console.log(`https://explorer.solana.com/tx/${txSig}?cluster=devnet`);
Quickstart example
The example above is based on the Solana Games Preset, which generates you a scaffold that includes a JavaScript and Unity client for this game, including the configuration for interacting with the Solana Anchor program.
You can run it yourself with the following command:
npx create-solana-game gameName
Setup your local environment
In order to run this example locally, you will need to make sure you have setup your local environment for Solana development, including installing and configuring the Anchor CLI. If you do not already, you can follow the previously linked setup guide to do so.
Project structure
The Anchor project is structured like this:
The entry point is in the lib.rs file. Here we define the program id and the instructions. The instructions are defined in the instructions folder. The state is defined in the state folder.
So the calls arrive in the lib.rs file and are then forwarded to the instructions. The instructions then call the state to get the data and update it.
You can find the mint NFT instruction in the instructions folder.
├── src
│ ├── instructions
│ │ ├── chop_tree.rs
│ │ ├── init_player.rs
│ │ ├── mint_nft.rs
│ │ └── update_energy.rs
│ ├── state
│ │ ├── game_data.rs
│ │ ├── mod.rs
│ │ └── player_data.rs
│ ├── lib.rs
│ └── constants.rs
│ └── errors.rs
Anchor program
To finish setting up the Anchor program generated from the create-solana-game
tool:
cd program
to end the program directory- Run
anchor build
to build the program - Run
anchor deploy
to deploy the program - Copy the program id from the terminal into the
lib.rs
,anchor.toml
and within the Unity project in theAnchorService
and if you use JavaScript in theanchor.ts
file - Build and deploy again
NextJS client
To finish setting up the NextJS client generated from the create-solana-game
tool:
- Copy the
programId
intoapp/utils/anchor.ts
cd app
to end the app directory- Run
yarn install
to install the Node dependencies - Run
yarn dev
to start the client - After doing changes to the Anchor program make sure to copy over the types
from the program into the client so you can use them. You can find the
TypeScript types in the
target/idl
folder.
Run this example locally
Using Anchor's test
command with the --detach
flag will start and configure
your Solana local test validator to have the program deployed (and keep the
validator running after the tests complete):
cd program
anchor test --detach
Then you can set the Solana Explorer to use your
local test validator (which starts when running the anchor test
command) so
you can look at the transactions:
https://explorer.solana.com/?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899
The program is also already deployed to net so you can try it out on devnet
.
The JavaScript client also has a button to mint the NFT. Starting the JavaScript
client:
cd app
yarn install
yarn dev
Open the Unity project
First open the Unity project with Unity Version 2021.3.32.f1 (or similar), then
open the GameScene
or LoginScene
and hit play. Use the editor login button
in the bottom left.
If you can't get devnet SOL you can copy your address from the console and follow the instructions on this guide on how to get devnet SOL
Connect to the Solana test validator in Unity
If you want to avoid having to worry about maintaining devnet SOL, you can connect to your running local test validator from within Unity. Simply add these links on the wallet holder game object:
http://localhost:8899
ws://localhost:8900
Run the JavaScript client
To start the JavaScript client and be able to interact with the game and program using your web browser:
- open the
app
directory within the repo - install the Node dependencies
- run the
dev
command to start the development server
cd app
yarn install
yarn dev
To start changing the program and connecting to your own program follow the steps below.