Imagine trying to mint 1 million NFTs on Solana. Traditional NFTs would require 2 million on-chain accounts: One mint and one token account per NFT, resulting in prohibitively high storage and transaction costs. Compressed NFTs (cNFTs) solve this by storing NFT data off-chain in a Merkle tree, with only the root on-chain. This approach slashes costs dramatically, transforming large-scale NFT projects from impractical to economically feasible.
In this post we will explore Compressed NFTs (cNFTs) on Solana, focusing on the account-compression
and mpl-bubblegum
programs. We'll cover how cNFTs use Merkle trees to store NFT data off-chain, significantly reducing costs and complexity. This guide is designed for developers and auditors who want to understand the inner workings of cNFTs without diving into the codebase.
We'll begin by understanding what cNFTs are, then dive into the account-compression
program, and finally explore mpl-bubblegum
.
You won’t need to open the code while reading this, everything is explained here!😉
1. NFTs vs cNFTs
1.1 What are NFTs in Solana?
On Solana, NFTs are similar to regular tokens. The only difference is that NFTs have a total supply of 1. To hold an NFT, you need:
- A token account: Holds the NFT.
- A mint account: Stores the NFT's data.
Both are managed under the SPL Token program.
For 100 NFTs, you'll need:
- 100 token accounts.
- 100 mint accounts.
That’s 200 accounts in total, each with its own public key and rent deposit. Especially the cost of rent quickly adds up.
1.2 What are cNFTs and how are they different?
Because we need on-chain accounts for each NFT, scaling NFTs quickly becomes expensive. For example, in gaming, you might need millions of NFTs, leading to millions of accounts.
To solve this, compressed NFTs (cNFTs) were introduced. They use Merkle trees (Concurrent Merkle Trees) to store NFT data (IDs, owners, metadata, etc.) off-chain, and only the root of the Merkle tree is stored on-chain.
Why do we store the root of the Merkle tree in an on-chain account?
For validations, for example, if someone wants to transfer their own NFT, we pass the leaf based on that, and with the help of the root, we validate that the leaf exists on the Merkle tree.
Each leaf in the tree represents a full NFT, including the owner and metadata. Instead of creating thousands of accounts, you just create a single Merkle tree account and validate ownership using Merkle proofs.
This drastically reduces costs, from millions of accounts to 2 accounts.
2. account-compression
The Account Compression Program's source is available on GitHub.
The idea is simple:
- Create and manage a Concurrent Merkle Tree off-chain.
- Store only the tree’s root on-chain.
- Validate changes (like transfers) using on-chain root and proofs.
While it’s used for cNFTs in this guide, the program is generic and can support other use cases too.
Each leaf can represent an NFT, with its owner and related metadata. When actions like transfer happen, changes are made off-chain, and only the proofs are verified on-chain using the root.
Example of a leaf:
LeafSchema::new_v1(
asset_id,
leaf_owner.key(),
leaf_delegate.key(),
nonce,
data_hash,
creator_hash,
);
Let's dive into the code, starting with the Noop program.
2.1: Noop Program
This is a placeholder program that does nothing (no-op means no operation). It’s used to emit logs for off-chain indexers to pick up.
When you send a transaction through noop
, it doesn't do any computation, but indexers can pick up on the call data, interpret them as logs and process these data accordingly.
use solana_program::{
account_info::AccountInfo, declare_id, entrypoint::ProgramResult, instruction::Instruction,
pubkey::Pubkey,
};
declare_id!("noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV");
#[cfg(not(feature = "no-entrypoint"))]
solana_program::entrypoint!(noop);
pub fn noop(
_program_id: &Pubkey,
_accounts: &[AccountInfo],
_instruction_data: &[u8],
) -> ProgramResult {
Ok(())
}
pub fn instruction(data: Vec<u8>) -> Instruction {
Instruction {
program_id: crate::id(),
accounts: vec![],
data,
}
}
Example of calling:
pub fn wrap_event<'info>(
event: &AccountCompressionEvent,
noop_program: &Program<'info, Noop>,
) -> Result<()> {
invoke(
&spl_noop::instruction(event.try_to_vec()?),
&[noop_program.to_account_info()],
)?;
Ok(())
}
event.try_to_vec()?
is the data that we need off-chain.
2.2: account-compression Program
This is the core of Merkle tree management. It includes instructions to:
- Create a tree.
- Append leaves (mint nft).
- Replace leaves (transfer, burn nft).
- Verify leaves (optional).
Let’s briefly summarise the key instructions:
2.2.1 init_empty_merkle_tree
Initialises a new empty Merkle tree.
- Accounts
/// Context for initializing a new SPL ConcurrentMerkleTree
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(zero)]
/// CHECK: This account will be zeroed out, and the size will be validated
pub merkle_tree: UncheckedAccount<'info>,
/// Authority that controls write-access to the tree
/// Typically a program, e.g., the Bubblegum contract validates that leaves are valid NFTs.
pub authority: Signer<'info>,
/// Program used to emit changelogs as cpi instruction data.
pub noop: Program<'info, Noop>,
}
- Merkle tree account, it must be owned by this program (will check in logic) and must be zeroed.
- Authority that will control the Merkle tree, for cNFTs, a PDA that is derived from Bubblegum will be the authority of Merkle trees.
- noop program.
- Inputs
pub fn init_empty_merkle_tree(
ctx: Context<Initialize>,
max_depth: u32,
max_buffer_size: u32,
) -> Result<()> {
- max_depth is the height of the tree, and it defines the maximum leaf of the tree (2**max_depth).
- max_buffer_size is a minimum concurrency limit, for example, if it's 1024, a minimum of 1024 replacements can be executed before a new proof must be generated for the next replace instruction.
This data must be calculated beforehand, depending on need.
- Logic
require_eq!(
*ctx.accounts.merkle_tree.owner,
crate::id(),
AccountCompressionError::IncorrectAccountOwner
);
let mut merkle_tree_bytes = ctx.accounts.merkle_tree.try_borrow_mut_data()?;
let (mut header_bytes, rest) =
merkle_tree_bytes.split_at_mut(CONCURRENT_MERKLE_TREE_HEADER_SIZE_V1);
let mut header = ConcurrentMerkleTreeHeader::try_from_slice(header_bytes)?;
header.initialize(
max_depth,
max_buffer_size,
&ctx.accounts.authority.key(),
Clock::get()?.slot,
);
header.serialize(&mut header_bytes)?;
let merkle_tree_size = merkle_tree_get_size(&header)?;
let (tree_bytes, canopy_bytes) = rest.split_at_mut(merkle_tree_size);
let id = ctx.accounts.merkle_tree.key();
let change_log_event = merkle_tree_initialize_empty(&header, id, tree_bytes)?;
wrap_event(
&AccountCompressionEvent::ChangeLog(*change_log_event),
&ctx.accounts.noop,
)?;
update_canopy(canopy_bytes, header.get_max_depth(), None)
}
- Checks the owner.
- Splits data for getting the header and initialises it with inputs, authority, and time.
- Header:
This is the struct for the header:
pub struct ConcurrentMerkleTreeHeader {
/// Account type
pub account_type: CompressionAccountType,
/// Versioned header
pub header: ConcurrentMerkleTreeHeaderData,
}
We have two types: Uninitialized and ConcurrentMerkleTree.
pub struct ConcurrentMerkleTreeHeaderDataV1 {
max_buffer_size: u32,
max_depth: u32,
authority: Pubkey,
creation_slot: u64,
_padding: [u8; 6],
}
It sets type to ConcurrentMerkleTree and assigns other fields.
- Now this is the interesting part: after assigning the header, we use the rest of
merkle_tree_bytes
for the Merkle tree, and the other part (rest-merkletreesize) will be used forcanopy_bytes
. - The tree is then initialized, and we will use
spl-concurrent-merkle-tree
andwrap_event
for initialisation. - As you see, when we make changes to the tree, we will get
change_log_event
. We will send this data to the noop program so the indexer will get this data and make changes off-chain. - After all, we will update the canopy. So what is
canopy_bytes
?
Canopy Nodes
When we want to change a leaf, we must provide the proof. Proof is provided in the remaining account, so for each node, we must pass an account. Due to the transaction size limit, we cannot pass many nodes. Therefore, in some cases, when the maximum depth is high, we will encounter a size limit on transactions.
Solution? Canopy Nodes
These canopy_bytes
help us in generating and providing proofs. These are some upper nodes in the tree that we will need in proofs. So we store these nodes in an on-chain account and during the proof providing we don't need to give all nodes, we pass some lower node and the account-compression program appends these upper nodes for us.
Ex: If we have a depth of 10 and store 3 top nodes, these 3 top nodes are canopy_bytes
, and we don't need to pass them to the program. When we provide the 7 remaining nodes as proof, the program will update the proof with these canopy_bytes
.
pub fn update_canopy(
canopy_bytes: &mut [u8],
max_depth: u32,
change_log: Option<&ChangeLogEvent>,
) -> Result<()> {
check_canopy_bytes(canopy_bytes)?;
let canopy = cast_slice_mut::<u8, Node>(canopy_bytes);
let path_len = get_cached_path_length(canopy, max_depth)?;
if let Some(cl_event) = change_log {
match &*cl_event {
ChangeLogEvent::V1(cl) => {
// Update the canopy from the newest change log
for path_node in cl.path.iter().rev().skip(1).take(path_len as usize) {
// node_idx - 2 maps to the canopy index
canopy[(path_node.index - 2) as usize] = path_node.node;
}
}
}
}
Ok(())
}
It first checks the size of canopy_bytes to be multiplied by each node, then determines how many nodes from the Merkle path should be updated into the canopy.
Then, if we want the event to create data for us, which in our case we don't need, we set change_log to None.
2.2.2 transfer_authority
Transfers write-access of a Merkle tree from one authority to another.
pub fn transfer_authority(
ctx: Context<TransferAuthority>,
new_authority: Pubkey,
) -> Result<()> {
require_eq!(
*ctx.accounts.merkle_tree.owner,
crate::id(),
AccountCompressionError::IncorrectAccountOwner
);
let mut merkle_tree_bytes = ctx.accounts.merkle_tree.try_borrow_mut_data()?;
let (mut header_bytes, _) =
merkle_tree_bytes.split_at_mut(CONCURRENT_MERKLE_TREE_HEADER_SIZE_V1);
let mut header = ConcurrentMerkleTreeHeader::try_from_slice(header_bytes)?;
header.assert_valid_authority(&ctx.accounts.authority.key())?;
header.set_new_authority(&new_authority);
header.serialize(&mut header_bytes)?;
Ok(())
}
2.2.3 close_empty_tree
Closes an unused Merkle tree and returns the rent (lamports) to the recipient.
- Accounts
#[derive(Accounts)]
pub struct CloseTree<'info> {
#[account(mut)]
/// CHECK: This account is validated in the instruction
pub merkle_tree: AccountInfo<'info>,
/// Authority that controls write-access to the tree
pub authority: Signer<'info>,
/// CHECK: Recipient of funds after
#[account(mut)]
pub recipient: AccountInfo<'info>,
}
- Merkle tree account, it must be owned by this program (will check in logic).
- Authority that controls the Merkle tree.
- Recipient for getting funds.
- Inputs
- No input needed.
- Logic
require_eq!(
*ctx.accounts.merkle_tree.owner,
crate::id(),
AccountCompressionError::IncorrectAccountOwner
);
let mut merkle_tree_bytes = ctx.accounts.merkle_tree.try_borrow_mut_data()?;
let (header_bytes, rest) =
merkle_tree_bytes.split_at_mut(CONCURRENT_MERKLE_TREE_HEADER_SIZE_V1);
let header = ConcurrentMerkleTreeHeader::try_from_slice(header_bytes)?;
header.assert_valid_authority(&ctx.accounts.authority.key())?;
let merkle_tree_size = merkle_tree_get_size(&header)?;
let (tree_bytes, canopy_bytes) = rest.split_at_mut(merkle_tree_size);
let id = ctx.accounts.merkle_tree.key();
merkle_tree_prove_tree_is_empty(&header, id, tree_bytes)?;
// Close merkle tree account
// 1. Move lamports
let dest_starting_lamports = ctx.accounts.recipient.lamports();
**ctx.accounts.recipient.lamports.borrow_mut() = dest_starting_lamports
.checked_add(ctx.accounts.merkle_tree.lamports())
.unwrap();
**ctx.accounts.merkle_tree.lamports.borrow_mut() = 0;
// 2. Set all CMT account bytes to 0
header_bytes.fill(0);
tree_bytes.fill(0);
canopy_bytes.fill(0);
Ok(())
- It first checks the tree to be owned by the program.
- Validates the authority.
- Checks the tree to be empty.
- Deletes the tree and sends Lamports to the recipient.
2.2.4 append
This is the function that the authority could append a new leaf to the tree.
This is the function that we use in Bubblegum when we want to mint NFTs.
- Accounts
#[derive(Accounts)]
pub struct Modify<'info> {
#[account(mut)]
/// CHECK: This account is validated in the instruction
pub merkle_tree: UncheckedAccount<'info>,
/// Authority that controls write-access to the tree
/// Typically a program, e.g., the Bubblegum contract validates that leaves are valid NFTs.
pub authority: Signer<'info>,
/// Program used to emit changelogs as cpi instruction data.
pub noop: Program<'info, Noop>,
}
- Merkle tree account, it must be owned by this program (will check in logic).
- Authority that controls the Merkle tree.
- noop program that we will call to get data off-chain and append the leaf off-chain.
- Inputs
leaf: [u8; 32]
- This is the leaf that we want to append, for the cNFT case, Bubblegum will use the following leaf:
LeafSchema::new_v1(
asset_id,
leaf_owner.key(),
leaf_delegate.key(),
nonce,
data_hash,
creator_hash,
);
- Logic
require_eq!(
*ctx.accounts.merkle_tree.owner,
crate::id(),
AccountCompressionError::IncorrectAccountOwner
);
let mut merkle_tree_bytes = ctx.accounts.merkle_tree.try_borrow_mut_data()?;
let (header_bytes, rest) =
merkle_tree_bytes.split_at_mut(CONCURRENT_MERKLE_TREE_HEADER_SIZE_V1);
let header = ConcurrentMerkleTreeHeader::try_from_slice(header_bytes)?;
header.assert_valid_authority(&ctx.accounts.authority.key())?;
let id = ctx.accounts.merkle_tree.key();
let merkle_tree_size = merkle_tree_get_size(&header)?;
let (tree_bytes, canopy_bytes) = rest.split_at_mut(merkle_tree_size);
let change_log_event = merkle_tree_append_leaf(&header, id, tree_bytes, &leaf)?;
update_canopy(
canopy_bytes,
header.get_max_depth(),
Some(&change_log_event),
)?;
wrap_event(
&AccountCompressionEvent::ChangeLog(*change_log_event),
&ctx.accounts.noop,
)
- It first checks the tree to be owned by the program.
- Validates the authority.
- Gets the tree_bytes and canopy_bytes.
- Logs an event by calling the noop program.
- Updates the canopy.
2.2.5 insert_or_append
Tries to insert a leaf at a specific index. If it fails, it appends it.
- Accounts
- Same as above.
- Inputs
root: [u8; 32],
leaf: [u8; 32],
index: u32,
- More than the leaf, it will get the index of the leaf that wants to insert and the root for validation.
- Logic
require_eq!(
*ctx.accounts.merkle_tree.owner,
crate::id(),
AccountCompressionError::IncorrectAccountOwner
);
let mut merkle_tree_bytes = ctx.accounts.merkle_tree.try_borrow_mut_data()?;
let (header_bytes, rest) =
merkle_tree_bytes.split_at_mut(CONCURRENT_MERKLE_TREE_HEADER_SIZE_V1);
let header = ConcurrentMerkleTreeHeader::try_from_slice(header_bytes)?;
header.assert_valid_authority(&ctx.accounts.authority.key())?;
header.assert_valid_leaf_index(index)?;
let merkle_tree_size = merkle_tree_get_size(&header)?;
let (tree_bytes, canopy_bytes) = rest.split_at_mut(merkle_tree_size);
let mut proof = vec![];
for node in ctx.remaining_accounts.iter() {
proof.push(node.key().to_bytes());
}
fill_in_proof_from_canopy(canopy_bytes, header.get_max_depth(), index, &mut proof)?;
// A call is made to ConcurrentMerkleTree::fill_empty_or_append
let id = ctx.accounts.merkle_tree.key();
let args = &FillEmptyOrAppendArgs {
current_root: root,
leaf,
proof_vec: proof,
index,
};
let change_log_event = merkle_tree_fill_empty_or_append(&header, id, tree_bytes, args)?;
update_canopy(
canopy_bytes,
header.get_max_depth(),
Some(&change_log_event),
)?;
wrap_event(
&AccountCompressionEvent::ChangeLog(*change_log_event),
&ctx.accounts.noop,
)
- It first checks the tree to be owned by the program.
- Validates the authority.
- Validates index to be in range
leaf_index < (1 << self.get_max_depth())
. - Gets the tree_bytes and canopy_bytes.
- Gets the proof: proof will be obtained from
remaining_accounts
. - Updates the proof with
canopy
as we said, due to transaction limit, since we couldn't pass the full proof in the remaining accounts, so canopy will help here. - Logs an event by calling the noop program.
- Updates the canopy.
The difference that we will see here is that instead of passing
merkle_tree_append_leaf
as a change_log_event, we passmerkle_tree_fill_empty_or_append
.
2.2.6 replace_leaf
This is the function that replaces an existing leaf.
This is the function that we use in Bubblegum when we want to transfer, burn, or do other stuff to NFTs.
- Accounts
- Same as above.
- Inputs
root: [u8; 32],
previous_leaf: [u8; 32],
new_leaf: [u8; 32],
index: u32,
- Logic
require_eq!(
*ctx.accounts.merkle_tree.owner,
crate::id(),
AccountCompressionError::IncorrectAccountOwner
);
let mut merkle_tree_bytes = ctx.accounts.merkle_tree.try_borrow_mut_data()?;
let (header_bytes, rest) =
merkle_tree_bytes.split_at_mut(CONCURRENT_MERKLE_TREE_HEADER_SIZE_V1);
let header = ConcurrentMerkleTreeHeader::try_from_slice(header_bytes)?;
header.assert_valid_authority(&ctx.accounts.authority.key())?;
header.assert_valid_leaf_index(index)?;
let merkle_tree_size = merkle_tree_get_size(&header)?;
let (tree_bytes, canopy_bytes) = rest.split_at_mut(merkle_tree_size);
let mut proof = vec![];
for node in ctx.remaining_accounts.iter() {
proof.push(node.key().to_bytes());
}
fill_in_proof_from_canopy(canopy_bytes, header.get_max_depth(), index, &mut proof)?;
let id = ctx.accounts.merkle_tree.key();
// A call is made to ConcurrentMerkleTree::set_leaf(root, previous_leaf, new_leaf, proof, index)
let args = &SetLeafArgs {
current_root: root,
previous_leaf,
new_leaf,
proof_vec: proof,
index,
};
let change_log_event = merkle_tree_set_leaf(&header, id, tree_bytes, args)?;
update_canopy(
canopy_bytes,
header.get_max_depth(),
Some(&change_log_event),
)?;
wrap_event(
&AccountCompressionEvent::ChangeLog(*change_log_event),
&ctx.accounts.noop,
)
Same as above:
- It first checks the tree to be owned by the program.
- Validates the authority.
- Validates index to be in range
leaf_index < (1 << self.get_max_depth())
. - Gets the tree_bytes and canopy_bytes.
- Gets the proof: proof will be obtained from
remaining_accounts
. - Updates the proof with
canopy
as we said, due to transaction limits, we couldn't pass the full proof in the remaining accounts, so canopy will help here. - Logs an event by calling the noop program.
- Updates the canopy.
The difference that we will see here is that instead of passing
merkle_tree_append_leaf
as a change_log_event, we passmerkle_tree_set_leaf
.
2.2.7 verify_leaf
Verifies if a given leaf exists in the Merkle tree.
- Accounts
pub struct VerifyLeaf<'info> {
/// CHECK: This account is validated in the instruction
pub merkle_tree: UncheckedAccount<'info>,
}
- Merkle tree account, it must be owned by this program (will check in logic).
- Inputs
root: [u8; 32],
leaf: [u8; 32],
index: u32,
- Logic
require_eq!(
*ctx.accounts.merkle_tree.owner,
crate::id(),
AccountCompressionError::IncorrectAccountOwner
);
let merkle_tree_bytes = ctx.accounts.merkle_tree.try_borrow_data()?;
let (header_bytes, rest) =
merkle_tree_bytes.split_at(CONCURRENT_MERKLE_TREE_HEADER_SIZE_V1);
let header = ConcurrentMerkleTreeHeader::try_from_slice(header_bytes)?;
header.assert_valid()?;
header.assert_valid_leaf_index(index)?;
let merkle_tree_size = merkle_tree_get_size(&header)?;
let (tree_bytes, canopy_bytes) = rest.split_at(merkle_tree_size);
let mut proof = vec![];
for node in ctx.remaining_accounts.iter() {
proof.push(node.key().to_bytes());
}
fill_in_proof_from_canopy(canopy_bytes, header.get_max_depth(), index, &mut proof)?;
let id = ctx.accounts.merkle_tree.key();
let args = &ProveLeafArgs {
current_root: root,
leaf,
proof_vec: proof,
index,
};
merkle_tree_prove_leaf(&header, id, tree_bytes, args)?;
Ok(())
- It first checks the tree to be owned by the program.
- Validates the header.
- Validates index to be in range
leaf_index < (1 << self.get_max_depth())
. - Gets the tree_bytes and canopy_bytes.
- Gets the proof: proof will be obtained from
remaining_accounts
. - Updates the proof with
canopy
as we said, due to transaction limits, we couldn't pass the full proof in the remaining accounts, so canopy will help here. - Verifies the leaf.
3. mpl-bubblegum
The MPL-bubblegum program's source is available on GitHub.
This program handles the logic for minting, transferring, and burning cNFTs. It works in conjunction with the account-compression
program.
There are two versions for some instructions (v1
and v2
). This post covers v1
.
Difference between v1 and v2:
- Uses MPL Core collections instead of Token Metadata collections.
- Uses the streamlined
MetadataV2
arguments, which eliminate the collection verified flag. InMetadataV2
, any collection included is automatically considered verified.- Allows for use of plugins such as Royalties, Permanent Burn Delegate, etc. on the MPL Core collection to authorize operations on the Bubblegum asset. Note the
BubblegumV2
plugin must also be present on the MPL Core collection for it to be used for Bubblegum. See MPL CoreBubblegumV2
plugin for list of compatible collection-level plugins.- Allows for freezing/thawing of the asset, as well as setting an asset to be permanently non-transferable (soulbound). Non-transferable is similar to freezing but allows the owner to burn the asset, while freezing does not.
- Not available yet, but optionally specify data (and a schema) to be associated with the asset.
3.1 CreateTree
Used to create a new Merkle tree on-chain using account-compression
.
- Accounts
pub struct CreateTree<'info> {
#[account(
init,
seeds = [merkle_tree.key().as_ref()],
payer = payer,
space = TREE_AUTHORITY_SIZE,
bump,
)]
pub tree_authority: Account<'info, TreeConfig>,
/// CHECK: This account must be all zeros
#[account(zero)]
pub merkle_tree: UncheckedAccount<'info>,
#[account(mut)]
pub payer: Signer<'info>,
pub tree_creator: Signer<'info>,
pub log_wrapper: Program<'info, SplNoop>,
pub compression_program: Program<'info, SplAccountCompression>,
pub system_program: Program<'info, System>,
}
- The
tree_authority
is a PDA that is derived bymerkle_tree
.
- Inputs
max_depth: u32,
max_buffer_size: u32,
public: Option<bool>,
- max_depth and max_buffer_size as discussed before.
- public: if anyone can mint new tokens, or only the corresponding authority is allowed.
- Logic
let merkle_tree = ctx.accounts.merkle_tree.to_account_info();
check_canopy_size(
ctx.accounts.merkle_tree.data.borrow(),
ctx.accounts.tree_authority.key(),
max_depth,
max_buffer_size,
)?;
let seed = merkle_tree.key();
let seeds = &[seed.as_ref(), &[ctx.bumps.tree_authority]];
let authority = &mut ctx.accounts.tree_authority;
authority.set_inner(TreeConfig {
tree_creator: ctx.accounts.tree_creator.key(),
tree_delegate: ctx.accounts.tree_creator.key(),
total_mint_capacity: 1 << max_depth,
num_minted: 0,
is_public: public.unwrap_or(false),
is_decompressible: DecompressibleState::Disabled,
version: crate::state::leaf_schema::Version::V1,
});
let authority_pda_signer = &[&seeds[..]];
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.compression_program.to_account_info(),
spl_account_compression::cpi::accounts::Initialize {
authority: ctx.accounts.tree_authority.to_account_info(),
merkle_tree,
noop: ctx.accounts.log_wrapper.to_account_info(),
},
authority_pda_signer,
);
spl_account_compression::cpi::init_empty_merkle_tree(cpi_ctx, max_depth, max_buffer_size)
- Checks canopy size based on merkle_tree and header.
- Initializes the TreeConfig based on
tree_creator
,tree_delegate
,total_mint_capacity
, etc.tree_delegate
is able to mint new tokens as well astree_creator
. - Calls
spl_account_compression
and creates a new empty Merkle tree.
3.2 MintV1
Mints a new cNFT into the tree.
- Accounts
#[derive(Accounts)]
pub struct MintV1<'info> {
#[account(
mut,
seeds = [merkle_tree.key().as_ref()],
bump,
)]
pub tree_authority: Account<'info, TreeConfig>,
/// CHECK: This account is neither written to nor read from.
pub leaf_owner: AccountInfo<'info>,
/// CHECK: This account is neither written to nor read from.
pub leaf_delegate: AccountInfo<'info>,
/// CHECK: This account is modified in the downstream program
#[account(mut)]
pub merkle_tree: UncheckedAccount<'info>,
pub payer: Signer<'info>,
pub tree_delegate: Signer<'info>,
pub log_wrapper: Program<'info, SplNoop>,
pub compression_program: Program<'info, SplAccountCompression>,
pub system_program: Program<'info, System>,
}
tree_creator
: if it's not public, it must be either tree_creator or tree_delegate.tree_authority
that is needed for CPI to account-compression, and the data ofTreeConfig
is needed.leaf_owner
andleaf_delegate
,leaf_delegate
can transfer or burn tokens as well.
- Inputs
message: MetadataArgs
pub struct MetadataArgs {
/// The name of the asset
pub name: String,
/// The symbol for the asset
pub symbol: String,
/// URI pointing to JSON representing the asset
pub uri: String,
/// Royalty basis points that goes to creators in secondary sales (0-10000)
pub seller_fee_basis_points: u16,
/// Immutable, once flipped, all sales of this metadata are considered secondary.
pub primary_sale_happened: bool,
/// Whether or not the data struct is mutable, default is not
pub is_mutable: bool,
/// nonce for easy calculation of editions, if present
pub edition_nonce: Option<u8>,
/// Token standard. Currently only `NonFungible` is allowed.
pub token_standard: Option<TokenStandard>,
/// Collection
pub collection: Option<Collection>,
/// Uses
pub uses: Option<Uses>,
pub token_program_version: TokenProgramVersion,
pub creators: Vec<Creator>,
}
- It's metadata related to the token.
- Logic
let payer = ctx.accounts.payer.key();
let incoming_tree_delegate = ctx.accounts.tree_delegate.key();
let merkle_tree = &ctx.accounts.merkle_tree;
// V1 instructions only work with V1 trees.
let authority = &mut ctx.accounts.tree_authority;
require!(
authority.version == Version::V1,
BubblegumError::UnsupportedSchemaVersion
);
if !authority.is_public {
require!(
incoming_tree_delegate == authority.tree_creator
|| incoming_tree_delegate == authority.tree_delegate,
BubblegumError::TreeAuthorityIncorrect,
);
}
if !authority.contains_mint_capacity(1) {
return Err(BubblegumError::InsufficientMintCapacity.into());
}
// Create a HashSet to store signers to use with creator validation. Any signer can be
// counted as a validated creator.
let mut metadata_auth = HashSet::<Pubkey>::new();
metadata_auth.insert(payer);
metadata_auth.insert(incoming_tree_delegate);
// If there are any remaining accounts that are also signers, they can also be used for
// creator validation.
metadata_auth.extend(
ctx.remaining_accounts
.iter()
.filter(|a| a.is_signer)
.map(|a| a.key()),
);
let leaf = process_mint(
message,
&ctx.accounts.leaf_owner,
Some(&ctx.accounts.leaf_delegate),
metadata_auth,
ctx.bumps.tree_authority,
authority,
merkle_tree,
&ctx.accounts.log_wrapper,
&ctx.accounts.compression_program,
false,
)?;
authority.increment_mint_count();
Ok(leaf)
- Checks the version.
- Checks the authority if it's not public.
- Checks mint capacity.
- Does some metadata stuff.
- Calls process_mint (important part).
- Increments the number of minted tokens.
process_mint
:
let asset_id = get_asset_id(&merkle_tree.key(), tree_authority.num_minted);
solana_program::msg!("Leaf asset ID: {}", asset_id);
let leaf_delegate = leaf_delegate.unwrap_or(leaf_owner);
let version = message.version();
let leaf = match version {
Version::V1 => LeafSchema::new_v1(
asset_id,
leaf_owner.key(),
leaf_delegate.key(),
tree_authority.num_minted,
data_hash.to_bytes(),
creator_hash.to_bytes(),
),
Version::V2 => {
let collection_hash = hash_collection_option(message.collection_key())?;
LeafSchema::new_v2(
asset_id,
leaf_owner.key(),
leaf_delegate.key(),
tree_authority.num_minted,
data_hash.to_bytes(),
creator_hash.to_bytes(),
collection_hash,
DEFAULT_ASSET_DATA_HASH,
DEFAULT_FLAGS,
)
}
};
crate::utils::wrap_application_data_v1(version, leaf.to_event().try_to_vec()?, wrapper)?;
append_leaf(
version,
&merkle_tree.key(),
authority_bump,
&compression_program.to_account_info(),
&tree_authority.to_account_info(),
&merkle_tree.to_account_info(),
&wrapper.to_account_info(),
leaf.to_node(),
)?;
As each NFT must have a unique ID, it calculates asset_id
based on the Merkle tree and the index of the token. Note that num_minted
is increased every time we mint a new token and will not decrease in the burn process.
- Creates a leaf based on version, and then appends that leaf to the Merkle tree.
Optimisation on validating the rights on NFTs
As we've seen, no new account is created on-chain. Previously, we needed two accounts to own an NFT.
So how does Bubblegum do this and check for cNFT
owners and other data?
Whenever new CNFTs are minted, no new account is created; we just calculate the leaf based on the needed data and insert the leaf.
Whenever users want to transfer or do actions on their NFT, no validation done here and as saw no validation done on account-compression as well, the only check that is done is that leaf must be present in tree, so Bubblegum do it smartly, insead of getting the leaf from user and do validation on owner it calculate the leaf each time, as a result if someone wants to transfer other users token, the leaf will be calcuated wrongly, so only when the right authority calls trasnfer the currect leaf is calcuated. Check the Burn
instruction as an example.
3.3 Burn
Burns a cNFT by replacing its Merkle leaf with an empty one.
The owner or delegate must sign the transaction.
- Accounts
pub struct Burn<'info> {
#[account(
seeds = [merkle_tree.key().as_ref()],
bump,
)]
pub tree_authority: Account<'info, TreeConfig>,
/// CHECK: This account is checked in the instruction
pub leaf_owner: UncheckedAccount<'info>,
/// CHECK: This account is checked in the instruction
pub leaf_delegate: UncheckedAccount<'info>,
/// CHECK: This account is modified in the downstream program
#[account(mut)]
pub merkle_tree: UncheckedAccount<'info>,
pub log_wrapper: Program<'info, SplNoop>,
pub compression_program: Program<'info, SplAccountCompression>,
pub system_program: Program<'info, System>,
}
- As we said, the leaf will be calculated again, so we will need
merkle_tree
,leaf_owner
orleaf_delegate
(one of them must be the signer).
- Inputs
root: [u8; 32],
data_hash: [u8; 32],
creator_hash: [u8; 32],
nonce: u64,
index: u32,
- Root for verification, data_hash, creator_hash, nonce, and index for leaf calculation, nonce is the index of the leaf.
- Logic
// V1 instructions only work with V1 trees.
require!(
ctx.accounts.tree_authority.version == Version::V1,
BubblegumError::UnsupportedSchemaVersion
);
let owner = ctx.accounts.leaf_owner.to_account_info();
let delegate = ctx.accounts.leaf_delegate.to_account_info();
// Burn must be initiated by either the leaf owner or leaf delegate.
require!(
owner.is_signer || delegate.is_signer,
BubblegumError::LeafAuthorityMustSign
);
let merkle_tree = ctx.accounts.merkle_tree.to_account_info();
let asset_id = get_asset_id(&merkle_tree.key(), nonce);
let previous_leaf = LeafSchema::new_v1(
asset_id,
owner.key(),
delegate.key(),
nonce,
data_hash,
creator_hash,
);
let new_leaf = Node::default();
replace_leaf(
Version::V1,
&merkle_tree.key(),
ctx.bumps.tree_authority,
&ctx.accounts.compression_program.to_account_info(),
&ctx.accounts.tree_authority.to_account_info(),
&ctx.accounts.merkle_tree.to_account_info(),
&ctx.accounts.log_wrapper.to_account_info(),
ctx.remaining_accounts,
root,
previous_leaf.to_node(),
new_leaf,
index,
)
- Checks the version.
- Check that either the owner or delegate must be a signer(leaf will be calculated based on these).
- Gets asset ID.
- Calculates
previous_leaf
. - Calls replace_leaf on account compression.
3.4 Transfer
Similar to burn
, but instead replaces the leaf with a new one assigned to the new owner.
As a quick look:
- It will get the
new_leaf_owner
account. - Does some validation
- Calculates previous_leaf and new_leaf
- Calls replace_leaf
3.5 Redeem
cNFTs are capable of being redeemed from the Merkle tree and existing on-chain. For this purpose, the NFT must first be redeemed, then decompressed.
Prepares a cNFT to be transferred from the compressed Merkle tree to an on-chain NFT.
- Accounts
pub struct Redeem<'info> {
#[account(
seeds = [merkle_tree.key().as_ref()],
bump,
)]
pub tree_authority: Account<'info, TreeConfig>,
#[account(mut)]
pub leaf_owner: Signer<'info>,
/// CHECK: This account is checked in the instruction
pub leaf_delegate: UncheckedAccount<'info>,
/// CHECK: This account is modified in the downstream program
#[account(mut)]
pub merkle_tree: UncheckedAccount<'info>,
#[account(
init,
seeds = [
VOUCHER_PREFIX.as_ref(),
merkle_tree.key().as_ref(),
& nonce.to_le_bytes()
],
payer = leaf_owner,
space = VOUCHER_SIZE,
bump
)]
pub voucher: Account<'info, Voucher>,
pub log_wrapper: Program<'info, SplNoop>,
pub compression_program: Program<'info, SplAccountCompression>,
pub system_program: Program<'info, System>,
}
- Only the owner can redeem the NFT, so unlike previous ones,
leaf_owner
must be a signer. voucher
: this is an account that, when initialised, means the NFT is in a redeem state and could be cancelled or decompressed.
- Inputs
root: [u8; 32],
data_hash: [u8; 32],
creator_hash: [u8; 32],
nonce: u64,
index: u32,
- Root and data for calculating the leaf.
- Logic
// V1 instructions only work with V1 trees.
require!(
ctx.accounts.tree_authority.version == Version::V1,
BubblegumError::UnsupportedSchemaVersion
);
if ctx.accounts.tree_authority.is_decompressible == DecompressibleState::Disabled {
return Err(BubblegumError::DecompressionDisabled.into());
}
let leaf_owner = ctx.accounts.leaf_owner.key();
let leaf_delegate = ctx.accounts.leaf_delegate.key();
let merkle_tree = ctx.accounts.merkle_tree.to_account_info();
let asset_id = get_asset_id(&merkle_tree.key(), nonce);
let previous_leaf = LeafSchema::new_v1(
asset_id,
leaf_owner,
leaf_delegate,
nonce,
data_hash,
creator_hash,
);
let new_leaf = Node::default();
replace_leaf(
Version::V1,
&merkle_tree.key(),
ctx.bumps.tree_authority,
&ctx.accounts.compression_program.to_account_info(),
&ctx.accounts.tree_authority.to_account_info(),
&ctx.accounts.merkle_tree.to_account_info(),
&ctx.accounts.log_wrapper.to_account_info(),
ctx.remaining_accounts,
root,
previous_leaf.to_node(),
new_leaf,
index,
)?;
ctx.accounts
.voucher
.set_inner(Voucher::new(previous_leaf, index, merkle_tree.key()));
Ok(())
- Same as before: does some validation, calculates leaf, and replaces it with
Node::default()
. - An extra check on
is_decompressible
to be sure that it's capable of decompressing. - Initialises voucher with previous_leaf data.
pub struct Voucher {
pub leaf_schema: LeafSchema,
pub index: u32,
pub merkle_tree: Pubkey,
}
3.6 CancelRedeem
Cancels a redeem action, so same as before; it only closes the voucher and replaces the leaf with the leaf on the voucher.
3.7 DecompressV1
This is the instruction that must be called to transfer cNFTs from off-chain (leaf on Merkle tree) to an on-chain account. It must be called after calling redeem.
- Accounts
#[derive(Accounts)]
pub struct DecompressV1<'info> {
#[account(
mut,
close = leaf_owner,
seeds = [
VOUCHER_PREFIX.as_ref(),
voucher.merkle_tree.as_ref(),
voucher.leaf_schema.nonce().to_le_bytes().as_ref()
],
bump
)]
pub voucher: Box<Account<'info, Voucher>>,
#[account(mut)]
pub leaf_owner: Signer<'info>,
/// CHECK: versioning is handled in the instruction
#[account(mut)]
pub token_account: UncheckedAccount<'info>,
/// CHECK: versioning is handled in the instruction
#[account(
mut,
seeds = [
ASSET_PREFIX.as_ref(),
voucher.merkle_tree.as_ref(),
voucher.leaf_schema.nonce().to_le_bytes().as_ref(),
],
bump
)]
pub mint: UncheckedAccount<'info>,
/// CHECK:
#[account(
mut,
seeds = [mint.key().as_ref()],
bump,
)]
pub mint_authority: UncheckedAccount<'info>,
/// CHECK:
#[account(mut)]
pub metadata: UncheckedAccount<'info>,
/// CHECK: Initialized in Token Metadata Program
#[account(mut)]
pub master_edition: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
pub sysvar_rent: Sysvar<'info, Rent>,
/// CHECK:
pub token_metadata_program: Program<'info, MplTokenMetadata>,
pub token_program: Program<'info, Token>,
pub associated_token_program: Program<'info, AssociatedToken>,
pub log_wrapper: Program<'info, SplNoop>,
}
voucher
: Previously initialised voucher account used to validate decompression. It will be closed at the end of this instruction to reclaim rent.leaf_owner
: Must be a signer. Represents the rightful owner of the compressed asset. Note:leaf_delegate
is not authorised to perform this action.token_account
: The associated token account for theleaf_owner
andmint
. It will be created if it doesn’t exist.mint
: A PDA derived from the Merkle tree and the asset’s nonce. Serves as the unique on-chain mint for the decompressed NFT.mint_authority
: A PDA used to sign minting and metadata instructions. Temporarily assigned to your program, and reassigned to the System Program afterwards to lock it down.metadata
: The Metadata account created via the Token Metadata program. Stores on-chain NFT data such as name, symbol, URI, creators, and collection info.master_edition
: The Master Edition account for the NFT, indicating it’s a non-fungible asset (1-of-1 or limited edition). Required by most marketplaces to validate the asset’s uniqueness and metadata.- Required programs.
- Inputs
metadata: MetadataArgs
- Only the required metadata.
- Logic
The logic is straightforward but quite long.
// Validate the incoming metadata
let incoming_data_hash = hash_metadata(&metadata)?;
if !cmp_bytes(
&ctx.accounts.voucher.leaf_schema.data_hash(),
&incoming_data_hash,
32,
) {
return Err(BubblegumError::HashingMismatch.into());
}
if !cmp_pubkeys(
&ctx.accounts.voucher.leaf_schema.owner(),
ctx.accounts.leaf_owner.key,
) {
return Err(BubblegumError::AssetOwnerMismatch.into());
}
let voucher = &ctx.accounts.voucher;
match metadata.token_program_version {
TokenProgramVersion::Original => {
if ctx.accounts.mint.data_is_empty() {
invoke_signed(
&system_instruction::create_account(
&ctx.accounts.leaf_owner.key(),
&ctx.accounts.mint.key(),
Rent::get()?.minimum_balance(Mint::LEN),
Mint::LEN as u64,
&spl_token::id(),
),
&[
ctx.accounts.leaf_owner.to_account_info(),
ctx.accounts.mint.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
&[&[
ASSET_PREFIX.as_bytes(),
voucher.merkle_tree.key().as_ref(),
voucher.leaf_schema.nonce().to_le_bytes().as_ref(),
&[ctx.bumps.mint],
]],
)?;
invoke(
&spl_token::instruction::initialize_mint2(
&spl_token::id(),
&ctx.accounts.mint.key(),
&ctx.accounts.mint_authority.key(),
Some(&ctx.accounts.mint_authority.key()),
0,
)?,
&[
ctx.accounts.token_program.to_account_info(),
ctx.accounts.mint.to_account_info(),
],
)?;
}
if ctx.accounts.token_account.data_is_empty() {
invoke(
&spl_associated_token_account::instruction::create_associated_token_account(
&ctx.accounts.leaf_owner.key(),
&ctx.accounts.leaf_owner.key(),
&ctx.accounts.mint.key(),
&spl_token::ID,
),
&[
ctx.accounts.leaf_owner.to_account_info(),
ctx.accounts.mint.to_account_info(),
ctx.accounts.token_account.to_account_info(),
ctx.accounts.token_program.to_account_info(),
ctx.accounts.associated_token_program.to_account_info(),
ctx.accounts.system_program.to_account_info(),
ctx.accounts.sysvar_rent.to_account_info(),
],
)?;
}
// SPL token will check that the associated token account is initialized, that it
// has the correct owner, and that the mint (which is a PDA of this program)
// matches.
invoke_signed(
&spl_token::instruction::mint_to(
&spl_token::id(),
&ctx.accounts.mint.key(),
&ctx.accounts.token_account.key(),
&ctx.accounts.mint_authority.key(),
&[],
1,
)?,
&[
ctx.accounts.mint.to_account_info(),
ctx.accounts.token_account.to_account_info(),
ctx.accounts.mint_authority.to_account_info(),
ctx.accounts.token_program.to_account_info(),
],
&[&[
ctx.accounts.mint.key().as_ref(),
&[ctx.bumps.mint_authority],
]],
)?;
}
TokenProgramVersion::Token2022 => return Err(ProgramError::InvalidArgument.into()),
}
msg!("Creating metadata");
CreateMetadataAccountV3CpiBuilder::new(&ctx.accounts.token_metadata_program)
.metadata(&ctx.accounts.metadata)
.mint(&ctx.accounts.mint)
.mint_authority(&ctx.accounts.mint_authority)
.payer(&ctx.accounts.leaf_owner)
.update_authority(&ctx.accounts.mint_authority, true)
.system_program(&ctx.accounts.system_program)
.data(DataV2 {
name: metadata.name.clone(),
symbol: metadata.symbol.clone(),
uri: metadata.uri.clone(),
creators: if metadata.creators.is_empty() {
None
} else {
Some(metadata.creators.iter().map(|c| c.adapt()).collect())
},
collection: metadata.collection.map(|c| c.adapt()),
seller_fee_basis_points: metadata.seller_fee_basis_points,
uses: metadata.uses.map(|u| u.adapt()),
})
.is_mutable(metadata.is_mutable)
.invoke_signed(&[&[
ctx.accounts.mint.key().as_ref(),
&[ctx.bumps.mint_authority],
]])?;
msg!("Creating master edition");
CreateMasterEditionV3CpiBuilder::new(&ctx.accounts.token_metadata_program)
.edition(&ctx.accounts.master_edition)
.mint(&ctx.accounts.mint)
.mint_authority(&ctx.accounts.mint_authority)
.update_authority(&ctx.accounts.mint_authority)
.metadata(&ctx.accounts.metadata)
.payer(&ctx.accounts.leaf_owner)
.system_program(&ctx.accounts.system_program)
.token_program(&ctx.accounts.token_program)
.max_supply(0)
.invoke_signed(&[&[
ctx.accounts.mint.key().as_ref(),
&[ctx.bumps.mint_authority],
]])?;
ctx.accounts
.mint_authority
.to_account_info()
.assign(&System::id());
Ok(())
- Validates the metadata.
- Validates the owner.
- Only allows the normal token program (not 2022).
- Creates the mint account if it is empty and initialises it with
mint_authority
as authority. - Creates an ATA for the leaf owner and mints the token.
- Assigns
mint_authority
tocrate::id()
, which is needed for creating metadata. Unlike the SPL program, it will check the owner of this PDA, so this step is needed. - Creates metadata.
- Assigns back
mint_authority
toSystem::id()
.
3.8 SetDecompressibleState
This is a small instruction for changing the tree.is_decompressible
state (by default it's false).
As you remember, this variable is checked in the redemption process.
#[derive(Accounts)]
pub struct SetDecompressibleState<'info> {
#[account(mut, has_one = tree_creator)]
pub tree_authority: Account<'info, TreeConfig>,
pub tree_creator: Signer<'info>,
}
pub(crate) fn set_decompressible_state(
ctx: Context<SetDecompressibleState>,
decompressable_state: DecompressibleState,
) -> Result<()> {
// V1 instructions only work with V1 trees.
require!(
ctx.accounts.tree_authority.version == Version::V1,
BubblegumError::UnsupportedSchemaVersion
);
ctx.accounts.tree_authority.is_decompressible = decompressable_state;
Ok(())
}
3.9 MintToCollectionV1
This instruction is used to mint a new compressed NFT (cNFT) into a Merkle tree and assign it to a verified collection. It supports only Bubblegum V1 Merkle trees and validates the collection using the Token Metadata program.
- Accounts
#[derive(Accounts)]
pub struct MintToCollectionV1<'info> {
#[account(
mut,
seeds = [merkle_tree.key().as_ref()],
bump,
)]
pub tree_authority: Account<'info, TreeConfig>,
/// CHECK: This account is neither written to nor read from.
pub leaf_owner: AccountInfo<'info>,
/// CHECK: This account is neither written to nor read from.
pub leaf_delegate: AccountInfo<'info>,
/// CHECK: This account is modified in the downstream program
#[account(mut)]
pub merkle_tree: UncheckedAccount<'info>,
pub payer: Signer<'info>,
pub tree_delegate: Signer<'info>,
pub collection_authority: Signer<'info>,
/// CHECK: Optional collection authority record PDA. If not present, must be the Bubblegum program address.
pub collection_authority_record_pda: UncheckedAccount<'info>,
/// CHECK: Checked inside the downstream logic
pub collection_mint: UncheckedAccount<'info>,
#[account(mut)]
pub collection_metadata: Box<Account<'info, TokenMetadata>>,
/// CHECK: Checked inside the downstream logic
pub edition_account: UncheckedAccount<'info>,
/// CHECK: Legacy; not used but kept for compatibility
pub bubblegum_signer: UncheckedAccount<'info>,
pub log_wrapper: Program<'info, Noop>,
pub compression_program: Program<'info, SplAccountCompression>,
/// CHECK: Legacy; not used but kept for compatibility
pub token_metadata_program: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
tree_authority
: The authority for the Merkle tree. Contains permissions and minting capacity.leaf_owner
,leaf_delegate
: The future owner of the compressed NFT.merkle_tree
: The Merkle tree account that stores the compressed NFT leaves.payer
: Pays for any instruction overhead. Also counted as a creator authority if included in metadata.tree_delegate
: Signer authorised to mint to the Merkle tree, must matchtree_delegate
ortree_creator
.collection_authority
: The signer that verifies the collection (must be approved for the collection).collection_authority_record_pda
: Optional PDA proving delegated authority over the collection.collection_mint
: The mint account for the verified collection.collection_metadata
: Account holding on-chain collection metadata.edition_account
: Edition account used to verify the collection.- Required programs.
- Inputs
metadata_args: MetadataArgs
- Required metadata.
- Logic
The logic performs multiple validations and processes the mint:
// V1 instructions only work with V1 trees.
require!(
ctx.accounts.tree_authority.version == Version::V1,
BubblegumError::UnsupportedSchemaVersion
);
let mut message = metadata_args;
let payer = ctx.accounts.payer.key();
let incoming_tree_delegate = ctx.accounts.tree_delegate.key();
let authority = &mut ctx.accounts.tree_authority;
let merkle_tree = &ctx.accounts.merkle_tree;
let collection_metadata = &ctx.accounts.collection_metadata;
let collection_mint = ctx.accounts.collection_mint.to_account_info();
let edition_account = ctx.accounts.edition_account.to_account_info();
let collection_authority = ctx.accounts.collection_authority.to_account_info();
let collection_authority_record_pda = ctx
.accounts
.collection_authority_record_pda
.to_account_info();
if !authority.is_public {
require!(
incoming_tree_delegate == authority.tree_creator
|| incoming_tree_delegate == authority.tree_delegate,
BubblegumError::TreeAuthorityIncorrect,
);
}
if !authority.contains_mint_capacity(1) {
return Err(BubblegumError::InsufficientMintCapacity.into());
}
// Create a HashSet to store signers to use with creator validation. Any signer can be
// counted as a validated creator.
let mut metadata_auth = HashSet::<Pubkey>::new();
metadata_auth.insert(payer);
metadata_auth.insert(incoming_tree_delegate);
// If there are any remaining accounts that are also signers, they can also be used for
// creator validation.
metadata_auth.extend(
ctx.remaining_accounts
.iter()
.filter(|a| a.is_signer)
.map(|a| a.key()),
);
let collection = message
.collection
.as_mut()
.ok_or(BubblegumError::CollectionNotFound)?;
collection.verified = true;
process_collection_verification_mpl_only(
collection_metadata,
&collection_mint,
&collection_authority,
&collection_authority_record_pda,
&edition_account,
collection,
)?;
let leaf = process_mint(
message,
&ctx.accounts.leaf_owner,
Some(&ctx.accounts.leaf_delegate),
metadata_auth,
ctx.bumps.tree_authority,
authority,
merkle_tree,
&ctx.accounts.log_wrapper,
&ctx.accounts.compression_program,
true,
)?;
authority.increment_mint_count();
Ok(leaf)
- Validates tree version.
- Checks mint permissions, if it's public or not.
- Checks that the collection is present in the metadata and sets it to
verified
. - Uses the Token Metadata program logic to verify the collection.
- Processes the mint as before.
3.10 Delegate
This is the process that you could delegate your cNFT
.
Setting a new leaf_delegate
that can transfer or burn the nft.
- Accounts
#[derive(Accounts)]
pub struct Delegate<'info> {
#[account(
seeds = [merkle_tree.key().as_ref()],
bump,
)]
/// CHECK: This account is neither written to nor read from.
pub tree_authority: Account<'info, TreeConfig>,
pub leaf_owner: Signer<'info>,
/// CHECK: This account is neither written to nor read from.
pub previous_leaf_delegate: UncheckedAccount<'info>,
/// CHECK: This account is neither written to nor read from.
pub new_leaf_delegate: UncheckedAccount<'info>,
/// CHECK: This account is modified in the downstream program
#[account(mut)]
pub merkle_tree: UncheckedAccount<'info>,
pub log_wrapper: Program<'info, SplNoop>,
pub compression_program: Program<'info, SplAccountCompression>,
pub system_program: Program<'info, System>,
}
leaf_owner
that must be signer(only the owner is able to delegate not delagator).
- Inputs
root: [u8; 32],
data_hash: [u8; 32],
creator_hash: [u8; 32],
nonce: u64,
index: u32,
data needed for the calculation of the leaf.
- Logic
// V1 instructions only work with V1 trees.
require!(
ctx.accounts.tree_authority.version == Version::V1,
BubblegumError::UnsupportedSchemaVersion
);
let merkle_tree = ctx.accounts.merkle_tree.to_account_info();
let owner = ctx.accounts.leaf_owner.key();
let previous_delegate = ctx.accounts.previous_leaf_delegate.key();
let new_delegate = ctx.accounts.new_leaf_delegate.key();
let asset_id = get_asset_id(&merkle_tree.key(), nonce);
let previous_leaf = LeafSchema::new_v1(
asset_id,
owner,
previous_delegate,
nonce,
data_hash,
creator_hash,
);
let new_leaf = LeafSchema::new_v1(
asset_id,
owner,
new_delegate,
nonce,
data_hash,
creator_hash,
);
crate::utils::wrap_application_data_v1(
Version::V1,
new_leaf.to_event().try_to_vec()?,
&ctx.accounts.log_wrapper,
)?;
replace_leaf(
Version::V1,
&merkle_tree.key(),
ctx.bumps.tree_authority,
&ctx.accounts.compression_program.to_account_info(),
&ctx.accounts.tree_authority.to_account_info(),
&ctx.accounts.merkle_tree.to_account_info(),
&ctx.accounts.log_wrapper.to_account_info(),
ctx.remaining_accounts,
root,
previous_leaf.to_node(),
new_leaf.to_node(),
index,
)
- Validates tree version.
- Calculate the previous leaf.
- Calculate the new leaf; the only difference is
new_delegate
. - Checks that the collection is present in the metadata and sets it to
verified
. - Call replace leaf on account-compression.
3.11 SetTreeDelegate
Set a new tree_delegate
for a tree; tree_delegate
can mint new NFTs.
#[derive(Accounts)]
pub struct SetTreeDelegate<'info> {
#[account(
mut,
seeds = [merkle_tree.key().as_ref()],
bump,
has_one = tree_creator
)]
pub tree_authority: Account<'info, TreeConfig>,
pub tree_creator: Signer<'info>,
/// CHECK: this account is neither read from or written to
pub new_tree_delegate: UncheckedAccount<'info>,
/// CHECK: Used to derive `tree_authority`
pub merkle_tree: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
pub(crate) fn set_tree_delegate(ctx: Context<SetTreeDelegate>) -> Result<()> {
ctx.accounts.tree_authority.tree_delegate = ctx.accounts.new_tree_delegate.key();
Ok(())
}
Conclusion
Compressed NFTs (cNFTs) offer a scalable, cost-effective way to manage large volumes of NFTs on Solana, especially for use cases like gaming, collectables, and ticketing. By leveraging Merkle trees and off-chain data storage, developers can drastically reduce the number of on-chain accounts, saving on fees and improving efficiency.
The combination of account-compression and mpl-bubblegum provides a powerful toolkit for creating, managing, and interacting with cNFTs, without compromising on validation or ownership security.
Whether you're building a high-throughput dApp or auditing an existing system, understanding how these programs work under the hood gives you the edge to optimise and innovate.
If you’ve made it this far, you now have a solid grasp of how cNFTs work on Solana. The future of NFTs is not just about art, it’s about scale. And cNFTs make that scale possible.