Cover image
NOV 24, 2023

Collection and Merkle Tree on-chain maintenance

by Vlad, Access Protocol

In this article you will learn how to create a collection and a Merkle Tree in Solana on-chain program to have a fully decentralized solution to minting cNFTs.
Vlad is a Fullstack Software Engineer at Access Protocol, working on Smart Contract and Backend development, as well as user-facing applications. Vlad has moved to the exciting world of crypto after building big systems in traditional FinTech companies. You can reach out to Vlad on X (@mmatdev) or via e-mail at vladislav@accessprotocol.co.
This is a Part 2 of an article about cNFT minting and burning from an on-chain program. You can find the Part 1 here: https://scribe.accessprotocol.co/access-protocol/cnft-operations-in-an-on-chain-program-clovocpxc0001i808lss4n078
In the previous article we have solved cNFT minting and burning from an on-chain program. However, the cNFT collection and Merkle tree creation was done off-chain and there was no requirement for the collection and Merkle tree authority. This could pose a security issue in a real-world program as a malicious actor could call the program with an account that he owns and then modify the collection or Merkle tree in any way.

Collection creation

First part of our new code will solve the collection creation on chain. This can be directly plugged into the Anchor contract from the previous article.

Collection data and account structures

We will create a collection and store its mint address on-chain in a dedicated account. This account will serve as well as the collection (and later Merkle tree) authority.
#[account] pub struct CentralStateData { pub collection_address: Pubkey, pub merkle_tree_address: Option<Pubkey>, } impl CentralStateData { pub const MAX_SIZE: usize = 32 * 3; } #[derive(Accounts)] pub struct Init<'info> { /// CHECK: ok, we are passing in this account ourselves #[account(mut, signer)] pub signer: AccountInfo<'info>, #[account( init, payer = signer, space = 8 + CentralStateData::MAX_SIZE, seeds = [b"central_authority"], bump )] pub central_authority: Account<'info, CentralStateData>, #[account( init, payer = signer, mint::decimals = 0, mint::authority = central_authority.key(), mint::freeze_authority = central_authority.key(), )] pub mint: Account<'info, Mint>, #[account( init_if_needed, payer = signer, associated_token::mint = mint, associated_token::authority = central_authority )] pub associated_token_account: Account<'info, TokenAccount>, /// CHECK - address #[account( mut, address = find_metadata_account(& mint.key()).0, )] pub metadata_account: AccountInfo<'info>, /// CHECK: address #[account( mut, address = find_master_edition_account(& mint.key()).0, )] pub master_edition_account: AccountInfo<'info>, pub token_program: Program<'info, Token>, pub associated_token_program: Program<'info, AssociatedToken>, pub token_metadata_program: Program<'info, Metadata>, pub system_program: Program<'info, System>, pub rent: Sysvar<'info, Rent>, }

Collection creation code

Collection is just an ordinary uncompressed NFT with a few specific details. To set it up we need to create the following three accounts:
    • Mint account
    • Metadata account
    • Master Edition Account
You can find more information about the account structure and purpose in the Metaplex documentation.
To ensure that this is a collection and not just an NFT, we only need to set the collection details with the maximum supply equal to one: Some(CollectionDetails::V1 { size: 1 }).
We save the collection mint address to the central_authority account so that we can then check the collection account passed to the mint instruction like this:
require_keys_eq!( *ctx.accounts.collection_mint.key, ctx.accounts.central_authority.collection_address, MyError::InvalidCollection );
The full code of the collection setup looks as follows:
pub fn initialize( ctx: Context<Init>, name: String, symbol: String, uri: String, ) -> Result<()> { let bump_seed = [ctx.bumps.central_authority]; let signer_seeds: &[&[&[u8]]] = &[&[ "central_authority".as_bytes(), &bump_seed.as_ref(), ]]; // create mint account let cpi_context = CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), MintTo { mint: ctx.accounts.mint.to_account_info(), to: ctx.accounts.associated_token_account.to_account_info(), authority: ctx.accounts.central_authority.to_account_info(), }, signer_seeds, ); mint_to(cpi_context, 1)?; // create metadata account let cpi_context = CpiContext::new_with_signer( ctx.accounts.token_metadata_program.to_account_info(), CreateMetadataAccountsV3 { metadata: ctx.accounts.metadata_account.to_account_info(), mint: ctx.accounts.mint.to_account_info(), mint_authority: ctx.accounts.central_authority.to_account_info(), update_authority: ctx.accounts.central_authority.to_account_info(), payer: ctx.accounts.signer.to_account_info(), system_program: ctx.accounts.system_program.to_account_info(), rent: ctx.accounts.rent.to_account_info(), }, signer_seeds, ); let data_v2 = DataV2 { name, symbol, uri, seller_fee_basis_points: 0, creators: None, collection: None, uses: None, }; create_metadata_accounts_v3( cpi_context, data_v2, true, true, Some(CollectionDetails::V1 { size: 1 }), )?; //create master edition account let cpi_context = CpiContext::new_with_signer( ctx.accounts.token_metadata_program.to_account_info(), CreateMasterEditionV3 { edition: ctx.accounts.master_edition_account.to_account_info(), mint: ctx.accounts.mint.to_account_info(), update_authority: ctx.accounts.central_authority.to_account_info(), mint_authority: ctx.accounts.central_authority.to_account_info(), payer: ctx.accounts.signer.to_account_info(), metadata: ctx.accounts.metadata_account.to_account_info(), token_program: ctx.accounts.token_program.to_account_info(), system_program: ctx.accounts.system_program.to_account_info(), rent: ctx.accounts.rent.to_account_info(), }, signer_seeds, ); create_master_edition_v3(cpi_context, Some(0))?; ctx.accounts.central_authority.collection_address = ctx.accounts.mint.key(); Ok(()) }

Merkle tree creation

To create a merkle tree we will use the instructions::CreateTreeConfigCpiBuilder from the Metaplex Bubblegum library. However, there is one pitfall that we have to solve on the client side before we can successfully create a Merkle tree using a CPI call on chain.
To be able to initialize the Merkle tree we need to preallocate the needed space on-chain first. This is usually not possible to do from your on-chain program, because an inner instruction can allocate at most than 10KB of space and most of the commonly used trees need more than that. You would then run into the following error:

SystemProgram::CreateAccount data size limited to 10240 in inner instructions

Fortunately, the JavaScript @solana/spl-account-compression library provides us with a helper function createAllocTreeIx to create an instruction to preallocate the needed space. You can find an example of its usage in tests in the GitHub repository linked at the end of this article. Once we have preallocated the space, the tree creation is fairly straightforward.

Merkle tree account structure and constants

I have decided to require all the created trees in my program to be the same size. I am including a code snippet to calculate the appropriate account size in case that you need a different one or want to allow multiple sizes. Beware that the canopy depth is not specified when creating a tree, it is derived from the account size instead.
// The program will support only trees of the following parameters: const MAX_TREE_DEPTH: u32 = 14; const MAX_TREE_BUFFER_SIZE: u32 = 64; // this corresponds to account with a canopy depth 11. // If you need the tree parameters to be dynamic, you can use the following function: // fn tree_bytes_size() -> usize { // const CONCURRENT_MERKLE_TREE_HEADER_SIZE_V1: usize = 2 + 54; // let merkle_tree_size = size_of::<ConcurrentMerkleTree<14, 64>>(); // msg!("merkle tree size: {}", merkle_tree_size); // let canopy_size = ((2 << 9) - 2) * 32; // msg!("canopy size: {}", canopy_size); // CONCURRENT_MERKLE_TREE_HEADER_SIZE_V1 + merkle_tree_size + (canopy_size as usize) // } const REQUIRED_TREE_ACCOUNT_SIZE: usize = 162_808; #[derive(Accounts)] pub struct MerkleTree<'info> { #[account(mut, signer)] pub payer: Signer<'info>, #[account( seeds = [b"central_authority"], bump, mut )] pub central_authority: Account<'info, CentralStateData>, /// CHECK: This account must be all zeros #[account(zero, signer)] pub merkle_tree: AccountInfo<'info>, /// CHECK: This account is checked in the instruction #[account(mut)] pub tree_config: UncheckedAccount<'info>, // program pub bubblegum_program: Program<'info, MplBubblegum>, pub system_program: Program<'info, System>, pub log_wrapper: Program<'info, Noop>, pub compression_program: Program<'info, SplAccountCompression>, }

Merkle tree creation code

You can notice that this instruction is permissionless. However, the only thing that it does is that it swaps the currently used Merkle tree for an empty one. Therefore, this doesn't pose any security risk.
Keep in mind that in a real-world scenario you need to monitor the remaining capacity of the currently used Merkle tree and call this instruction to swap it for a new one once it is about to run out of space.
pub fn initialize_tree<'info>(ctx: Context<'_, '_, '_, 'info, MerkleTree<'info>>) -> Result<()> { msg!("initializing merkle tree"); require_eq!(ctx.accounts.merkle_tree.data.borrow().len(), REQUIRED_TREE_ACCOUNT_SIZE, MyError::UnsupportedTreeAccountSize); let bump_seed = [ctx.bumps.central_authority]; let signer_seeds: &[&[&[u8]]] = &[&[ "central_authority".as_bytes(), &bump_seed.as_ref(), ]]; CreateTreeConfigCpiBuilder::new( &ctx.accounts.bubblegum_program.to_account_info(), ) .tree_config(&ctx.accounts.tree_config.to_account_info()) .merkle_tree(&ctx.accounts.merkle_tree.to_account_info()) .payer(&ctx.accounts.payer.to_account_info()) .tree_creator(&ctx.accounts.central_authority.to_account_info()) .log_wrapper(&ctx.accounts.log_wrapper.to_account_info()) .compression_program(&ctx.accounts.compression_program.to_account_info()) .system_program(&ctx.accounts.system_program.to_account_info()) .max_depth(MAX_TREE_DEPTH) .max_buffer_size(MAX_TREE_BUFFER_SIZE) .invoke_signed(signer_seeds)?; ctx.accounts.central_authority.merkle_tree_address = Some(ctx.accounts.merkle_tree.key()); Ok(()) }

Closing notes

I put the whole example Anchor program into a GitHub repo here:
You can find there a fully functioning on-chain program code and examples of its JavaScript usage in tests.
Feel free to check it out and point out any issues that you might see or create a PR to improve the code for everyone.
Comments
To comment, please sign in.
J1yZ..Z5Fn
last year
Good project
7kUD..4tLL
last year
Cool thread