Overview
The Bubblegum Program is a specialized on-chain program developed by the Metaplex Foundation for the Solana blockchain. It is architected to enhance the scalability of Non-Fungible Tokens (NFTs) by leveraging data compression techniques and concurrent merkle tree structures.
The Challenges in NFT Management
As NFTs have surged in popularity, several challenges have emerged, particularly concerning scalability and efficiency:
- On-Chain Data Storage Limitations:
- High Costs: Storing extensive metadata and multiple NFTs directly on-chain can be prohibitively expensive due to Solana's account rent fee.
- Data Redundancy and Inefficiency:
- Repetitive Data: Storing similar or related NFTs separately leads to redundant data, wasting valuable blockchain storage space.
Account Compression and Merkle Trees
The Bubblegum Program addresses the challenge through a combination of SPL Account Compression and Concurrent Merkle Tree structures, offering a scalable and efficient framework for NFT management.
1. SPL Account Compression
The SPL Account Compression program is a Solana Program Library (SPL) that enables the compression of multiple accounts into a single compressed account. This is achieved by organizing data into a Merkle tree structure, where each leaf node represents individual data points—in this case, NFTs.
2. Merkle Trees for NFT Representation
- A Merkle tree is a cryptographic data structure that allows efficient and secure verification of large data sets. Each leaf node represents an individual NFT, while parent nodes represent hashes of their child nodes, culminating in a single root hash that encapsulates the entire tree's state.
- Benefits:
- Integrity Verification: Easily verify the authenticity and integrity of any NFT by traversing the tree from the leaf to the root.
- Space Efficiency: Compresses multiple NFTs into a single account, drastically reducing on-chain storage requirements.
- Enhanced Scalability: Facilitates the management of millions of NFTs without overwhelming the network.
Connection Between Bubblegum and SPL Account Compression
- Dependency: Bubblegum is built on top of the SPL Account Compression program. It relies on its capabilities to compress and manage large sets of NFTs within Merkle trees.
- Enhanced Functionality: While SPL Account Compression provides the tools for data compression and Merkle tree management, Bubblegum extends these tools to offer specialized NFT management features, such as metadata updates, authority verifications, and integration with Metaplex's ecosystem.
- Interoperability: By leveraging SPL Account Compression, Bubblegum ensures compatibility with other Solana programs and tools that utilize the same compression standards, fostering a cohesive and interoperable blockchain environment.
Key Features of the Bubblegum Program
- Compressed NFT Trees:
Organizes NFTs into a scalable Merkle tree structure, enabling efficient storage and management.
- Metadata Handling:
Facilitates seamless updates to NFT metadata, ensuring compliance with Metaplex standards and maintaining data integrity.
- Event Logging:
Integrates with logging mechanisms (e.g., the Noop program) to record events for off-chain indexing and transparency.
Architecture
Create Tree
create_tree
creates a concurrent merkle tree (CMT) which stores cNFTs.It performs the following tasks:
- Validates and configures the Merkle tree’s canopy.
- Initializes a
TreeConfig
account (the PDAtree_authority
) which holds metadata and controls around the tree.
- Uses the
spl_account_compression
program (via CPI) to initialize the empty Merkle tree with given parametersmax_depth
andmax_buffer_size
.
Key inputs:
ctx
: The AnchorContext
holding all the accounts, signers, and programs needed.
max_depth
: The maximum depth of the Merkle tree. This controls how many leaves (and thus how many NFTs, etc.) the tree can hold. The number of leaves in a full binary tree is2^max_depth
.
max_buffer_size
: This controls how many concurrent changes can be buffered for compression.
public
: An optional boolean that, ifSome(true)
, designates the tree as public, meaning anyone can mint cNFT in the tree.
Key accounts:
tree_authority
: A PDA (Program Derived Address) account that will store the tree’s configuration.
merkle_tree
: An account that will be the on-chain representation of the Merkle tree data structure (initially zeroed and will be initialized by the account compression program).
payer
: The account paying for the initialization of these accounts.
tree_creator
: The signer that creates the tree.
log_wrapper
,compression_program
, andsystem_program
: Various programs needed.log_wrapper
is the Noop program (used for logging),compression_program
is the SPL compression program that provides the Merkle tree functionalities, andsystem_program
is the Solana native program for account creation and basic instructions.
/// --- programs/bubblegum/program/src/lib.rs --- /// Creates a new tree. pub fn create_tree( ctx: Context<CreateTree>, max_depth: u32, max_buffer_size: u32, public: Option<bool>, ) -> Result<()> { processor::create_tree(ctx, max_depth, max_buffer_size, public) } /// --- programs/bubblegum/program/src/processor/create_tree.rs --- #[derive(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>, #[account(zero)] /// CHECK: This account must be all zeros pub merkle_tree: UncheckedAccount<'info>, #[account(mut)] pub payer: Signer<'info>, pub tree_creator: Signer<'info>, pub log_wrapper: Program<'info, Noop>, pub compression_program: Program<'info, SplAccountCompression>, pub system_program: Program<'info, System>, }
Steps:
- Validate Merkle Tree Canopy Size
Before the Merkle tree is fully initialized, the code calls
check_canopy_size
to ensure that the canopy size (the portion of the tree stored on-chain for quick verification of proofs) is correct. If the canopy is not appropriately sized, it returns an InvalidCanopySize
error.- Initialize the
TreeConfig
Account tree_creator
andtree_delegate
are set to thetree_creator
key, meaning the same account controls initial creation and delegation.total_mint_capacity
is2^max_depth
.num_minted
starts at 0 because no leaves are minted yet.is_public
sets whether this tree can be used by everyone or is restricted.is_decompressible
is set toDecompressibleState::Disabled
initially, meaning leaves cannot be decompressed off-chain by default.
Initialize the
tree_authority
account with a TreeConfig
struct:- Invoke the SPL Account Compression Program to Initialize the Merkle Tree
- A CPI (Cross-Program Invocation) context is created to call the
init_empty_merkle_tree
function of thespl_account_compression
program. init_empty_merkle_tree
takesmax_depth
andmax_buffer_size
and initializes themerkle_tree
account's data to represent an empty Merkle tree.- This CPI call sets up the initial data structure (nodes, canopy, etc.) that we previously validated.
/// --- programs/bubblegum/program/src/processor/create_tree.rs --- pub(crate) fn create_tree( ctx: Context<CreateTree>, max_depth: u32, max_buffer_size: u32, public: Option<bool>, ) -> Result<()> { let merkle_tree = ctx.accounts.merkle_tree.to_account_info(); /// Validate Merkle Tree Canopy Size check_canopy_size(&ctx, max_depth, max_buffer_size)?; /// Initialize the TreeConfig Account let seed = merkle_tree.key(); let seeds = &[seed.as_ref(), &[*ctx.bumps.get("tree_authority").unwrap()]]; 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, }); /// Invoke the SPL Account Compression Program to Initialize the Merkle Tree 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) }
Check Canopy Size
It checks that the canopy size (the portion of the tree stored on-chain for quick verification of proofs) is correct.
Steps:
- Reading Account Data:
merkle_tree_bytes
is obtained by calling data.borrow()
on the merkle_tree
account. This provides a runtime borrow of the account’s underlying data buffer. Because merkle_tree
is initially zeroed, this will be a slice of zeros before initialization.- Splitting Header and Rest:
header_bytes
and rest
are obtained by slicing the account data. The header of size CONCURRENT_MERKLE_TREE_HEADER_SIZE_V1
is parsed into a ConcurrentMerkleTreeHeader
.- Initialize the Header:
The header is then “initialized” with the provided
max_depth
, max_buffer_size
, and the authority key. This sets fields in the header to expected initial states.- Compute Merkle Tree Size:
merkle_tree_size = merkle_tree_get_size(&header)
computes how much space the actual tree nodes (i.e., leaves and branches) need.- Canopy Extraction:
The remaining bytes after the header and the core tree data are the canopy. The canopy is a set of nodes (hashes) that help speed up proof verification by caching partial paths.
- Check Cached Path Length:
get_cached_path_length
is called to verify the canopy forms a structure consistent with a binary tree missing only the root. It checks if (canopy.len() + 2)
is a power of two, which is a requirement for the canopy structure.- Compare Cached Path with Requirements:
The
required_canopy
is calculated as max_depth - MAX_ACC_PROOFS_SIZE
. The code ensures the cached_path_len
from the canopy is at least required_canopy
. If not, it errors out./// --- programs/bubblegum/program/src/processor/create_tree.rs --- fn check_canopy_size( ctx: &Context<CreateTree>, max_depth: u32, max_buffer_size: u32, ) -> Result<()> { /// Reading Account Data: let merkle_tree_bytes = ctx.accounts.merkle_tree.data.borrow(); /// Splitting Header and Rest: let (header_bytes, rest) = merkle_tree_bytes.split_at(CONCURRENT_MERKLE_TREE_HEADER_SIZE_V1); let mut header = ConcurrentMerkleTreeHeader::try_from_slice(header_bytes)?; /// Initialize the Header: header.initialize( max_depth, max_buffer_size, &ctx.accounts.tree_authority.key(), Clock::get()?.slot, ); /// Compute Merkle Tree Size: let merkle_tree_size = merkle_tree_get_size(&header)?; /// Canopy Extraction: let (_tree_bytes, canopy_bytes) = rest.split_at(merkle_tree_size); let canopy = cast_slice::<u8, Node>(canopy_bytes); /// Check Cached Path Length: let cached_path_len = get_cached_path_length(canopy, max_depth)?; /// Compare Cached Path with Requirements: let required_canopy = max_depth.saturating_sub(MAX_ACC_PROOFS_SIZE); require!( (cached_path_len as u32) >= required_canopy, BubblegumError::InvalidCanopySize ); Ok(()) } /// --- spl-account-compression-0.2.0/src/state/concurrent_merkle_tree_header.rs --- impl ConcurrentMerkleTreeHeader { pub fn initialize( &mut self, max_depth: u32, max_buffer_size: u32, authority: &Pubkey, creation_slot: u64, ) { self.account_type = CompressionAccountType::ConcurrentMerkleTree; match self.header { ConcurrentMerkleTreeHeaderData::V1(ref mut header) => { // Double check header is empty after deserialization from zero'd bytes assert_eq!(header.max_buffer_size, 0); assert_eq!(header.max_depth, 0); header.max_buffer_size = max_buffer_size; header.max_depth = max_depth; header.authority = *authority; header.creation_slot = creation_slot; } } } /// ... }
Mint
The
mint_v1
instruction mints a new compressed NFT (a “leaf” in a Merkle tree). It:- Ensures the caller has the right authority to mint if the tree is not public.
- Checks that the tree has enough capacity to mint another asset.
- Validates the provided metadata (e.g., name, symbol, URI, creators).
- Hashes the metadata and creators to produce the on-chain leaf data.
- Records the asset’s data via a CPI (Cross-Program Invocation) to a
Noop
program for permanent event logging.
- Updates the Merkle tree on-chain by appending a new leaf via the
spl_account_compression
program.
- Increments the minted count in the tree’s configuration.
Context:
tree_authority
: A PDA (Program Derived Address) that stores the state of the Merkle tree (e.g., how many items minted, who can mint, etc.).
leaf_owner
: The owner of the newly minted NFT leaf.
leaf_delegate
: A delegate allowed to act on behalf of the owner.
merkle_tree
: The on-chain account representing the compressed Merkle tree structure.
payer
: The signer paying for the transaction.
tree_delegate
: A signer that acts as a delegate (in addition to or instead of the tree’s creator) if the tree is not public.
log_wrapper
andcompression_program
: Programs used to record logs and modify the Merkle tree.
system_program
: The standard Solana system program used for basic operations like creating accounts.
/// --- programs/bubblegum/program/src/lib.rs --- /// Mints a new asset. pub fn mint_v1(ctx: Context<MintV1>, message: MetadataArgs) -> Result<LeafSchema> { processor::mint_v1(ctx, message) } /// --- programs/bubblegum/program/src/processor/mint.rs --- #[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>, #[account(mut)] /// CHECK: unsafe pub merkle_tree: UncheckedAccount<'info>, pub payer: Signer<'info>, pub tree_delegate: Signer<'info>, pub log_wrapper: Program<'info, Noop>, pub compression_program: Program<'info, SplAccountCompression>, pub system_program: Program<'info, System>, } /// --- programs/bubblegum/program/src/state/metaplex_adapter.rs --- #[derive(AnchorSerialize, AnchorDeserialize, PartialEq, Eq, Debug, Clone)] 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>, /// Since we cannot easily change Metadata, we add the new DataV2 fields here at the end. pub token_standard: Option<TokenStandard>, /// Collection pub collection: Option<Collection>, /// Uses pub uses: Option<Uses>, pub token_program_version: TokenProgramVersion, pub creators: Vec<Creator>, }
Steps:
- Access Control:
If the tree is not public, ensure the signer (
incoming_tree_delegate
) who initiated the mint matches the recorded tree_creator
or tree_delegate
.- Capacity Check:
Call
authority.contains_mint_capacity(1)
to ensure at least one more asset can be minted.- Metadata Auth Set:
Create a HashSet (
metadata_auth
) of all keys that can potentially verify creators. This includes the payer, the incoming tree delegate, and any remaining signer accounts. A creator is considered verified if their public key is in this set.- Call
process_mint_v1
to Mint cNFT: Pass all relevant info toprocess_mint_v1
: - The
message
(metadata about the asset), - Owner, delegate,
- The set of metadata_auth keys,
- The authority bump for signing PDAs,
- The
authority
andmerkle_tree
references, - The
log_wrapper
(for logging data), - The
compression_program
(for modifying the Merkle tree), false
forallow_verified_collection
(meaning we do not allow verified collections here).
- Increment the Mint Count:
If
process_mint_v1
succeeds, increment authority.num_minted
to reflect the new minted asset.- Return the Leaf:
Return the newly created
LeafSchema
instance representing the minted asset./// --- programs/bubblegum/program/src/processor/mint.rs --- pub(crate) fn mint_v1(ctx: Context<MintV1>, message: MetadataArgs) -> Result<LeafSchema> { // TODO -> Separate V1 / V1 into seperate instructions let payer = ctx.accounts.payer.key(); let incoming_tree_delegate = ctx.accounts.tree_delegate.key(); let owner = ctx.accounts.leaf_owner.key(); let delegate = ctx.accounts.leaf_delegate.key(); let authority = &mut ctx.accounts.tree_authority; let merkle_tree = &ctx.accounts.merkle_tree; /// Access Control: if !authority.is_public { require!( incoming_tree_delegate == authority.tree_creator || incoming_tree_delegate == authority.tree_delegate, BubblegumError::TreeAuthorityIncorrect, ); } /// Capacity Check: if !authority.contains_mint_capacity(1) { return Err(BubblegumError::InsufficientMintCapacity.into()); } /// Metadata Auth Set: // 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()), ); /// Call process_mint_v1 to Mint cNFT: let leaf = process_mint_v1( message, owner, delegate, metadata_auth, *ctx.bumps.get("tree_authority").unwrap(), authority, merkle_tree, &ctx.accounts.log_wrapper, &ctx.accounts.compression_program, false, )?; /// Increment the Mint Count: authority.increment_mint_count(); /// Return the Leaf: Ok(leaf) } /// --- programs/bubblegum/program/src/state/mod.rs --- impl TreeConfig { pub fn increment_mint_count(&mut self) { self.num_minted = self.num_minted.saturating_add(1); } pub fn contains_mint_capacity(&self, requested_capacity: u64) -> bool { let remaining_mints = self.total_mint_capacity.saturating_sub(self.num_minted); requested_capacity <= remaining_mints } }
Process Mint
It handles the core logic of producing the leaf data and appending it to the Merkle tree:
Steps:
- Metadata Validation:
assert_metadata_is_mpl_compatible(&message)
ensures the metadata fits Metaplex limits (name, symbol, URI lengths, creator counts, total creator share = 100).- Collection Rules:
If
allow_verified_collection
is false and the provided message
has a verified collection, return an error.- Token Standard Check:
assert_metadata_token_standard(&message)
ensures the metadata’s token standard is NonFungible
, rejecting anything else.- Hashing Metadata:
metadata_args_hash
: a keccak hash of the entireMetadataArgs
.data_hash
: a keccak hash derived frommetadata_args_hash
plus the seller fee basis points.
Two hashes are computed:
- Verifying and Hashing Creators:
- If
verified = true
, the creator’s public key must be inmetadata_auth
. - If this check fails, return
CreatorDidNotVerify
.
For each creator:
If all creators pass,
creator_hash
is computed by concatenating each creator’s data and hashing with keccak.- Asset ID Computation:
asset_id
is derived using a PDA with [ASSET_PREFIX, merkle_tree.key(), nonce]
. The nonce here is authority.num_minted
, ensuring each newly minted asset gets a unique ID.- Leaf Schema Construction:
- The
asset_id
owner
delegate
nonce
(the currentnum_minted
count)data_hash
andcreator_hash
reflecting the NFT’s metadata.
A
LeafSchema
structure is created to represent the NFT. It includes:- Logging the Event:
wrap_application_data_v1(leaf.to_event().try_to_vec()?, wrapper)
logs the asset data inside a CPI call to the Noop program. This allows the data to be recorded permanently in transaction logs, ensuring indexers can reconstruct the Merkle tree state later.- Appending the Leaf to the Tree:
Finally,
append_leaf
is called. This is a CPI to spl_account_compression
, which updates the Merkle tree to include the new leaf, thus finalizing the mint process on-chain./// --- programs/bubblegum/program/src/processor/mint.rs --- pub(crate) fn process_mint_v1<'info>( message: MetadataArgs, owner: Pubkey, delegate: Pubkey, metadata_auth: HashSet<Pubkey>, authority_bump: u8, authority: &mut Account<'info, TreeConfig>, merkle_tree: &AccountInfo<'info>, wrapper: &Program<'info, Noop>, compression_program: &AccountInfo<'info>, allow_verified_collection: bool, ) -> Result<LeafSchema> { /// Metadata Validation: assert_metadata_is_mpl_compatible(&message)?; /// Collection Rules: if !allow_verified_collection { if let Some(collection) = &message.collection { if collection.verified { return Err(BubblegumError::CollectionCannotBeVerifiedInThisInstruction.into()); } } } /// Token Standard Check: assert_metadata_token_standard(&message)?; /// Hashing Metadata: // @dev: seller_fee_basis points is encoded twice so that it can be passed to marketplace // instructions, without passing the entire, un-hashed MetadataArgs struct let metadata_args_hash = keccak::hashv(&[message.try_to_vec()?.as_slice()]); let data_hash = keccak::hashv(&[ &metadata_args_hash.to_bytes(), &message.seller_fee_basis_points.to_le_bytes(), ]); /// Verifying and Hashing Creators: // Use the metadata auth to check whether we can allow `verified` to be set to true in the // creator Vec. let creator_data = message .creators .iter() .map(|c| { if c.verified && !metadata_auth.contains(&c.address) { Err(BubblegumError::CreatorDidNotVerify.into()) } else { Ok([c.address.as_ref(), &[c.verified as u8], &[c.share]].concat()) } }) .collect::<Result<Vec<_>>>()?; // Calculate creator hash. let creator_hash = keccak::hashv( creator_data .iter() .map(|c| c.as_slice()) .collect::<Vec<&[u8]>>() .as_ref(), ); /// Asset ID Computation: let asset_id = get_asset_id(&merkle_tree.key(), authority.num_minted); /// Leaf Schema Construction: let leaf = LeafSchema::new_v0( asset_id, owner, delegate, authority.num_minted, data_hash.to_bytes(), creator_hash.to_bytes(), ); /// Logging the Event: wrap_application_data_v1(leaf.to_event().try_to_vec()?, wrapper)?; /// Appending the Leaf to the Tree: append_leaf( &merkle_tree.key(), authority_bump, &compression_program.to_account_info(), &authority.to_account_info(), &merkle_tree.to_account_info(), &wrapper.to_account_info(), leaf.to_node(), )?; Ok(leaf) }
Check Metadata Compatibility with Metaplex
/// --- programs/bubblegum/program/src/asserts.rs --- /// Assert that the provided MetadataArgs are compatible with MPL `Data` pub fn assert_metadata_is_mpl_compatible(metadata: &MetadataArgs) -> Result<()> { if metadata.name.len() > mpl_token_metadata::MAX_NAME_LENGTH { return Err(BubblegumError::MetadataNameTooLong.into()); } if metadata.symbol.len() > mpl_token_metadata::MAX_SYMBOL_LENGTH { return Err(BubblegumError::MetadataSymbolTooLong.into()); } if metadata.uri.len() > mpl_token_metadata::MAX_URI_LENGTH { return Err(BubblegumError::MetadataUriTooLong.into()); } if metadata.seller_fee_basis_points > 10000 { return Err(BubblegumError::MetadataBasisPointsTooHigh.into()); } if !metadata.creators.is_empty() { if metadata.creators.len() > mpl_token_metadata::MAX_CREATOR_LIMIT { return Err(BubblegumError::CreatorsTooLong.into()); } let mut total: u8 = 0; for i in 0..metadata.creators.len() { let creator = &metadata.creators[i]; for iter in metadata.creators.iter().skip(i + 1) { if iter.address == creator.address { return Err(BubblegumError::DuplicateCreatorAddress.into()); } } total = total .checked_add(creator.share) .ok_or(BubblegumError::CreatorShareTotalMustBe100)?; } if total != 100 { return Err(BubblegumError::CreatorShareTotalMustBe100.into()); } } Ok(()) }
Token Standard Check
/// --- programs/bubblegum/program/src/asserts.rs --- /// Assert that the provided MetadataArgs contains info about Token Standard /// and ensures that it's NonFungible pub fn assert_metadata_token_standard(metadata: &MetadataArgs) -> Result<()> { match metadata.token_standard { Some(MetadataTokenStandard::NonFungible) => Ok(()), _ => Err(BubblegumError::InvalidTokenStandard.into()), } }
Calc Asset Id
/// --- programs/bubblegum/program/src/utils.rs --- pub fn get_asset_id(tree_id: &Pubkey, nonce: u64) -> Pubkey { Pubkey::find_program_address( &[ ASSET_PREFIX.as_ref(), tree_id.as_ref(), &nonce.to_le_bytes(), ], &crate::id(), ) .0 }
Leaf Scheme
/// --- programs/bubblegum/program/src/state/leaf_schema.rs --- #[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] pub enum LeafSchema { V1 { id: Pubkey, owner: Pubkey, delegate: Pubkey, nonce: u64, data_hash: [u8; 32], creator_hash: [u8; 32], }, } #[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone, Default)] pub enum Version { #[default] V1, } impl LeafSchema { pub fn new_v0( id: Pubkey, owner: Pubkey, delegate: Pubkey, nonce: u64, data_hash: [u8; 32], creator_hash: [u8; 32], ) -> Self { Self::V1 { id, owner, delegate, nonce, data_hash, creator_hash, } } pub fn version(&self) -> Version { match self { LeafSchema::V1 { .. } => Version::V1, } } pub fn to_node(&self) -> Node { let hashed_leaf = match self { LeafSchema::V1 { id, owner, delegate, nonce, data_hash, creator_hash, } => keccak::hashv(&[ &[self.version().to_bytes()], id.as_ref(), owner.as_ref(), delegate.as_ref(), nonce.to_le_bytes().as_ref(), data_hash.as_ref(), creator_hash.as_ref(), ]) .to_bytes(), }; hashed_leaf } /// ... }
Append Leaf
/// --- programs/bubblegum/program/src/utils.rs --- pub fn append_leaf<'info>( seed: &Pubkey, bump: u8, compression_program: &AccountInfo<'info>, authority: &AccountInfo<'info>, merkle_tree: &AccountInfo<'info>, log_wrapper: &AccountInfo<'info>, leaf_node: Node, ) -> Result<()> { let seeds = &[seed.as_ref(), &[bump]]; let authority_pda_signer = &[&seeds[..]]; let cpi_ctx = CpiContext::new_with_signer( compression_program.clone(), spl_account_compression::cpi::accounts::Modify { authority: authority.clone(), merkle_tree: merkle_tree.clone(), noop: log_wrapper.clone(), }, authority_pda_signer, ); spl_account_compression::cpi::append(cpi_ctx, leaf_node) }
Mint to Collection
mint_to_collection_v1
allows minting a new compressed NFT asset (a leaf in a Merkle tree) that is directly associated with a specific collection. This can be understood as minting an NFT and simultaneously verifying that it belongs to a particular on-chain collection.The function does the following:
- Validates that the caller has permission to mint to this tree.
- Checks that the tree has remaining mint capacity.
- Prepares a set of signer accounts that can verify creators.
- Processes collection verification via Metaplex Token Metadata program constraints.
- Once verified, it proceeds with the minting of the leaf (similar to
mint_v1
) but includes the additional step of ensuring the asset is attached to a valid, verified collection.
Key Accounts and Their Roles:
tree_authority
: PDA that holds the Merkle tree’s configuration (creator, delegate, capacity, etc.).
leaf_owner
: The eventual owner of the minted NFT.
leaf_delegate
: A delegate that can manage the NFT on behalf of the owner.
merkle_tree
: The Merkle tree account that stores the compressed NFT leaves.
payer
: The account paying for the transaction.
tree_delegate
: A signer who must match certain constraints if the tree is not public.
collection_authority
: The signer that has authority over the collection.
collection_authority_record_pda
: Optional PDA that proves the collection_authority’s rights.
collection_mint
,collection_metadata
,edition_account
: Accounts related to the collection NFT, ensuring that the new NFT is part of a valid and verified collection.
/// --- programs/bubblegum/program/src/lib.rs --- /// Mints a new asset and adds it to a collection. pub fn mint_to_collection_v1( ctx: Context<MintToCollectionV1>, metadata_args: MetadataArgs, ) -> Result<LeafSchema> { processor::mint_to_collection_v1(ctx, metadata_args) } /// -- programs/bubblegum/program/src/processor/mint_to_collection.rs --- #[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>, #[account(mut)] /// CHECK: unsafe 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 there is no collecton authority record PDA then /// this must be the Bubblegum program address. pub collection_authority_record_pda: UncheckedAccount<'info>, /// CHECK: This account is checked in the instruction pub collection_mint: UncheckedAccount<'info>, #[account(mut)] pub collection_metadata: Box<Account<'info, TokenMetadata>>, /// CHECK: This account is checked in the instruction pub edition_account: UncheckedAccount<'info>, /// CHECK: This is no longer needed but kept for backwards compatibility. pub bubblegum_signer: UncheckedAccount<'info>, pub log_wrapper: Program<'info, Noop>, pub compression_program: Program<'info, SplAccountCompression>, /// CHECK: This is no longer needed but kept for backwards compatibility. pub token_metadata_program: UncheckedAccount<'info>, pub system_program: Program<'info, System>, }
Steps:
- Extract Accounts
- Authority Check:
If the tree is not public, ensure that the
tree_delegate
signer matches either the stored tree_creator
or tree_delegate
in TreeConfig
.- Capacity Check:
Check if the tree has at least one remaining mint slot.
- Metadata Auth Set:
Create a HashSet of signers who can verify creators. Creators marked as
verified
in MetadataArgs
must be signed by one of these accounts.- Process Collection Verification:
- The collection details in
message.collection
correspond to the providedcollection_metadata
,collection_mint
,edition_account
. - The
collection_authority
is indeed authorized to verify this collection. message.collection.verified
is set totrue
upon successful verification (sincetrue
is passed as the verify parameter).
It calls
process_collection_verification_mpl_only
to ensure that:This step ensures the NFT being minted will be tied to a valid and verified collection on-chain.
- Mint the Asset:
process_mint_v1
is a shared helper used bymint_v1
as well.- It validates metadata again, checks creators, hashes data, and appends a new leaf to the Merkle tree.
- The difference here is that
allow_verified_collection
istrue
, meaning it will accept a verified collection in the metadata.
After collection verification, the code calls
process_mint_v1
:This will return a
LeafSchema
that represents the newly minted NFT.- Increment the Mint Count:
If
process_mint_v1
succeeds, increment authority.num_minted
to reflect the new minted asset.- Return the Leaf:
Return the newly created
LeafSchema
instance representing the minted asset./// --- programs/bubblegum/program/src/processor/mint_to_collection.rs --- pub(crate) fn mint_to_collection_v1( ctx: Context<MintToCollectionV1>, metadata_args: MetadataArgs, ) -> Result<LeafSchema> { let mut message = metadata_args; /// Extract Accounts: // TODO -> Separate V1 / V1 into seperate instructions let payer = ctx.accounts.payer.key(); let incoming_tree_delegate = ctx.accounts.tree_delegate.key(); let owner = ctx.accounts.leaf_owner.key(); let delegate = ctx.accounts.leaf_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(); /// Authority Check: if !authority.is_public { require!( incoming_tree_delegate == authority.tree_creator || incoming_tree_delegate == authority.tree_delegate, BubblegumError::TreeAuthorityIncorrect, ); } /// Capacity Check: if !authority.contains_mint_capacity(1) { return Err(BubblegumError::InsufficientMintCapacity.into()); } /// Metadata Auth Set: // 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()), ); /// Process Collection Verification: process_collection_verification_mpl_only( collection_metadata, &collection_mint, &collection_authority, &collection_authority_record_pda, &edition_account, &mut message, true, )?; /// Mint the Asset: let leaf = process_mint_v1( message, owner, delegate, metadata_auth, *ctx.bumps.get("tree_authority").unwrap(), authority, merkle_tree, &ctx.accounts.log_wrapper, &ctx.accounts.compression_program, true, )?; /// Increment the Mint Count: authority.increment_mint_count(); /// Return the Leaf: Ok(leaf) }
Process Collection Verification
process_collection_verification_mpl_only
is responsible for verifying that a given NFT’s metadata and collection details conform to the Metaplex Token Metadata (MPL) program’s rules and that the provided collection_authority
is authorized to verify the collection.Parameters:
collection_metadata
: AnAccount
wrapper around theTokenMetadata
account, representing the metadata for the collection NFT.
collection_mint
: The account info for the mint of the collection NFT.
collection_authority
: The account info for the authority allowed to verify or unverify the collection.
collection_authority_record_pda
: An optional PDA (Program Derived Address) that may store collection authority records. If this is not used, the Bubblegum program’s ID will appear here instead.
edition_account
: The account info for the Master Edition or Edition account associated with the collection NFT.
message
: A mutable reference toMetadataArgs
. This structure holds the metadata arguments (e.g., collection info) for the NFT being minted or updated.
verify
: A boolean indicating whether we are verifying (true
) or un-verifying (false
) the collection on this NFT.
Steps:
- Collection Authority Record Handling:
- No special authority record: rely on the default authority checks.
- Special authority record: rely on the provided record for more granular checks.
The function first checks if the
collection_authority_record_pda
is simply the Bubblegum program’s ID. If it is, it means no dedicated collection authority record PDA was provided, and it sets collection_authority_record
to None
. Otherwise, it treats collection_authority_record_pda
as a valid authority record account (Some(...)
).This enables the function to handle two cases:
- Ownership Checks:
- The
collection_metadata
account is owned by the Metaplex Token Metadata program (mpl_token_metadata::ID
). - The
collection_mint
account is owned by the SPL Token program (Not token program 2022) (spl_token::id()
). - The
edition_account
(Master Edition or Edition) is also owned bympl_token_metadata::ID
.
The function requires that:
This ensures that the accounts involved match the expected on-chain programs.
- Collection Validation and Verification:
- Calls
assert_collection_membership(...)
to ensure that: - The collection referenced in
message.collection
matches thecollection_metadata
. - The
collection_mint
andedition_account
correspond to a valid NFT collection that meets Metaplex standards. - Calls
assert_has_collection_authority(...)
to verify that the providedcollection_authority
(and optionally thecollection_authority_record_pda
) is legitimately allowed to modify the verification status of this collection. This ensures only authorized entities can verify/unverify the collection association.
The code checks if
message.collection
is Some(...)
. If no collection is present, it returns error.If a collection is present:
If both checks succeed, the
collection.verified
field in message.collection
is updated to be verify
parameter which is true
./// --- programs/bubblegum/program/src/processor/mod.rs --- #[allow(deprecated)] fn process_collection_verification_mpl_only<'info>( collection_metadata: &Account<'info, TokenMetadata>, collection_mint: &AccountInfo<'info>, collection_authority: &AccountInfo<'info>, collection_authority_record_pda: &AccountInfo<'info>, edition_account: &AccountInfo<'info>, message: &mut MetadataArgs, verify: bool, ) -> Result<()> { /// See if a collection authority record PDA was provided. let collection_authority_record = if collection_authority_record_pda.key() == crate::id() { None } else { Some(collection_authority_record_pda) }; // Verify correct account ownerships. require!( *collection_metadata.to_account_info().owner == mpl_token_metadata::ID, BubblegumError::IncorrectOwner ); require!( *collection_mint.owner == spl_token::id(), BubblegumError::IncorrectOwner ); require!( *edition_account.owner == mpl_token_metadata::ID, BubblegumError::IncorrectOwner ); /// Collection Validation and Verification: // If the NFT has collection data, we set it to the correct value after doing some validation. if let Some(collection) = &mut message.collection { assert_collection_membership( &Some(collection.adapt()), collection_metadata, collection_mint.key, edition_account, )?; assert_has_collection_authority( collection_metadata, collection_mint.key, collection_authority.key, collection_authority_record, )?; // Update collection in metadata args. Note since this is a mutable reference, // it is still updating `message.collection` after being destructured. collection.verified = verify; } else { return Err(BubblegumError::CollectionNotFound.into()); } Ok(()) }
Membership Check
/// --- programs/bubblegum/program/src/asserts.rs --- pub fn assert_collection_membership( membership: &Option<Collection>, collection_metadata: &Metadata, collection_mint: &Pubkey, collection_edition: &AccountInfo, ) -> Result<()> { match membership { Some(collection) => { if collection.key != *collection_mint || collection_metadata.mint != *collection_mint { return Err(BubblegumError::CollectionNotFound.into()); } } None => { return Err(BubblegumError::CollectionNotFound.into()); } } let (expected, _) = mpl_token_metadata::accounts::MasterEdition::find_pda(collection_mint); if collection_edition.key != &expected { return Err(BubblegumError::CollectionMasterEditionAccountInvalid.into()); } let edition = mpl_token_metadata::accounts::MasterEdition::try_from(collection_edition) .map_err(|_err| BubblegumError::CollectionMustBeAUniqueMasterEdition)?; match collection_metadata.token_standard { Some(TokenStandard::NonFungible) | Some(TokenStandard::ProgrammableNonFungible) => (), _ => return Err(BubblegumError::CollectionMustBeAUniqueMasterEdition.into()), } if edition.max_supply != Some(0) { return Err(BubblegumError::CollectionMustBeAUniqueMasterEdition.into()); } Ok(()) }
Authority Check
/// --- programs/bubblegum/program/src/asserts.rs --- // Checks both delegate types: old collection_authority_record and newer // metadata_delegate pub fn assert_has_collection_authority( collection_data: &Metadata, mint: &Pubkey, collection_authority: &Pubkey, delegate_record: Option<&AccountInfo>, ) -> Result<()> { // Mint is the correct one for the metadata account. if collection_data.mint != *mint { return Err(BubblegumError::MetadataMintMismatch.into()); } if let Some(record_info) = delegate_record { let (ca_pda, ca_bump) = CollectionAuthorityRecord::find_pda(mint, collection_authority); let (md_pda, md_bump) = MetadataDelegateRecord::find_pda( mint, MetadataDelegateRole::Collection, &collection_data.update_authority, collection_authority, ); let data = record_info.try_borrow_data()?; if data.len() == 0 { return Err(BubblegumError::InvalidCollectionAuthority.into()); } if record_info.key == &ca_pda { let record = CollectionAuthorityRecord::safe_deserialize(&data)?; if record.bump != ca_bump { return Err(BubblegumError::InvalidCollectionAuthority.into()); } match record.update_authority { Some(update_authority) => { if update_authority != collection_data.update_authority { return Err(BubblegumError::InvalidCollectionAuthority.into()); } } None => return Err(BubblegumError::InvalidCollectionAuthority.into()), } } else if record_info.key == &md_pda { let record = MetadataDelegateRecord::safe_deserialize(&data)?; if record.bump != md_bump { return Err(BubblegumError::InvalidCollectionAuthority.into()); } if record.update_authority != collection_data.update_authority { return Err(BubblegumError::InvalidCollectionAuthority.into()); } } else { return Err(BubblegumError::InvalidDelegateRecord.into()); } } else if collection_data.update_authority != *collection_authority { return Err(BubblegumError::InvalidCollectionAuthority.into()); } Ok(()) }
Transfer
The
transfer
function updates the ownership of an already-existing compressed NFT (represented as a leaf in a Merkle tree) to a new owner.Parameters:
root
: The current root of the Merkle tree (before the update).
data_hash
andcreator_hash
: Hashes representing the metadata and creators of the NFT. These remain unchanged during a transfer.
nonce
: A unique nonce for this particular NFT, used to generate a unique asset ID.
index
: The index of the leaf being modified in the Merkle tree.
/// --- programs/bubblegum/program/src/lib.rs --- /// Transfers a leaf node from one account to another. pub fn transfer<'info>( ctx: Context<'_, '_, '_, 'info, Transfer<'info>>, root: [u8; 32], data_hash: [u8; 32], creator_hash: [u8; 32], nonce: u64, index: u32, ) -> Result<()> { processor::transfer(ctx, root, data_hash, creator_hash, nonce, index) } /// --- programs/bubblegum/program/src/processor/transfer.rs --- #[derive(Accounts)] pub struct Transfer<'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>, /// CHECK: This account is checked in the instruction pub leaf_owner: UncheckedAccount<'info>, /// CHECK: This account is chekced in the instruction pub leaf_delegate: UncheckedAccount<'info>, /// CHECK: This account is neither written to nor read from. pub new_leaf_owner: UncheckedAccount<'info>, #[account(mut)] /// CHECK: This account is modified in the downstream program pub merkle_tree: UncheckedAccount<'info>, pub log_wrapper: Program<'info, Noop>, pub compression_program: Program<'info, SplAccountCompression>, pub system_program: Program<'info, System>, }
Steps:
- Context Extraction:
merkle_tree
is the on-chain pda account owned by account compression program holding the Merkle tree state.owner
is the account currently listed as the NFT’s owner.delegate
is the account currently listed as the NFT’s delegate.
The function receives a
Context<Transfer>
, which includes all the accounts defined in the Transfer
account struct.- Authority Check:
To ensure security, the code checks that either the current
leaf_owner
or the leaf_delegate
is a signer of this transaction. If neither is signing, it returns LeafAuthorityMustSign
. This prevents unauthorized transfers.- Determining the New Owner:
new_owner
is simply the public key of thenew_leaf_owner
account.asset_id
is computed by callingget_asset_id
, which derives a program address (PDA
) based on:- A prefix (
ASSET_PREFIX
), - The
merkle_tree
public key, - The
nonce
.
This
asset_id
uniquely identifies the NFT (leaf) inside the Merkle tree.- Constructing Old and New Leaves:
previous_leaf
: Represents the NFT’s current state before transfer. This includes the current owner and delegate.new_leaf
: Represents the updated NFT state after the transfer. Notice that for the new leaf:- The owner is now
new_owner
. - The delegate is also set to
new_owner
. This effectively removes the old delegate and sets the new owner as their own delegate, simplifying ownership control.
Importantly,
data_hash
and creator_hash
remain unchanged. The NFT’s identity and metadata do not alter on transfer—only the ownership and delegate fields change.- Event Logging:
new_leaf.to_event()
produces an event representation of the leaf’s new state.try_to_vec()
serializes this event into bytes.wrap_application_data_v1(...)
sends this serialized data through a Noop program, leveraging CPI (Cross-Program Invocation) for logging. This ensures the transfer event is recorded and can be indexed by off-chain indexers.
This logging step is crucial for transparency and replaying the state of the Merkle tree off-chain if needed.
- Replacing the Leaf in the Merkle Tree:
&merkle_tree.key()
: The Merkle tree’s public key, used to identify the tree in the compression program.- The PDA bump for
tree_authority
so the tree authority PDA can sign the instruction. - References to
compression_program
,tree_authority
,merkle_tree
,log_wrapper
, and anyremaining_accounts
needed for proof or additional constraints. root
: The old root of the tree before updating.previous_leaf.to_node()
andnew_leaf.to_node()
: The old and new leaf nodes, representing the leaf before and after the transfer.index
: The position of the leaf in the Merkle tree.
The
replace_leaf
function is called to update the Merkle tree on-chain. This is a CPI call into the spl_account_compression
program:Parameters:
The
replace_leaf
CPI call uses a secure protocol to recalculate the Merkle tree root and store the updated leaf on-chain. Post-execution, the tree will reflect the new ownership of the NFT. Note that the proof is stored in remaining accounts.
/// --- programs/bubblegum/program/src/processor/transfer.rs --- pub(crate) fn transfer<'info>( ctx: Context<'_, '_, '_, 'info, Transfer<'info>>, root: [u8; 32], data_hash: [u8; 32], creator_hash: [u8; 32], nonce: u64, index: u32, ) -> Result<()> { /// Context Extraction: // TODO add back version to select hash schema let merkle_tree = ctx.accounts.merkle_tree.to_account_info(); let owner = ctx.accounts.leaf_owner.to_account_info(); let delegate = ctx.accounts.leaf_delegate.to_account_info(); /// Authority Check: // Transfers must be initiated by either the leaf owner or leaf delegate. require!( owner.is_signer || delegate.is_signer, BubblegumError::LeafAuthorityMustSign ); /// Determining the New Owner: let new_owner = ctx.accounts.new_leaf_owner.key(); let asset_id = get_asset_id(&merkle_tree.key(), nonce); /// Constructing Old and New Leaves: let previous_leaf = LeafSchema::new_v0( asset_id, owner.key(), delegate.key(), nonce, data_hash, creator_hash, ); // New leafs are instantiated with no delegate let new_leaf = LeafSchema::new_v0( asset_id, new_owner, new_owner, nonce, data_hash, creator_hash, ); /// Event Logging: wrap_application_data_v1(new_leaf.to_event().try_to_vec()?, &ctx.accounts.log_wrapper)?; /// Replacing the Leaf in the Merkle Tree: replace_leaf( &merkle_tree.key(), *ctx.bumps.get("tree_authority").unwrap(), &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, ) }
Burn
burn
instruction handles removing (or “burning”) a compressed NFT (a leaf in a Merkle tree) by replacing it with a default/empty leaf node.Parameters:
ctx
: TheContext<Burn>
providing all the required accounts and signers.
root
: The current root of the Merkle tree.
data_hash
&creator_hash
: Hashes representing the NFT’s metadata and creators.
nonce
: Unique nonce used to derive the NFT’s asset ID.
index
: The position of the leaf to be burned in the Merkle tree.
/// Burns a leaf node from the tree. pub fn burn<'info>( ctx: Context<'_, '_, '_, 'info, Burn<'info>>, root: [u8; 32], data_hash: [u8; 32], creator_hash: [u8; 32], nonce: u64, index: u32, ) -> Result<()> { processor::burn(ctx, root, data_hash, creator_hash, nonce, index) } #[derive(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>, #[account(mut)] /// CHECK: This account is modified in the downstream program pub merkle_tree: UncheckedAccount<'info>, pub log_wrapper: Program<'info, Noop>, pub compression_program: Program<'info, SplAccountCompression>, pub system_program: Program<'info, System>, }
Steps:
- Authority Check:
The function ensures that the transaction is authorized by either the
leaf_owner
or leaf_delegate
.- Identify the Leaf Being Burned:
asset_id
is derived using theget_asset_id
function, which deterministically computes a PDA from the Merkle tree's public key and thenonce
. This unique asset ID corresponds to the NFT.previous_leaf
is constructed usingLeafSchema::new_v0
. It represents the leaf in its current state (before burning). This includes:asset_id
- Current owner and delegate public keys
nonce
data_hash
andcreator_hash
that identify the metadata and creator data for the NFT
- Creating the "Burned" Leaf:
new_leaf
is set to a default Node
, which is essentially an empty placeholder. By replacing the current leaf with this empty node, we effectively remove the NFT from the Merkle tree.- Replacing the Leaf in the Merkle Tree:
- The core of the burn operation is the call to
replace_leaf
. This function: - Takes the old leaf (
previous_leaf
) and the new leaf (new_leaf
= empty node). - Updates the Merkle tree at
index
to use thenew_leaf
. - Recomputes and updates the Merkle tree root accordingly via a CPI call to the
spl_account_compression
program.
/// --- programs/bubblegum/program/src/processor/burn.rs --- pub(crate) fn burn<'info>( ctx: Context<'_, '_, '_, 'info, Burn<'info>>, root: [u8; 32], data_hash: [u8; 32], creator_hash: [u8; 32], nonce: u64, index: u32, ) -> Result<()> { /// Authority Check: 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 ); /// Identify the Leaf Being Burned: 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_v0( asset_id, owner.key(), delegate.key(), nonce, data_hash, creator_hash, ); /// Creating the "Burned" Leaf: let new_leaf = Node::default(); /// Replacing the Leaf in the Merkle Tree: replace_leaf( &merkle_tree.key(), *ctx.bumps.get("tree_authority").unwrap(), &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, ) }
Redeem
redeem
instruction is designed to handle the redemption of a compressed NFT (represented as a leaf in a Merkle tree) by effectively removing it from the tree. Also,
redeem
process creates an voucher account which records the original cNFT info, which can be used in decompress
instruction to mint a standard NFT.The
redeem
function serves to:- Validate Decompression Capability: Ensure that the tree permits decompression operations.
- Authorize the Redemption: Confirm that the entity initiating the redemption has the necessary permissions (either as the leaf owner or delegate).
- Update the Merkle Tree: Replace the targeted leaf node with a default (empty) node, effectively marking it as redeemed.
- Create a Voucher: Instantiate a
Voucher
account to record the redemption event for off-chain tracking or further processing.
/// --- programs/bubblegum/program/src/lib.rs --- /// Redeems a vouches. /// /// Once a vouch is redeemed, the corresponding leaf node is removed from the tree. pub fn redeem<'info>( ctx: Context<'_, '_, '_, 'info, Redeem<'info>>, root: [u8; 32], data_hash: [u8; 32], creator_hash: [u8; 32], nonce: u64, index: u32, ) -> Result<()> { processor::redeem(ctx, root, data_hash, creator_hash, nonce, index) } /// --- programs/bubblegum/program/src/processor/redeem.rs -- #[derive(Accounts)] #[instruction( _root: [u8; 32], _data_hash: [u8; 32], _creator_hash: [u8; 32], nonce: u64, _index: u32, )] pub struct Redeem<'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>, #[account(mut)] pub leaf_owner: Signer<'info>, /// CHECK: This account is chekced in the instruction pub leaf_delegate: UncheckedAccount<'info>, #[account(mut)] /// CHECK: checked in cpi 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, Noop>, pub compression_program: Program<'info, SplAccountCompression>, pub system_program: Program<'info, System>, }
Steps:
- Decompression Capability Check
Ensures that the Merkle tree is configured to allow decompression operations.
- Extracting Accounts
- Creating a Default (Empty) Leaf
Initializes a new leaf node as a default
Node
, effectively emptying the space previously occupied by the NFT.Replacing the old leaf with this default node marks the NFT as burned or redeemed.
- Replacing the Leaf in the Merkle Tree
Performs a Cross-Program Invocation (CPI) to the SPL Account Compression program to replace a leaf in the Merkle tree.
- Creating the
Voucher
Account
Initializes the
voucher
account with details of the redeemed leaf. Voucher is used to cancel redeem in future.pub(crate) fn redeem<'info>( ctx: Context<'_, '_, '_, 'info, Redeem<'info>>, root: [u8; 32], data_hash: [u8; 32], creator_hash: [u8; 32], nonce: u64, index: u32, ) -> Result<()> { /// Decompression Capability Check if ctx.accounts.tree_authority.is_decompressible == DecompressibleState::Disabled { return Err(BubblegumError::DecompressionDisabled.into()); } /// Extracting Accounts let owner = ctx.accounts.leaf_owner.key(); let 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_v0(asset_id, owner, delegate, nonce, data_hash, creator_hash); /// Creating a Default (Empty) Leaf let new_leaf = Node::default(); /// Replacing the Leaf in the Merkle Tree replace_leaf( &merkle_tree.key(), *ctx.bumps.get("tree_authority").unwrap(), &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, )?; /// Creating the Voucher Account ctx.accounts .voucher .set_inner(Voucher::new(previous_leaf, index, merkle_tree.key())); Ok(()) }
Cancel Redeem
cancel_redeem
cancels previous redeem, re-mint the original cNFT according to info recorded in the voucher.Only the original cNFT’s owner can redeem the NFT.
This design has a problem, if a NFT protocol wants to convert users’ cNFTs to ordinary NFTs. They can’t prevent users from cancelling the redemption. But of course, the project can let users first transfer cNFT to them, then it redeems those cNFTs, and finally mints ordinary NFTs to users.
/// --- programs/bubblegum/program/src/lib.rs --- /// Cancels a redeem. pub fn cancel_redeem<'info>( ctx: Context<'_, '_, '_, 'info, CancelRedeem<'info>>, root: [u8; 32], ) -> Result<()> { processor::cancel_redeem(ctx, root) } /// --- programs/bubblegum/program/src/processor/cancel_redeem.rs --- #[derive(Accounts)] pub struct CancelRedeem<'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>, #[account(mut)] pub leaf_owner: Signer<'info>, #[account(mut)] /// CHECK: unsafe pub merkle_tree: UncheckedAccount<'info>, #[account( mut, close = leaf_owner, seeds = [ VOUCHER_PREFIX.as_ref(), merkle_tree.key().as_ref(), & voucher.leaf_schema.nonce().to_le_bytes() ], bump )] pub voucher: Account<'info, Voucher>, pub log_wrapper: Program<'info, Noop>, pub compression_program: Program<'info, SplAccountCompression>, pub system_program: Program<'info, System>, }
/// --- programs/bubblegum/program/src/processor/cancel_redeem.rs --- pub(crate) fn cancel_redeem<'info>( ctx: Context<'_, '_, '_, 'info, CancelRedeem<'info>>, root: [u8; 32], ) -> Result<()> { let voucher = &ctx.accounts.voucher; match ctx.accounts.voucher.leaf_schema { LeafSchema::V1 { owner, .. } => assert_pubkey_equal( &ctx.accounts.leaf_owner.key(), &owner, Some(BubblegumError::AssetOwnerMismatch.into()), ), }?; let merkle_tree = ctx.accounts.merkle_tree.to_account_info(); wrap_application_data_v1( voucher.leaf_schema.to_event().try_to_vec()?, &ctx.accounts.log_wrapper, )?; replace_leaf( &merkle_tree.key(), *ctx.bumps.get("tree_authority").unwrap(), &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, [0; 32], voucher.leaf_schema.to_node(), voucher.index, ) }
Decompress
The
decompress_v1
function is designed to handle the decompression or redemption of a compressed NFT within a Merkle tree. Decompression involves:
- Redeeming a Voucher: A voucher represents a compressed NFT that can be redeemed to instantiate a standard SPL Token (NFT) with associated metadata.
- Creating Token Accounts: Upon redemption, the function creates necessary token accounts if they don't already exist.
- Initializing Metadata: It sets up the NFT's metadata using the Metaplex Token Metadata program.
- Managing Authorities: Ensures that the correct authorities are assigned to the minted NFT.
- Updating the Merkle Tree: Replaces the redeemed leaf with a default node, effectively marking it as redeemed.
Parameters:
ctx: Context<DecompressV1>
– Provides access to the accounts defined in theDecompressV1
struct.
metadata: MetadataArgs
– User-supplied metadata for the NFT to be created upon redemption.
/// --- programs/bubblegum/program/src/lib.rs --- /// Decompresses a leaf node from the tree. pub fn decompress_v1(ctx: Context<DecompressV1>, metadata: MetadataArgs) -> Result<()> { processor::decompress_v1(ctx, metadata) } #[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, Noop>, }
Steps:
- Metadata Validation
- Ensures that the provided
metadata
aligns with thedata_hash
stored in thevoucher
. - Validates the integrity and ownership before proceeding with redemption. Only leaf owner can decompress NFT.
- Asset Identification
- Determines the version of the token program being used.
- Currently, only
Original
version is supported;Token2022
is not handled and will result in an error.
- Mint Initialization
- Check if Mint Account is Empty:
- Create Mint Account
- Initialize Mint
Initializes the mint account for the NFT if it hasn't been initialized yet.
ctx.accounts.mint.data_is_empty()
checks whether the mint account has been initialized.- Token Account Initialization
Creates an associated token account for the minted NFT if it doesn't already exist.
- Minting NFT to ATA
Mints NFT to the associated token account.
- Assigning Mint Authority
Assigns the mint authority to the current program.
- Creating Metadata Account
Initializes the Metaplex Token Metadata account for the newly minted NFT.
This transaction sets the
metadata.token_standard
to be TokenStandard::FungibleAsset
.- Creating Master Edition
Creates a Master Edition account for the NFT, which is essential for enforcing uniqueness and handling editions.
It transfers
mint_authority
to master edition acocunt to ensure NFT’s uniqueness.This transaction sets the
metadata.token_standard
to be TokenStandard::NonFungible
.- Finalizing Mint Authority
Reassigns the
mint_authority
account's owner to the System Program, effectively removing program control over it.- Returning Success
/// --- programs/bubblegum/program/src/processor/decompress.rs --- pub(crate) fn decompress_v1(ctx: Context<DecompressV1>, metadata: MetadataArgs) -> Result<()> { /// Metadata Validation match ctx.accounts.voucher.leaf_schema { LeafSchema::V1 { owner, data_hash, .. } => { let incoming_data_hash: [u8; 32] = hash_metadata(&metadata)?; if !cmp_bytes(&data_hash, &incoming_data_hash, 32) { return Err(BubblegumError::HashingMismatch.into()); } if !cmp_pubkeys(&owner, ctx.accounts.leaf_owner.key) { return Err(BubblegumError::AssetOwnerMismatch.into()); } } } /// Asset Identification let voucher = &ctx.accounts.voucher; match metadata.token_program_version { TokenProgramVersion::Original => { /// Mint Initialization 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.get("mint").unwrap()], ]], )?; 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(), ], )?; } /// Token Account Initialization 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(), ], )?; } /// Minting NFT to ATA // 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()), } /// Assigning Mint Authority invoke_signed( &system_instruction::assign(&ctx.accounts.mint_authority.key(), &crate::id()), &[ctx.accounts.mint_authority.to_account_info()], &[&[ ctx.accounts.mint.key().as_ref(), &[*ctx.bumps.get("mint_authority").unwrap()], ]], )?; /// Creating Metadata Account 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"]], ]])?; /// Creating Master Edition 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"]], ]])?; /// Finalizing Mint Authority ctx.accounts .mint_authority .to_account_info() .assign(&System::id()); Ok(()) }
Assign Mint Authority to
During NFT mint and metadata initialization, it needs to also migrate the creator and collection verification information. These information has been verified in bubblegum program already, so there is no need to verify it again in the metaplex program.
In metaplex program, it checks the
mint_authority
account of NFT mint
, if its owner is bubblegum program and the address is calculated using bubblegum program id and the mint
address, then metaplex just migrates creator and collection information without checks./// --- programs/token-metadata/program/src/utils/metadata.rs --- #[allow(clippy::too_many_arguments)] pub fn process_create_metadata_accounts_logic( program_id: &Pubkey, accounts: CreateMetadataAccountsLogicArgs, data: DataV2, allow_direct_creator_writes: bool, mut is_mutable: bool, is_edition: bool, add_token_standard: bool, collection_details: Option<CollectionDetails>, token_standard_override: Option<TokenStandard>, programmable_config: Option<ProgrammableConfig>, ) -> ProgramResult { /// ... // This allows the Bubblegum program to create metadata with verified creators since they were // verified already by the Bubblegum program. let is_decompression = is_decompression(mint_info, mint_authority_info); let allow_direct_creator_writes = allow_direct_creator_writes || is_decompression; assert_data_valid( &compatible_data, &update_authority_key, &metadata, allow_direct_creator_writes, update_authority_info.is_signer, )?; /// ... // This allows for either print editions or the Bubblegum program to create metadata with verified collection. let allow_direct_collection_verified_writes = is_edition || is_decompression; assert_collection_update_is_valid( allow_direct_collection_verified_writes, &None, &data.collection, )?; /// ... }
Set Decompressible State
set_decompressible_state
instruction sets whether the NFTs in certain tree can be decompressed./// --- programs/bubblegum/program/src/lib.rs --- /// Sets the `decompressible_state` of a tree. pub fn set_decompressible_state( ctx: Context<SetDecompressibleState>, decompressable_state: DecompressibleState, ) -> Result<()> { processor::set_decompressible_state(ctx, decompressable_state) } /// --- programs/bubblegum/program/src/processor/set_decompressible_state.rs --- #[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<()> { ctx.accounts.tree_authority.is_decompressible = decompressable_state; Ok(()) }
Update Metadata
The
update_metadata
instruction is designed to update the metadata of an existing NFT (Non-Fungible Token) within a Merkle tree. It ensures that only authorized entities can modify an NFT's metadata and that any updates adhere to the constraints imposed by the Metaplex Token Metadata program.It handles:
- Authorization: Ensuring that the updater has the right to modify the NFT's metadata, especially in the context of collections.
- Metadata Validation: Confirming that the new metadata conforms to Metaplex's standards, including proper creator verification and adherence to maximum length constraints.
- Merkle Tree Integrity: Updating the leaf node within the Merkle tree to reflect the new metadata while maintaining the tree's cryptographic integrity.
- Event Logging: Recording the metadata update event for off-chain indexing and transparency.
Parameters:
ctx: Context<UpdateMetadata<'info>>
: Provides access to the accounts defined in theUpdateMetadata
struct.
root: [u8; 32]
: The current root hash of the Merkle tree before the update.
nonce: u64
: A unique number associated with the NFT, used for generating asset IDs.
index: u32
: The index position of the NFT's leaf within the Merkle tree.
current_metadata: MetadataArgs
: The existing metadata of the NFT.
update_args: UpdateArgs
: The new metadata fields to be updated.
/// --- programs/bubblegum/program/src/lib.rs --- /// Updates metadata for a leaf node that is not part of a verified collection. pub fn update_metadata<'info>( ctx: Context<'_, '_, '_, 'info, UpdateMetadata<'info>>, root: [u8; 32], nonce: u64, index: u32, current_metadata: MetadataArgs, update_args: UpdateArgs, ) -> Result<()> { processor::update_metadata(ctx, root, nonce, index, current_metadata, update_args) } /// --- programs/bubblegum/program/src/processor/update_metadata.rs --- #[derive(Accounts)] pub struct UpdateMetadata<'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>, /// Either collection authority or tree owner/delegate, depending /// on whether the item is in a verified collection pub authority: Signer<'info>, /// CHECK: This account is checked in the instruction /// Used when item is in a verified collection pub collection_mint: Option<UncheckedAccount<'info>>, /// Used when item is in a verified collection pub collection_metadata: Option<Box<Account<'info, TokenMetadata>>>, /// CHECK: This account is checked in the instruction pub collection_authority_record_pda: Option<UncheckedAccount<'info>>, /// 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>, pub payer: Signer<'info>, #[account(mut)] /// CHECK: This account is modified in the downstream program pub merkle_tree: UncheckedAccount<'info>, pub log_wrapper: Program<'info, Noop>, pub compression_program: Program<'info, SplAccountCompression>, /// CHECK: This is no longer needed but kept for backwards compatibility. pub token_metadata_program: UncheckedAccount<'info>, pub system_program: Program<'info, System>, } /// --- programs/bubblegum/program/src/state/metaplex_adapter.rs --- #[derive(AnchorSerialize, AnchorDeserialize, PartialEq, Eq, Debug, Clone, Default)] pub struct UpdateArgs { pub name: Option<String>, pub symbol: Option<String>, pub uri: Option<String>, pub creators: Option<Vec<Creator>>, pub seller_fee_basis_points: Option<u16>, pub primary_sale_happened: Option<bool>, pub is_mutable: Option<bool>, }
Steps:
- Collection Verification
- Case 1: NFT is in a Verified Collection
- Case 2: NFT is Not in a Verified Collection or Collection is Unverified
Determines whether the NFT is part of a verified collection and verifies the authority accordingly.
Only metadata update authority or delegate of the collection mint can update the cNFT’s metadata.
Only tree creator or delegate can update cNFT’s metadata.
- Processing Metadata Updates
Handles the core logic of updating the metadata, including validation, updating the Merkle tree, and ensuring consistency.
/// --- programs/bubblegum/program/src/processor/update_metadata.rs --- pub fn update_metadata<'info>( ctx: Context<'_, '_, '_, 'info, UpdateMetadata<'info>>, root: [u8; 32], nonce: u64, index: u32, current_metadata: MetadataArgs, update_args: UpdateArgs, ) -> Result<()> { match ¤t_metadata.collection { // Verified collection case. Some(collection) if collection.verified => { let collection_mint = ctx .accounts .collection_mint .as_ref() .ok_or(BubblegumError::MissingCollectionMintAccount)?; let collection_metadata = ctx .accounts .collection_metadata .as_ref() .ok_or(BubblegumError::MissingCollectionMetadataAccount)?; assert_authority_matches_collection( collection, &ctx.accounts.authority.to_account_info(), &ctx.accounts.collection_authority_record_pda, collection_mint, &collection_metadata.to_account_info(), collection_metadata, )?; } // No collection or unverified collection case. _ => { require!( ctx.accounts.authority.key() == ctx.accounts.tree_authority.tree_creator || ctx.accounts.authority.key() == ctx.accounts.tree_authority.tree_delegate, BubblegumError::TreeAuthorityIncorrect, ); } } process_update_metadata( &ctx.accounts.merkle_tree.to_account_info(), &ctx.accounts.authority, &ctx.accounts.leaf_owner, &ctx.accounts.leaf_delegate, &ctx.accounts.compression_program.to_account_info(), &ctx.accounts.tree_authority.to_account_info(), *ctx.bumps.get("tree_authority").unwrap(), &ctx.accounts.log_wrapper, ctx.remaining_accounts, root, current_metadata, update_args, nonce, index, ) }
Assert Authority Matches Collection
Ensures that the provided
collection_authority
is indeed authorized to manage the collection's NFTs.Steps:
- Mint Account Validation:
- Ensures that the
collection_mint
matches the collection's key. - Ensures that the
collection_metadata
's mint matches the collection's key.
- Ownership Checks:
- Confirms that the
collection_metadata
account is owned by the Metaplex Token Metadata program. - Ensures that the
collection_mint
is owned by the SPL Token program.
- Collection Authority Verification:
- Extracts the
collection_authority_record_pda
if provided. - Calls
assert_has_collection_authority
to verify that thecollection_authority
is legitimate.
/// --- programs/bubblegum/program/src/processor/update_metadata.rs --- fn assert_authority_matches_collection<'info>( collection: &Collection, collection_authority: &AccountInfo<'info>, collection_authority_record_pda: &Option<UncheckedAccount<'info>>, collection_mint: &AccountInfo<'info>, collection_metadata_account_info: &AccountInfo, collection_metadata: &TokenMetadata, ) -> Result<()> { // Mint account must match Collection mint require!( collection_mint.key() == collection.key, BubblegumError::CollectionMismatch ); // Metadata mint must match Collection mint require!( collection_metadata.mint == collection.key, BubblegumError::CollectionMismatch ); // Verify correct account ownerships. require!( *collection_metadata_account_info.owner == mpl_token_metadata::ID, BubblegumError::IncorrectOwner ); // Collection mint must be owned by SPL token require!( *collection_mint.owner == spl_token::id(), BubblegumError::IncorrectOwner ); let collection_authority_record = collection_authority_record_pda .as_ref() .map(|authority_record_pda| authority_record_pda.to_account_info()); // Assert that the correct Collection Authority was provided using token-metadata assert_has_collection_authority( collection_metadata, collection_mint.key, collection_authority.key, collection_authority_record.as_ref(), )?; Ok(()) } /// --- programs/bubblegum/program/src/asserts.rs --- // Checks both delegate types: old collection_authority_record and newer // metadata_delegate pub fn assert_has_collection_authority( collection_data: &Metadata, mint: &Pubkey, collection_authority: &Pubkey, delegate_record: Option<&AccountInfo>, ) -> Result<()> { // Mint is the correct one for the metadata account. if collection_data.mint != *mint { return Err(BubblegumError::MetadataMintMismatch.into()); } if let Some(record_info) = delegate_record { let (ca_pda, ca_bump) = CollectionAuthorityRecord::find_pda(mint, collection_authority); let (md_pda, md_bump) = MetadataDelegateRecord::find_pda( mint, MetadataDelegateRole::Collection, &collection_data.update_authority, collection_authority, ); let data = record_info.try_borrow_data()?; if data.len() == 0 { return Err(BubblegumError::InvalidCollectionAuthority.into()); } if record_info.key == &ca_pda { let record = CollectionAuthorityRecord::safe_deserialize(&data)?; if record.bump != ca_bump { return Err(BubblegumError::InvalidCollectionAuthority.into()); } match record.update_authority { Some(update_authority) => { if update_authority != collection_data.update_authority { return Err(BubblegumError::InvalidCollectionAuthority.into()); } } None => return Err(BubblegumError::InvalidCollectionAuthority.into()), } } else if record_info.key == &md_pda { let record = MetadataDelegateRecord::safe_deserialize(&data)?; if record.bump != md_bump { return Err(BubblegumError::InvalidCollectionAuthority.into()); } if record.update_authority != collection_data.update_authority { return Err(BubblegumError::InvalidCollectionAuthority.into()); } } else { return Err(BubblegumError::InvalidDelegateRecord.into()); } } else if collection_data.update_authority != *collection_authority { return Err(BubblegumError::InvalidCollectionAuthority.into()); } Ok(()) }
Process Update Metadata
The
process_update_metadata
function is responsible for updating the metadata of an existing NFT within a Merkle tree. This involves:- Validating Mutability: Ensuring that the NFT's metadata is allowed to be updated.
- Updating Metadata Fields: Applying the requested changes to the metadata.
- Ensuring Creator Integrity: Maintaining the verification status of creators to prevent unauthorized alterations.
- Validating Compatibility: Ensuring the updated metadata adheres to Metaplex's standards.
- Updating the Merkle Tree: Reflecting the metadata changes in the Merkle tree to maintain its cryptographic integrity.
- Logging the Update: Recording the update event for off-chain indexing and transparency.
Steps:
- Metadata Mutability Check
Ensures that the existing metadata allows for updates. If the
is_mutable
flag is false
, the function aborts, preventing any unauthorized or unintended modifications.- Hash Calculations
current_data_hash
: Computes a Keccak-256 hash of the current metadata to ensure data integrity.current_creator_hash
: Computes a Keccak-256 hash of the current creators' data to maintain creator verification integrity.
This is to ensure that both metadata and creator information are consistent and have not been tampered with.
- Applying Updates
- Name, Symbol, URI Updates:
- If
name
,symbol
, oruri
are provided inupdate_args
, they replace the corresponding fields inupdated_metadata
. - Verification Constraints:
- No New Verified Creators: Ensures that no new creators are marked as
verified
unless they are theauthority
(typically the tree delegate). - No Unverified Removals: Ensures that existing verified creators are not unverified or removed unless they are the
authority
. - Seller Fee Basis Points Update:
- Primary Sale Happened Update:
- Allows flipping
primary_sale_happened
fromfalse
totrue
only. - Constraint: Prevents reverting
primary_sale_happened
fromtrue
back tofalse
. - Mutability Flag Update:
Applies the requested updates to the NFT's metadata based on the provided
update_args
.If
seller_fee_basis_points
is provided, it updates the corresponding field in updated_metadata
.If
is_mutable
is provided, updates the corresponding flag in updated_metadata
.- Metadata Compatibility Check
- Name Length: Must not exceed
MAX_NAME_LENGTH
. - Symbol Length: Must not exceed
MAX_SYMBOL_LENGTH
. - URI Length: Must not exceed
MAX_URI_LENGTH
. - Seller Fee Basis Points: Must not exceed
10000
(equivalent to 100%). - Creators Constraints:
- Maximum Creators: Must not exceed
MAX_CREATOR_LIMIT
. - Unique Addresses: No duplicate creator addresses.
- Total Share: The sum of all creator shares must equal
100
.
Ensures that the updated metadata complies with the Metaplex Token Metadata program's standards and constraints.
- Recalculating Hashes
updated_data_hash
: Computes the Keccak-256 hash of the updated metadata.updated_creator_hash
: Computes the Keccak-256 hash of the updated creators' data.
- Constructing New Leaf Schemas
- Event Logging
Logs the metadata update event, enabling off-chain indexing.
- Updating the Merkle Tree
Replaces the old leaf in the Merkle tree with the updated leaf, ensuring the tree's cryptographic integrity.
/// --- programs/bubblegum/program/src/processor/update_metadata.rs --- fn process_update_metadata<'info>( merkle_tree: &AccountInfo<'info>, authority: &AccountInfo<'info>, owner: &AccountInfo<'info>, delegate: &AccountInfo<'info>, compression_program: &AccountInfo<'info>, tree_authority: &AccountInfo<'info>, tree_authority_bump: u8, log_wrapper: &Program<'info, Noop>, remaining_accounts: &[AccountInfo<'info>], root: [u8; 32], current_metadata: MetadataArgs, update_args: UpdateArgs, nonce: u64, index: u32, ) -> Result<()> { /// Metadata Mutability Check // Old metadata must be mutable to allow metadata update require!( current_metadata.is_mutable, BubblegumError::MetadataImmutable ); /// Hash Calculations let current_data_hash = hash_metadata(¤t_metadata)?; let current_creator_hash = hash_creators(¤t_metadata.creators)?; /// Applying Updates let mut updated_metadata = current_metadata; if let Some(name) = update_args.name { updated_metadata.name = name; }; if let Some(symbol) = update_args.symbol { updated_metadata.symbol = symbol; }; if let Some(uri) = update_args.uri { updated_metadata.uri = uri; }; if let Some(updated_creators) = update_args.creators { let current_creators = updated_metadata.creators; // Make sure no new creator is verified (unless it is the tree delegate). let no_new_creators_verified = all_verified_creators_in_a_are_in_b( &updated_creators, ¤t_creators, authority.key(), ); require!( no_new_creators_verified, BubblegumError::CreatorDidNotVerify ); // Make sure no current verified creator is unverified or removed (unless it is the tree // delegate). let no_current_creators_unverified = all_verified_creators_in_a_are_in_b( ¤t_creators, &updated_creators, authority.key(), ); require!( no_current_creators_unverified, BubblegumError::CreatorDidNotUnverify ); updated_metadata.creators = updated_creators; } if let Some(seller_fee_basis_points) = update_args.seller_fee_basis_points { updated_metadata.seller_fee_basis_points = seller_fee_basis_points }; if let Some(primary_sale_happened) = update_args.primary_sale_happened { // a new value of primary_sale_happened should only be specified if primary_sale_happened was false in the original metadata require!( !updated_metadata.primary_sale_happened, BubblegumError::PrimarySaleCanOnlyBeFlippedToTrue ); updated_metadata.primary_sale_happened = primary_sale_happened; }; if let Some(is_mutable) = update_args.is_mutable { updated_metadata.is_mutable = is_mutable; }; /// Metadata Compatibility Check assert_metadata_is_mpl_compatible(&updated_metadata)?; /// Recalculating Hashes let updated_data_hash = hash_metadata(&updated_metadata)?; let updated_creator_hash = hash_creators(&updated_metadata.creators)?; /// Constructing Leaf Schemas let asset_id = get_asset_id(&merkle_tree.key(), nonce); let previous_leaf = LeafSchema::new_v0( asset_id, owner.key(), delegate.key(), nonce, current_data_hash, current_creator_hash, ); let new_leaf = LeafSchema::new_v0( asset_id, owner.key(), delegate.key(), nonce, updated_data_hash, updated_creator_hash, ); /// Event Logging wrap_application_data_v1(new_leaf.to_event().try_to_vec()?, log_wrapper)?; /// Updating the Merkle Tree replace_leaf( &merkle_tree.key(), tree_authority_bump, compression_program, tree_authority, merkle_tree, &log_wrapper.to_account_info(), remaining_accounts, root, previous_leaf.to_node(), new_leaf.to_node(), index, ) }