
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!😉
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:
Both are managed under the SPL Token program.
For 100 NFTs, you'll need:
That’s 200 accounts in total, each with its own public key and rent deposit. Especially the cost of rent quickly adds up.
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.
The Account Compression Program's source is available on GitHub.
The idea is simple:
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.
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. (For more on noop logging and its compute cost advantages, see Tip #11 in our 100 Solana tips.)
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.
This is the core of Merkle tree management. It includes instructions to:
Let’s briefly summarise the key instructions:
Initialises a new empty Merkle tree.
/// 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>,
}
pub fn init_empty_merkle_tree(
ctx: Context<Initialize>,
max_depth: u32,
max_buffer_size: u32,
) -> Result<()> {
This data must be calculated beforehand, depending on need.
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)
}
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.
merkle_tree_bytes for the Merkle tree, and the other part (rest-merkletreesize) will be used for canopy_bytes.spl-concurrent-merkle-tree and wrap_event for initialisation.change_log_event. We will send this data to the noop program so the indexer will get this data and make changes off-chain.canopy_bytes?
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.
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(())
}
Closes an unused Merkle tree and returns the rent (lamports) to the recipient.
#[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>,
}
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(())
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.
#[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>,
}
leaf: [u8; 32]
LeafSchema::new_v1(
asset_id,
leaf_owner.key(),
leaf_delegate.key(),
nonce,
data_hash,
creator_hash,
);
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,
)
Tries to insert a leaf at a specific index. If it fails, it appends it.
root: [u8; 32],
leaf: [u8; 32],
index: u32,
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,
)
leaf_index < (1 << self.get_max_depth()).remaining_accounts.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.merkle_tree_append_leaf as a change_log_event, we pass merkle_tree_fill_empty_or_append.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.
root: [u8; 32],
previous_leaf: [u8; 32],
new_leaf: [u8; 32],
index: u32,
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:
leaf_index < (1 << self.get_max_depth()).remaining_accounts.canopy as we said, due to transaction limits, we couldn't pass the full proof in the remaining accounts, so canopy will help here.merkle_tree_append_leaf as a change_log_event, we pass merkle_tree_set_leaf.Verifies if a given leaf exists in the Merkle tree.
pub struct VerifyLeaf<'info> {
/// CHECK: This account is validated in the instruction
pub merkle_tree: UncheckedAccount<'info>,
}
root: [u8; 32],
leaf: [u8; 32],
index: u32,
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(())
leaf_index < (1 << self.get_max_depth()).remaining_accounts.canopy as we said, due to transaction limits, we couldn't pass the full proof in the remaining accounts, so canopy will help here.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
MetadataV2arguments, 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
BubblegumV2plugin must also be present on the MPL Core collection for it to be used for Bubblegum. See MPL CoreBubblegumV2plugin 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.
Used to create a new Merkle tree on-chain using account-compression.
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>,
}
tree_authority is a PDA that is derived by merkle_tree. max_depth: u32,
max_buffer_size: u32,
public: Option<bool>,
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)
tree_creator, tree_delegate, total_mint_capacity, etc.
tree_delegate is able to mint new tokens as well as tree_creator.spl_account_compression and creates a new empty Merkle tree.Mints a new cNFT into the tree.
#[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 of TreeConfig is needed.leaf_owner and leaf_delegate, leaf_delegate can transfer or burn tokens as well.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>,
}
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)
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.
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.
Burns a cNFT by replacing its Merkle leaf with an empty one.
The owner or delegate must sign the transaction.
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>,
}
merkle_tree, leaf_owner or leaf_delegate(one of them must be the signer). root: [u8; 32],
data_hash: [u8; 32],
creator_hash: [u8; 32],
nonce: u64,
index: u32,
// 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,
)
previous_leaf.Similar to burn, but instead replaces the leaf with a new one assigned to the new owner.
As a quick look:
new_leaf_owner account.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.
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>,
}
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. root: [u8; 32],
data_hash: [u8; 32],
creator_hash: [u8; 32],
nonce: u64,
index: u32,
// 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(())
Node::default().is_decompressible to be sure that it's capable of decompressing.pub struct Voucher {
pub leaf_schema: LeafSchema,
pub index: u32,
pub merkle_tree: Pubkey,
}
Cancels a redeem action, so same as before; it only closes the voucher and replaces the leaf with the leaf on the voucher.
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.
#[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 the leaf_owner and mint. 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. metadata: MetadataArgs
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(())
mint_authority as authority.mint_authority to crate::id(), which is needed for creating metadata. Unlike the SPL program, it will check the owner of this PDA, so this step is needed.mint_authority to System::id().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(())
}
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.
#[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 match tree_delegate or tree_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.metadata_args: MetadataArgs
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)
verified.This is the process that you could delegate your cNFT.
Setting a new leaf_delegate that can transfer or burn the nft.
#[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). root: [u8; 32],
data_hash: [u8; 32],
creator_hash: [u8; 32],
nonce: u64,
index: u32,
data needed for the calculation of the leaf.
// 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,
)
new_delegate.verified.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(())
}
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. (For specific compute costs of Merkle proof verification and other operations, see our cost of security analysis.)
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're looking to audit a cNFT system, see what a Solana audit costs.
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.