Anchor

How to Create a Core NFT Asset with Anchor

This guide will demonstrate the use of the mpl-core Rust SDK crate to create a Core NFT Asset via CPI using the Anchor framework in a Solana program.

What is Core?

Core uses a single account design, reducing minting costs and improving Solana network load compared to alternatives. It also has a flexible plugin system that allows for developers to modify the behavior and functionality of assets.

But before starting, let's talk about Assets:

What is an Asset?

Setting itself apart from existing Asset programs, like Solana’s Token program, Metaplex Core and Core NFT Assets (sometimes referred to as Core NFT Assets) do not rely on multiple accounts, like Associated Token Accounts. Instead, Core NFT Assets store the relationship between a wallet and the "mint" account within the asset itself.

Prerequisite

  • Code Editor of your choice (recommended Visual Studio Code with the Rust Analyzer Plugin)
  • Anchor 0.30.1 or above.

Initial Setup

In this guide we’re going to use Anchor, leveraging a mono-file approach where all the necessary macros can be found in the lib.rs file:

  • declare_id: Specifies the program's on-chain address.
  • #[program]: Specifies the module containing the program’s instruction logic.
  • #[derive(Accounts)]: Applied to structs to indicate a list of accounts required for an instruction.
  • #[account]: Applied to structs to create custom account types specific to the program.

Note: You may need to modify and move functions around to suit your needs.

Initializing the Program

Start by initializing a new project (optional) using avm (Anchor Version Manager). To initialize it, run the following command in your terminal

anchor init create-core-asset-example

Required Crates

In this guide, we'll use the mpl_core crate with the anchor feature enabled. To install it, first navigate to the create-core-asset-example directory:

cd create-core-asset-example

Then run the following command:

cargo add mpl-core --features anchor

The program

Imports and Templates

Here we're going to define all the imports for this particular guide and create the template for the Account struct and instruction in our lib.rs file.

use anchor_lang::prelude::*;

use mpl_core::{
    ID as MPL_CORE_ID,
    accounts::BaseCollectionV1, 
    instructions::CreateV2CpiBuilder, 
};

declare_id!("C9PLf3qMCVqtUCJtEBy8NCcseNp3KTZwFJxAtDdN1bto");

#[derive(AnchorDeserialize, AnchorSerialize)]
pub struct CreateAssetArgs {

}

#[program]
pub mod create_core_asset_example {
    use super::*;

    pub fn create_core_asset(ctx: Context<CreateAsset>, args: CreateAssetArgs) -> Result<()> {

        Ok(())
    }
}

#[derive(Accounts)]
pub struct CreateAsset<'info> {

}

Creating the Args Struct

To keep our function organized and avoid clutter from too many parameters, it's standard practice to pass all inputs through a structured format. This is achieved by defining an argument struct (CreateAssetArgs) and deriving AnchorDeserialize and AnchorSerialize, which allows the struct to be serialized into a binary format using NBOR, and making it readable by Anchor.

#[derive(AnchorDeserialize, AnchorSerialize)]
pub struct CreateAssetArgs {
    name: String,
    uri: String,
}

In this CreateAssetArgs struct, the name and uri fields are provided as inputs, which will serve as arguments for the CreateV2CpiBuilder instruction used to create the Core NFT Asset.

Note: Since this is an Anchor focused guide, we're not going to include here how to create the Uri. If you aren't sure how to do it, refer to this example

Creating the Account Struct

The Account struct is where we define the accounts the instruction expects, and specify the constraints that these accounts must meet. This is done using two key constructs: types and constraints.

Account Types

Each type serves a specific purpose within your program:

  • Signer: Ensures that the account has signed the transaction.
  • Option: Allows for optional accounts that may or may not be provided.
  • Program: Verifies that the account is a specific program.

Constraints

While account types handle basic validations, they aren't sufficient for all the security checks your program might require. This is where constraints come into play.

Constraints add extra validation logic. For example, the #[account(mut)] constraint ensures that the asset and payer accounts are set as mutable, meaning that the data within these accounts can be modified during the instruction.

#[derive(Accounts)]
pub struct CreateAsset<'info> {
    #[account(mut)]
    pub asset: Signer<'info>,
    #[account(mut)]
    pub collection: Option<Account<'info, BaseCollectionV1>>,
    pub authority: Option<Signer<'info>>,
    #[account(mut)]
    pub payer: Signer<'info>,
    /// CHECK: this account will be checked by the mpl_core program
    pub owner: Option<UncheckedAccount<'info>>,
    /// CHECK: this account will be checked by the mpl_core program
    pub update_authority: Option<UncheckedAccount<'info>>,
    pub system_program: Program<'info, System>,
    #[account(address = MPL_CORE_ID)]
    /// CHECK: this account is checked by the address constraint
    pub mpl_core_program: UncheckedAccount<'info>,
}

Some accounts in the CreateAsset struct are marked as optional. This is because, in the definition of the CreateV2CpiBuilder, certain accounts can be omitted.

/// ### Accounts:
///
///   0. `[writable, signer]` asset
///   1. `[writable, optional]` collection
///   2. `[signer, optional]` authority
///   3. `[writable, signer]` payer
///   4. `[optional]` owner
///   5. `[optional]` update_authority
///   6. `[]` system_program

To make the example as flexible as possible, every optional account in the program instruction is also treated as optional in the create_core_asset instruction's account struct.

Creating the Instruction

The create_core_asset function utilizes the inputs from the CreateAsset account struct and the CreateAssetArgs arg struct that we defined earlier to interact with the CreateV2CpiBuilder program instruction.

pub fn create_core_asset(ctx: Context<CreateAsset>, args: CreateAssetArgs) -> Result<()> {
  let collection = match &ctx.accounts.collection {
    Some(collection) => Some(collection.to_account_info()),
    None => None,
  };

  let authority = match &ctx.accounts.authority {
    Some(authority) => Some(authority.to_account_info()),
    None => None,
  };

  let owner = match &ctx.accounts.owner {
    Some(owner) => Some(owner.to_account_info()),
    None => None,
  };

  let update_authority = match &ctx.accounts.update_authority {
    Some(update_authority) => Some(update_authority.to_account_info()),
    None => None,
  };
  
  CreateV2CpiBuilder::new(&ctx.accounts.mpl_core_program.to_account_info())
    .asset(&ctx.accounts.asset.to_account_info())
    .collection(collection.as_ref())
    .authority(authority.as_ref())
    .payer(&ctx.accounts.payer.to_account_info())
    .owner(owner.as_ref())
    .update_authority(update_authority.as_ref())
    .system_program(&ctx.accounts.system_program.to_account_info())
    .name(args.name)
    .uri(args.uri)
    .invoke()?;

  Ok(())
    }

In this function, the accounts defined in the CreateAsset struct are accessed using ctx.accounts. Before passing these accounts to the CreateV2CpiBuilder program instruction, they need to be converted to their raw data form using the .to_account_info() method.

This conversion is necessary because the builder requires the accounts in this format to interact correctly with the Solana runtime.

Some of the accounts in the CreateAsset struct are optional, meaning their value could be either Some(account) or None. To handle these optional accounts before passing them to the builder, we use a match statement that allows us to check if an account is present (Some) or absent (None) and based on this check, we bind the account as Some(account.to_account_info()) if it exists, or as None if it doesn't. Like this:

let collection = match &ctx.accounts.collection {
  Some(collection) => Some(collection.to_account_info()),
  None => None,
};

Note: As you can see, this approach is repeated for other optional accounts like authority, owner, and update_authority.

After preparing all the necessary accounts, we pass them to the CreateV2CpiBuilder and use .invoke() to execute the instruction, or .invoke_signed() if we need to use signer seeds.

For more details on how the Metaplex CPI Builder works, you can refer to this documentation

Additional Actions

Before moving on, What if we want to create the asset with plugins and/or external plugins, such as the FreezeDelegate plugin or the AppData external plugin, already included? Here's how we can do it.

First, let's add all the additional necessary imports:

use mpl_core::types::{
    Plugin, FreezeDelegate, PluginAuthority,
    ExternalPluginAdapterInitInfo, AppDataInitInfo, 
    ExternalPluginAdapterSchema
};

Then let's create vectors to hold the plugins and external plugin adapters, so we can easily add the plugin (or more) using the right imports:

let mut plugins: Vec<PluginAuthorityPair> = vec![];

plugins.push(
  PluginAuthorityPair { 
      plugin: Plugin::FreezeDelegate(FreezeDelegate {frozen: true}), 
      authority: Some(PluginAuthority::UpdateAuthority) 
  }
);

let mut external_plugin_adapters: Vec<ExternalPluginAdapterInitInfo> = vec![];
    
external_plugin_adapters.push(
  ExternalPluginAdapterInitInfo::AppData(
    AppDataInitInfo {
      init_plugin_authority: Some(PluginAuthority::UpdateAuthority),
      data_authority: PluginAuthority::Address{ address: data_authority },
      schema: Some(ExternalPluginAdapterSchema::Binary),
    }
  )
);

Lastly, let's integrate these plugins into the CreateV2CpiBuilder program instruction like this:

CreateV2CpiBuilder::new(&ctx.accounts.mpl_core_program.to_account_info())
  .asset(&ctx.accounts.asset.to_account_info())
  .collection(collection.as_ref())
  .authority(authority.as_ref())
  .payer(&ctx.accounts.payer.to_account_info())
  .owner(owner.as_ref())
  .update_authority(update_authority.as_ref())
  .system_program(&ctx.accounts.system_program.to_account_info())
  .name(args.name)
  .uri(args.uri)
  .plugins(plugins)
  .external_plugin_adapters(external_plugin_adapters)    
  .invoke()?;

Note: Refer to the documentation if you're not sure on what fields and plugin to use!

The Client

We've now reached the "testing" part of the guide for creating a Core Collection. But before testing the program we've built, we need to compile the workspace. Use the following command to build everything so it's ready for deployment and testing:

anchor build

After building, we should deploy the program so we can access it with our script. We can set the cluster we want to deploy the program to, in the anchor.toml file and then use the following command:

anchor deploy

Finally we're ready to test the program, but before, we need to work on the create-core-asset-example.ts in the tests folder.

Imports and Templates

Here are all the imports and the general template needed for the test.

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { CreateCoreAssetExample } from "../target/types/create_core_asset_example";
import { Keypair, SystemProgram } from "@solana/web3.js";
import { MPL_CORE_PROGRAM_ID } from "@metaplex-foundation/mpl-core";

describe("create-core-asset-example", () => {
  anchor.setProvider(anchor.AnchorProvider.env());
  const wallet = anchor.Wallet.local();
  const program = anchor.workspace.CreateCoreAssetExample as Program<CreateCoreAssetExample>;

  let asset = Keypair.generate();

  it("Create Asset", async () => {

  });
});

Creating the Test Function

In the test function, we're going to define the createAssetArgs struct and then pass in all the necessary accounts to the createCoreAsset function.

it("Create Asset", async () => {

  let createAssetArgs = {
    name: 'My Asset',
    uri: 'https://example.com/my-asset.json',
  };

  const createAssetTx = await program.methods.createCoreAsset(createAssetArgs)
    .accountsPartial({
      asset: asset.publicKey,
      collection: null,
      authority: null,
      payer: wallet.publicKey,
      owner: null,
      updateAuthority: null,
      systemProgram: SystemProgram.programId,
      mplCoreProgram: MPL_CORE_PROGRAM_ID
    })
    .signers([asset, wallet.payer])
    .rpc();

  console.log(createAssetTx);
});

We start by calling the createCoreAsset method and passing as input the createAssetArgs struct we just created:

await program.methods.createCoreAsset(createAssetArgs)

Next, we specify all the accounts required by the function. Since some of these accounts are optional, we can pass null for simplicity where the account isn't needed:

.accountsPartial({
  asset: asset.publicKey,
  collection: null,
  authority: null,
  payer: wallet.publicKey,
  owner: null,
  updateAuthority: null,
  systemProgram: SystemProgram.programId,
  mplCoreProgram: MPL_CORE_PROGRAM_ID
})

Finally, we provide the signers and send the transaction using the .rpc() method:

.signers([asset, wallet.payer])
.rpc();
Previous
Web2 typescript Staking Example