Anchor
How to Create a Core Collection with Anchor
This guide will demonstrate the use of the mpl-core
Rust SDK crate to create a Core NFT Collection 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 Collections:
What are Collections?
Collections are a group of Assets that belong together, part of the same series, or group. In order to group Assets together, we must first create a Collection Asset whose purpose is to store any metadata related to that collection such as collection name and collection image. The Collection Asset acts as a front cover to your collection and can also store collection wide plugins.
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-collection-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-collection-example
directory:
cd create-core-collection-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,
instructions::CreateCollectionV2CpiBuilder,
};
declare_id!("C9PLf3qMCVqtUCJtEBy8NCcseNp3KTZwFJxAtDdN1bto");
#[derive(AnchorDeserialize, AnchorSerialize)]
pub struct CreateCollectionArgs {
}
#[program]
pub mod create_core_collection_example {
use super::*;
pub fn create_core_collection(ctx: Context<CreateCollection>, args: CreateCollectionArgs) -> Result<()> {
Ok(())
}
}
#[derive(Accounts)]
pub struct CreateCollection<'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 (CreateCollectionArgs
) 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 CreateCollectionArgs {
name: String,
uri: String,
}
In this CreateCollectionArgs
struct, the name and uri fields are provided as inputs, which will serve as arguments for the CreateCollectionV2CpiBuilder
instruction used to create the Core Collection.
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 collection
and payer
accounts are set as mutable, meaning that the data within these accounts can be modified during the instruction.
#[derive(Accounts)]
pub struct CreateCollection<'info> {
#[account(mut)]
pub collection: Signer<'info>,
/// CHECK: this account will be checked by the mpl_core program
pub update_authority: Option<UncheckedAccount<'info>>,
#[account(mut)]
pub payer: Signer<'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 CreateCollection
struct are marked as optional
. This is because, in the definition of the CreateCollectionV2CpiBuilder
, certain accounts can be omitted.
/// ### Accounts:
///
/// 0. `[writable, signer]` collection
/// 1. `[optional]` update_authority
/// 2. `[writable, signer]` payer
/// 3. `[]` 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_collection
instruction's account struct.
Creating the Instruction
The create_core_collection
function utilizes the inputs from the CreateCollection
account struct and the CreateCollectionArgs
arg struct that we defined earlier to interact with the CreateCollectionV2CpiBuilder
program instruction.
pub fn create_core_collection(ctx: Context<CreateCollection>, args: CreateCollectionArgs) -> Result<()> {
let update_authority = match &ctx.accounts.update_authority {
Some(update_authority) => Some(update_authority.to_account_info()),
None => None,
};
CreateCollectionV2CpiBuilder::new(&ctx.accounts.mpl_core_program.to_account_info())
.collection(&ctx.accounts.collection.to_account_info())
.payer(&ctx.accounts.payer.to_account_info())
.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 CreateCollection
struct are accessed using ctx.accounts
. Before passing these accounts to the CreateCollectionV2CpiBuilder
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 update_authority = match &ctx.accounts.update_authority {
Some(update_authority) => Some(update_authority.to_account_info()),
None => None,
};
After preparing all the necessary accounts, we pass them to the CreateCollectionV2CpiBuilder
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 CreateCollectionV2CpiBuilder
program instruction like this:
CreateCollectionV2CpiBuilder::new(&ctx.accounts.mpl_core_program.to_account_info())
.collection(&ctx.accounts.collection.to_account_info())
.payer(&ctx.accounts.payer.to_account_info())
.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_collection_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 { CreateCoreCollectionExample } from "../target/types/create_core_collection_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.CreateCoreCollectionExample as Program<CreateCoreCollectionExample>;
let collection = Keypair.generate();
it("Create Collection", async () => {
});
});
Creating the Test Function
In the test function, we're going to define the createCollectionArgs
struct and then pass in all the necessary accounts to the createCoreCollection
function.
it("Create Collection", async () => {
let createCollectionArgs = {
name: 'My Collection',
uri: 'https://example.com/my-collection.json',
};
const createCollectionTx = await program.methods.createCoreCollection(createCollectionArgs)
.accountsPartial({
collection: collection.publicKey,
payer: wallet.publicKey,
updateAuthority: null,
systemProgram: SystemProgram.programId,
mplCoreProgram: MPL_CORE_PROGRAM_ID
})
.signers([collection, wallet.payer])
.rpc();
console.log(createCollectionTx);
});
We start by calling the createCoreCollection
method and passing as input the createCollectionArgs
struct we just created:
await program.methods.createCoreCollection(createCollectionArgs)
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({
collection: collection.publicKey,
payer: wallet.publicKey,
updateAuthority: null,
systemProgram: SystemProgram.programId,
mplCoreProgram: MPL_CORE_PROGRAM_ID
})
Finally, we provide the signers and send the transaction using the .rpc()
method:
.signers([collection, wallet.payer])
.rpc();