Mint Solana NFT Using Rust & Metaplex SDK

25 minute read Published: 2023-07-25

Solana is a public, permissionless blockchain launched in 2020. It leverages computer hardware for its high throughput and performance. Solana has been the pioneer in cutting-edge innovation leading in the adoption of tech like concurrent Merkle trees, interfaces, token-2022...etc

I briefly talked about some differences between Solana and EVM chains previously and in this article I will take you through the process of minting an NFT using the Rust Solana SDK and the MetapleX SDK.

Table of contents

Environmental Set-up

Consider: Solana Playground - A dedicated online playground where you can write and deploy your Solana programs using anchor, native Rust or Seahorse-lang online.

You can also skip this section if you already your Solana environment setup.

Convenient (must have) tool that will make development easier

To install the Solana CLI run the command below,

sh -c "$(curl -sSfL https://release.solana.com/stable/install)"

Depending on your system, you might get an installer messaging

Please update your PATH environment variable to include the solana programs:

Follow the installer instructions and Confirm the solana suite is installed properly by running,

solana --version

Next, we need to set up a paper wallet, that we will use to test our programs locally and deploy them.

solana-keygen new

This command creates a new paper wallet in the ~/.config/solana/id.json directory, though you have the option to tell it where to save the file using the -o, --outfile <FILEPATH> flag.

In this tutorial we will be writing a native Rust Solana Program though it's advisable to use the anchor framework.

Why Native? Because at the moment the solana cli and anchor are incompatible.

Introduction to Tokens in Solana

To start off, in Solana, everything is an account. You have data accounts that, you guessed it, store data and executable account(smart contracts) that run code when certain conditions are met.

In the context of tokens/NFTs, Unlike in Ethereum, where you have predefined standards/interfaces for creating a contract that mint's tokens(nfts included), solana has the Token program. That means you do not have to deploy a contract every time you want to mint a new token. This also makes it really easy to mint token using TypeScript or Python, if you have the web3.js Solana SDK.

Lets talk about some terminologies that usually trip up new developers,

It can be hard learning new things, so take a break and revisit these concepts after a few days.

Intro to Native Rust Solana Development.

To get started we will initialize a new empty rust lib project with cargo.

cargo new --lib solana-nft-native

These are the crates that we will need to create our program.

Note: We are using versioned crates because there is a borsh version mismatch between what metaplex and solana-program-library uses.

cargo add solana-program@=1.14.20
cargo add borsh@0.9.3
cargo add borsh-derive@0.9.3
cargo add spl-token@3.3.0 --features=no-entrypoint
cargo add spl-associated-token-account@1.0.3 --features=no-entrypoint
cargo add mpl-token-metadata@1.2.5 --features=no-entrypoint

We will also make to our Anchor.toml file.

[lib]
crate-type = ["cdylib", "lib"]

[lib] means that our project, compiles to a library crate, which can be used and linked by other libraries and executable. In the context of our program, we have defined the crate-type options as lib and cdylib, read more

Note: Solana programs must specify the crate-type as cdylib. This enables them to be automatically be discovered and built by the cargo build-spf.

Test everything works properly by building the program.

 cargo build-sbf

If your program builds successfully, let's head into our src/lib.rs file, clear the contents of this file. populate it with the contents below.

This is a simple hello world program program that logs hello world! when called on a solana cluster like devnet.

use solana_program::{
    account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, msg, pubkey::Pubkey,
};

entrypoint!(process_instruction);

pub fn process_instruction(
    _program_id: &Pubkey,
    _accounts: &[AccountInfo],
    _instruction_data: &[u8],
) -> ProgramResult {
    msg!("Hello, World!");

    Ok(())
}

Let's break down the line:

We get started by bringing into scope the packages that we added using cargo.

use solana_program::{
    account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, msg, pubkey::Pubkey,
};

Next we use the entrypoint! macro to define the function that will be called when a tx is made to our program. In our case it is the process_instruction function.

entrypoint!(process_instruction);

Finally we have our process_instruction function.

pub fn process_instruction(
    _program_id: &Pubkey,
    _accounts: &[AccountInfo],
    _instruction_data: &[u8],
) -> ProgramResult {
    msg!("Hello, World!");

    Ok(())
}

Our process_instruction function has three parameters,

  1. _program_id - this the the address of your program. you receive it once you've deployed your program.
  2. _accounts - NOTE: solana programs are stateless. What that means is that there is not data stored on a solana program. Instead you have to create other accounts should you want to write data on-chain and pass them as _accounts arguments. You pass all the account to which you will read/mutate as a slice of accounts.
  3. _instruction_data - This is where you pass in any serialized data that, mutates/writes to accounts that the program interacts with.

Client Side Set-up 1

Let's initialize a new ts project which will act as our client to interact with our program. I am not a big fan of monorepos so this will be in another directory

mkdir solana-nft-native-client
cd solana-nft-native-client
npm init -y
git init
tsc --init
mkdir src
cd src
touch main.ts utils.ts

Install our versioned ts packages

npm i @metaplex-foundation/mpl-token-metadata@2.13.0 @solana/spl-token@0.3.8 @solana/web3.js@1.78.0  borsh@0.7.0 buffer@6.0.3 fs@0.0.1-security
npm i --save-dev typescript@4.9.5 @types/bn.js@5.1.1

After installing our packages, we will also add an npm command dev, to call ts-node on our client side code. Make sure you have ts-node installed.

. Your finished package.json should resemble,

{
	"scripts": {
		"dev": "ts-node src/main.ts"
	},
	"dependencies": {
		"@metaplex-foundation/mpl-token-metadata": "^2.13.0",
		"@solana/spl-token": "^0.3.8",
		"@solana/web3.js": "^1.78.0",
		"borsh": "^0.7.0",
		"buffer": "^6.0.3",
		"fs": "^0.0.1-security"
	},
	"devDependencies": {
		"@types/bn.js": "^5.1.1",
		"typescript": "^4.9.5"
	}
}

We will also switch our node version to v16.17.1 using nvm. This is because solana-test-validator does not work with anything above v17.

nvm use v16.17.1

Let's write a script to test our hello world program.

First we will create our utility functions to derive our command line wallet Keypair and program Keypair.

NOTE: make sure your PROGRAM_KEYPAIR_PATH and USER_KEYPAIR_PATH paths are set correctly.

Populate your src/utils.ts with,

import { readFileSync } from "fs";
import { Keypair } from "@solana/web3.js";
import { homedir } from "os";

const PROGRAM_KEYPAIR_PATH =
	homedir() +
	"/Documents/web3/solana/solana-nft-native/target/deploy/solana_nft_native-keypair.json";
const USER_KEYPAIR_PATH = homedir() + "/.config/solana/id.json";

export function userKeyPair(): Keypair {
	return Keypair.fromSecretKey(
		Buffer.from(JSON.parse(readFileSync(USER_KEYPAIR_PATH, "utf-8")))
	);
}

export function programKeyPair() {
	return Keypair.fromSecretKey(
		Buffer.from(JSON.parse(readFileSync(PROGRAM_KEYPAIR_PATH, "utf-8")))
	);
}

console.log("user keypair", userKeyPair().publicKey.toString());
console.log("program keypair", programKeyPair().publicKey.toString());

Running the script above with npm run dev should print the two public keys.

Next we will write client code to call our program. If everything runs successfully, then our program should log hello world.

Populate main.ts with

import {
	Connection,
	Keypair,
	PublicKey,
	sendAndConfirmTransaction,
	Transaction,
	TransactionInstruction,
} from "@solana/web3.js";
import { userKeyPair, programKeyPair } from "./utils";

let connection = new Connection("http://localhost:8899");
let programId = programKeyPair().publicKey;
const payer = userKeyPair();

async function helloWorld(): Promise<String> {
	const ix = new TransactionInstruction({
		keys: [],
		programId,
	});

	const tx = new Transaction().add(ix);

	return await sendAndConfirmTransaction(connection, tx, [payer]);
}

helloWorld()
	.then((txSig) => console.log("tx sig: ", txSig))
	.catch((err) => console.error("program execution unsuccessful", err));

In the code snippet above, we define three global variables:

To understand the function, you must understand how transactions work in solana. In Solana a transaction can contain one of multiple instructions. And what are instruction? Simply, it is a method or functions of a Solana program.

In solana an instruction takes in three arguments, the first being the addresses of the programs the instruction will interact with the programId and finally data which you want to write to an account. In our ix, the data is omitted because we are not writing to any account.

The sendAndConfirmTransaction method allows us to send and sign transactions.

To ran our client, we will need to set up a local validator. In a new terminal window run

solana-test-validator

Next we will need the solana cli to send our transaction to this test-validator we are running with

solana config set --url localhost

We will now compile our program and deploy it. Run the build-sbf command in your program directory.

cargo build-sbf

We will now deploy our compiled program. This will be the .so (shared object) file in the target/deploy directory.

solana program deploy solana_nft_native.so

Head back to the directory where you wrote your client code.

Open a new terminal window or tab. We will use this to view our program logs

solana logs --url localhost

We will now run our client script and if successful, we should see the logs with the string Hello, World! logged out. Screenshot of our program logging our Hello World!

With the basics in place, let's head back to where our program code lives and write the code to mint our nft.

More on Native Rust Solana Dev

Clear everything except our package imports. We will start by importing borsh for serialization.

use borsh::{BorshDeserialize, BorshSerialize}; //new
use mpl_token_metadata::instruction as mpl_instruction; //new
use solana_program::{
    account_info::{next_account_info, AccountInfo}, //new
    entrypoint,
    entrypoint::ProgramResult,
    msg,
    program::invoke,    //new
    pubkey::Pubkey,
    rent::Rent,         //new
    system_instruction, //new
    sysvar::Sysvar,     //new
};
use spl_token::{instruction as token_instruction, state::Mint}; //new

entrypoint!(process_instruction);

pub fn process_instruction(
    _program_id: &Pubkey,
    _accounts: &[AccountInfo],
    _instruction_data: &[u8],
) -> ProgramResult {
    msg!("Hello, World!");

    Ok(())
}

The new imports:

When creating our token, we will need to pass in a title, symbol and our metadata uri. This data will need to be serialized and deserialized so that the client and the program can make sense of it. To do this we will use the Serialize and Deserialize borsh traits in a derive attribute.

We will pass on these arguments to the Metaplex Metadata Program

#[derive(BorshSerialize, BorshDeserialize)]
pub struct CreateTokenArgs {
    pub nft_title: String,
    pub nft_symbol: String,
    pub nft_uri: String,
}

Next, let's invoke the function that will call our token and metaplex program. We will call it create_nft.


fn create_nft(accounts: &[AccountInfo], create_token_data: CreateTokenArgs) -> ProgramResult {
    let accounts_iter = &mut accounts.iter();

    let mint_account = next_account_info(accounts_iter)?;
    let mint_authority = next_account_info(accounts_iter)?;
    let metadata_account = next_account_info(accounts_iter)?;
    let payer = next_account_info(accounts_iter)?;
    let rent = next_account_info(accounts_iter)?;
    let system_program = next_account_info(accounts_iter)?;
    let token_program = next_account_info(accounts_iter)?;
    let token_metadata_program = next_account_info(accounts_iter)?;

    Ok(())
}

The functions takes a slice of type AccountInfo and create a mutable iterator for the accounts input. it is from this accounts that we pull out the accounts that are required to create an nft while interacting with the metadata and token programs.

We are now ready to start calling instructions that will create our nft.

// invoke the system program to create our account
    msg!("Creating mint account...");
    msg!("Mint: {}", mint_account.key);
    invoke(
        &system_instruction::create_account(
            &payer.key,
            &mint_account.key,
            (Rent::get()?).minimum_balance(Mint::LEN),
            Mint::LEN as u64,
            &token_program.key,
        ),
        &[
            mint_account.clone(),
            payer.clone(),
            system_program.clone(),
            token_program.clone(),
        ],
    )?;

What is this ↑ piece of code doing? Well, we are using invoke to make a cross program call between our system_program and our token_program. To make a CPI call, you need to pass the instruction you want to call and all the accounts that the call interacts with as arguments.

In the first parameter for our instruction we are calling the create_account instruction from system_program to initialize a new account. These are the parameters we are required to pass to our instruction -

  1. &payer.key Is the public key that will pay for the accounts creation,

  2. &mint_account.key Is the public key for the account being created,

  3. (Rent::get()?).minimum_balance(Mint::LEN), when creating an account in solana, you are required to pay for blockspace. There is not such thing as a free lunch. To store data on Solana in an account, you need to pay for it. To note, is that there code above is checking whether our account is rent exempt. We check the minimum_balance required to keep the account rent exempt rent exempt is a concept in Solana that allows an account to not incur any rent rent collection for a duration of two years. Rent is the program that manages this.

  4. Mint::LEN as u64 is passed in to determine the length/space our account will take. The default size is 82 bytes

  5. &token_program.key is the address of the account that owns or account. In this case it's the token_program.

The next parameter is a slice of the account that invoke will touch.

Next we initialize the created account as the mint account

    //initialize the created account as the mint
    msg!("Initializing mint account...");
    msg!("Mint: {}", mint_account.key);
    invoke(
        &token_instruction::initialize_mint(
            &token_program.key, //token program public key
            &mint_account.key, //mint public key
            &mint_authority.key, //mint authority
            Some(&mint_authority.key), //freeze authority
            0, //decimals
        )?,
        &[
            mint_account.clone(),
            mint_authority.clone(),
            token_program.clone(),
            rent.clone(),
        ],
    )?;

As with the previous invoke call, we pass two arguments to this method. One is the instructions that we will invoke and the second is the accounts that the cross program call will touch.

our token_instruction::initialize_mint takes in 5 parameters, as indicated by the comments. To note, is the no of decimals. For a token in Solana to be non-fungible, it should not be divisible, hence zero.

To finish off our program, we call the token metadata program from Metaplex. We will use the create_metadata_accounts_v3 instruction to create our Metadata.

 //call Metaplex token metadata program
    msg!("Creating metadata account...");
    msg!("Metadata account address: {}", metadata_account.key);
    invoke(
        &mpl_instruction::create_metadata_accounts_v3(
            *token_metadata_program.key, //program id
            *metadata_account.key,       // metadata address
            *mint_account.key,           //mint address
            *mint_authority.key,         //mint authority address
            *payer.key, //payer public key address
            *mint_authority.key, //update authority address
            create_token_metadata.nft_title, //nft title
            create_token_metadata.nft_symbol, //nft symbol
            create_token_metadata.nft_uri, //nft uri
            None, //creators. for something you pass in a VEC of their addresses
            0, //royalties
            true, //is the payer the update authority
            false, //can we update the token metadata
            None, //collection the nft belongs to. struct of pubkey and
            None, //uses
            None, //collection details
        ),
        &[
            metadata_account.clone(),
            mint_account.clone(),
            mint_authority.clone(),
            payer.clone(),
            token_metadata_program.clone(),
            rent.clone(),
        ],
    )?;

    msg!("Token mint created successfully.");

The code pattern is the same as the previous one. We first call invoke with two arguments. The instruction and the accounts we touch.

The create_metadata_accounts_v3 will create our metadata account and write to it our metadata information. Each argument is commented to show what it represents.

To finally, finally finish off our program, we being our create_token into our process_instruction function which will now look like this

pub fn process_instruction(
    _program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    let token_metadata = CreateTokenArgs::try_from_slice(instruction_data)?; //new

    create_nft(accounts, token_metadata)?;

    Ok(())
}

The method is not something that is new. What is new, is try_from_slice. Remember that to efficiently sent messages to and from Solana and reduce computational overhead, your data needs to be serialized to a format(binary format) that can efficiently do this.

The try_from_slice method is part of the BorshDeserialize trait, and it's used to convert a byte slice (&[u8]) into an instance of our CreateTokenArgs.

And yes, You will need to serialize data from the client side too. This is why a lot of dev prefer using anchor as it automatically handles this boilerplate of serializing and deserializing data.

Altogether your src/lib.rs should resemble.

use borsh::{BorshDeserialize, BorshSerialize};
use mpl_token_metadata::instruction as mpl_instruction;
use solana_program::{
    account_info::{next_account_info, AccountInfo},
    entrypoint,
    entrypoint::ProgramResult,
    msg,
    program::invoke,
    program_pack::Pack,
    pubkey::Pubkey,
    rent::Rent,
    system_instruction,
    sysvar::Sysvar,
};
use spl_token::{instruction as token_instruction, state::Mint};

entrypoint!(process_instruction);

pub fn process_instruction(
    _program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    let token_metadata = CreateTokenArgs::try_from_slice(instruction_data)?;

    create_nft(accounts, token_metadata)?;

    Ok(())
}

#[derive(BorshSerialize, BorshDeserialize)]
pub struct CreateTokenArgs {
    pub nft_title: String,
    pub nft_symbol: String,
    pub nft_uri: String,
}

fn create_nft(accounts: &[AccountInfo], create_token_metadata: CreateTokenArgs) -> ProgramResult {
    let accounts_iter = &mut accounts.iter();

    //pick out our accounts
    let mint_account = next_account_info(accounts_iter)?;
    let mint_authority = next_account_info(accounts_iter)?;
    let metadata_account = next_account_info(accounts_iter)?;
    let payer = next_account_info(accounts_iter)?;
    let rent = next_account_info(accounts_iter)?;
    let system_program = next_account_info(accounts_iter)?;
    let token_program = next_account_info(accounts_iter)?;
    let token_metadata_program = next_account_info(accounts_iter)?;

    // invoke the system program to create our account
    msg!("Creating mint account...");
    msg!("Mint: {}", mint_account.key);
    invoke(
        &system_instruction::create_account(
            &payer.key,
            &mint_account.key,
            (Rent::get()?).minimum_balance(Mint::LEN),
            Mint::LEN as u64,
            &token_program.key,
        ),
        &[
            mint_account.clone(),
            payer.clone(),
            system_program.clone(),
            token_program.clone(),
        ],
    )?;

    //initialize the created account as the mint
    msg!("Initializing mint account...");
    msg!("Mint: {}", mint_account.key);
    invoke(
        &token_instruction::initialize_mint(
            &token_program.key,
            &mint_account.key,
            &mint_authority.key,
            Some(&mint_authority.key),
            0,
        )?,
        &[
            mint_account.clone(),
            mint_authority.clone(),
            token_program.clone(),
            rent.clone(),
        ],
    )?;

    //call Metaplex token metadata program
    msg!("Creating metadata account...");
    msg!("Metadata account address: {}", metadata_account.key);
    invoke(
        &mpl_instruction::create_metadata_accounts_v3(
            *token_metadata_program.key,      //program id
            *metadata_account.key,            // metadata address
            *mint_account.key,                //mint address
            *mint_authority.key,              //mint authority address
            *payer.key,                       //payer public key address
            *mint_authority.key,              //update authority address
            create_token_metadata.nft_title,  //nft title
            create_token_metadata.nft_symbol, //nft symbol
            create_token_metadata.nft_uri,    //nft uri
            None,  //creators. for something you pass in a VEC of their addresses
            0,     //royalties
            true,  //is the payer the update authority
            false, //can we update the token metadata
            None,  //collection the nft belongs to. struct of pubkey and
            None,  //uses
            None,  //collection details
        ),
        &[
            metadata_account.clone(),
            mint_account.clone(),
            mint_authority.clone(),
            payer.clone(),
            token_metadata_program.clone(),
            rent.clone(),
        ],
    )?;

    msg!("Token mint created successfully.");

    Ok(())
}

To finish off our program side development, we are going to build and deploy our program on devnet.

solana config set --url devnet
cargo build-sbf
solana program deploy solana_nft_native.so

Note: Your so file is in the target/deploy directory.

Client Side Set-up 2

We are almost at the end of our journey of creating nfts for Solana.

Going back to where you set up your client code before. You can comment out the helloWorld function we wrote. We will create a class to serialize our data.

class TokenArgs {
	nft_title: string;
	nft_symbol: string;
	nft_uri: string;

	constructor(nft_title: string, nft_symbol: string, nft_uri: string) {
		this.nft_title = nft_title;
		this.nft_symbol = nft_symbol;
		this.nft_uri = nft_uri;
	}

	serializeCreateTokenData(data: TokenArgs): Uint8Array {
		const schema = new Map([
			[
				TokenArgs,
				{
					kind: "struct",
					fields: [
						["nft_title", "string"],
						["nft_symbol", "string"],
						["nft_uri", "string"],
					],
				},
			],
		]);
		return Buffer.from(borsh.serialize(schema, data));
	}
}

We have abstracted the logic to serializing our data to the TokenArgs class.

In it we have our constructor which sets our nft_title, nft_symbol and nft_uri member variables.

Next we have our serializeTokenData method which contains our borsh schema and it's this methods that serializes our data, ready to be send to our program.

We then set up our createToken function. We will create our mint accounts keypair using Keypair.generate(); functions and derive a pda which will store our metadata. The seeds to do so are metadata, TOKEN_METADATA_PROGRAM_ID and mintKeypair.publicKey.

import { PROGRAM_ID as TOKEN_METADATA_PROGRAM_ID } from "@metaplex-foundation/mpl-token-metadata";

const mintKeypair: Keypair = Keypair.generate();

const metadataAddress = PublicKey.findProgramAddressSync(
	[
		Buffer.from("metadata"),
		TOKEN_METADATA_PROGRAM_ID.toBuffer(),
		mintKeypair.publicKey.toBuffer(),
	],
	TOKEN_METADATA_PROGRAM_ID
)[0];

Let finish off by declaring our data variables and assigning them appropriately.

async function createToken(): Promise<String> {
	let connection = new Connection("http://localhost:8899");
	let programId = programKeyPair().publicKey;
	const payer = userKeyPair();

	const mintKeypair: Keypair = Keypair.generate();

	const metadataAddress = PublicKey.findProgramAddressSync(
		[
			Buffer.from("metadata"),
			TOKEN_METADATA_PROGRAM_ID.toBuffer(),
			mintKeypair.publicKey.toBuffer(),
		],
		TOKEN_METADATA_PROGRAM_ID
	)[0];

	const instructionData = new TokenArgs(
		"Kobeni",
		"KBN",
		"https://raw.githubusercontent.com/687c/solana-nft-native-client/main/metadata.json"
	);

	let ix = new TransactionInstruction({
		keys: [
			{ pubkey: mintKeypair.publicKey, isSigner: true, isWritable: true }, // Mint account
			{ pubkey: payer.publicKey, isSigner: false, isWritable: true }, // Mint authority account
			{ pubkey: metadataAddress, isSigner: false, isWritable: true }, // Metadata account
			{ pubkey: payer.publicKey, isSigner: true, isWritable: true }, // Payer
			{ pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, // Rent account
			{
				pubkey: SystemProgram.programId,
				isSigner: false,
				isWritable: false,
			}, // System program
			{ pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, // Token program
			{
				pubkey: TOKEN_METADATA_PROGRAM_ID,
				isSigner: false,
				isWritable: false,
			}, // Token metadata program
		],
		data: instructionData.serialize(),
		programId: programId,
	});

	const modifyComputeUnits = ComputeBudgetProgram.setComputeUnitLimit({
		units: 1000000,
	});

	const tx = new Transaction().add(modifyComputeUnits).add(ix);

	return await sendAndConfirmTransaction(connection, tx, [
		payer,
		mintKeypair,
	]);
}

In our script above, we modify our tx and increase our compute units. This will prevent us from running into unwanted, compute limit exceeded errors.

Note:, you need to pass in all account that sign, in the signers array. In our case, these are, payer and mintKeypair.

createToken()
	.then((txSig) =>
		console.log(`tx hash: https://solscan.io/tx/${txSig}?cluster=devnet`)
	)
	.catch((err) => console.error("program execution unsuccessful", err));

And finally, finally, let's call our client script, npm run dev.

Open the link returned after running your script. It should take you to the solscan explorer.

Screenshot of the result of calling our program!

To look at our token on solscan, we will click on the second signer. The one surrounded by a yellow border. That is our token mint account.

Open the second account and it should show you the details about the nft you created.

Screenshot of our nft page on solscan!

Find the code for the client side here and for the Solana program here.

And finally, huge shout out to the Solana program examples repository from where the code for this article was lifted. 😂

In the next part we modify our code to mint the tokens directly to our wallet or a specified address.

Further Reading