Metaplex Fungible Token Metadata

Metaplex Fungible Token Metadata

Overview

In the Solana ecosystem, fungible tokens are typically represented by the SPL (Solana Program Library) Token program, which is analogous to ERC-20 tokens on Ethereum. Fungible token metadata on Solana includes essential information about the token such as its name, symbol, and image, etc. This metadata is crucial for wallets, exchanges, and other applications to correctly display and interact with the token.

Differences Between Solana and Ethereum Fungible Token Metadata

While both Solana and Ethereum support fungible tokens with metadata, there are several key differences in their implementation and characteristics.
1. Token Standards:
  • Ethereum: Uses the ERC-20 standard for fungible tokens. The ERC-20 standard is a set of functions that a smart contract must implement to be considered an ERC-20 token, including functions like totalSupply, balanceOf, transfer, approve, and transferFrom.
  • Solana: Uses the SPL Token standard. The SPL Token program is a set of instructions and data structures for creating and managing fungible tokens.
2. Metadata Storage:
  • Ethereum: ERC20 contracts basically stores all token relavant information in single contract. It stores basic token information like token balances and supply, also it stores metadata like name, symbol, and image in the same contract.
  • Solana: Mint account only stores basic token information like supply and decimals, it doesn’t stores other various token metadata information. Metadata can be stored on-chain using the Metaplex standard, which allows for comprehensive on-chain metadata storage. Metaplex protocol stores token metadata in separate metadata PDA account which is different from the Mint account. Each Mint account can have one specific token metadata PDA account generated from metaplex token metadata program, which records various metadata information like name, symbol, uri, creators, etc. Note that metaplex token metadata account works for both fungible and non-fungible token, there are parameters suit for different token standards.
notion image

Metadata Content

In the Metaplex token metadata system, Metadata struct defines the content of token metadata. It is a general metadata content, supporting fungible and non fungible token.
/// --- programs/token-metadata/program/src/state/metadata.rs --- pub struct Metadata { /// Account discriminator. pub key: Key, /// Address of the update authority. #[cfg_attr(feature = "serde-feature", serde(with = "As::<DisplayFromStr>"))] pub update_authority: Pubkey, /// Address of the mint. #[cfg_attr(feature = "serde-feature", serde(with = "As::<DisplayFromStr>"))] pub mint: Pubkey, /// Asset data. pub data: Data, // 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>, /// Collection Details pub collection_details: Option<CollectionDetails>, /// Programmable Config pub programmable_config: Option<ProgrammableConfig>, } pub enum ProgrammableConfig { V1 { /// Programmable authorization rules. #[cfg_attr( feature = "serde-feature", serde( deserialize_with = "deser_option_pubkey", serialize_with = "ser_option_pubkey" ) )] rule_set: Option<Pubkey>, }, } /// --- programs/token-metadata/program/src/state/data.rs --- pub struct Data { /// 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, /// Array of creators, optional pub creators: Option<Vec<Creator>>, } /// --- programs/token-metadata/program/src/state/creator.rs --- pub struct Creator { #[cfg_attr(feature = "serde-feature", serde(with = "As::<DisplayFromStr>"))] pub address: Pubkey, pub verified: bool, // In percentages, NOT basis points ;) Watch out! pub share: u8, } /// --- programs/token-metadata/program/src/state/mod.rs --- pub enum TokenStandard { NonFungible, // This is a master edition FungibleAsset, // A token with metadata that can also have attributes Fungible, // A token with simple metadata NonFungibleEdition, // This is a limited edition ProgrammableNonFungible, // NonFungible with programmable configuration ProgrammableNonFungibleEdition, // NonFungible with programmable configuration } /// --- programs/token-metadata/program/src/state/collection.rs --- pub struct Collection { pub verified: bool, #[cfg_attr(feature = "serde-feature", serde(with = "As::<DisplayFromStr>"))] pub key: Pubkey, } /// --- programs/token-metadata/program/src/state/uses.rs --- pub enum UseMethod { Burn, Multiple, Single, } pub struct Uses { // 17 bytes + Option byte pub use_method: UseMethod, //1 pub remaining: u64, //8 pub total: u64, //8 } /// --- programs/token-metadata/program/src/state/collection.rs --- pub enum CollectionDetails { #[deprecated( since = "1.13.1", note = "The collection size tracking feature is deprecated and will soon be removed." )] V1 { size: u64, }, V2 { padding: [u8; 8], }, }
 
There are some fields related to Non Fungible Token:
  1. Seller fee
    1. Metadata.data.seller_fee_basis_points defines the royalties shared by the creators in basis points. When NFT are sold, such percentage of price needed to pay to the NFT creators. Each creator has his/her own share of the royalty defined by the Creator.share.
      Note: this field can be used by NFT marketplaces, but it is not enforced by the Token Metadata program itself.
  1. Uses
    1. The uses field is an optional struct of type Uses. This struct typically includes information about:
    2. the usage type of the NFT. Whether it can be used multiple times or it’s just a one-time usage.
    3. the total number of times the NFT can be used (total)
    4. the remaining times the NFT can be used (remaining)
  1. edition_nonce
  1. collection
    1. Record the NFT collection this NFT belongs to.
  1. collection_details
    1. It provides additional information about an NFT collection. This struct is for managing collections of NFTs, especially in terms of tracking the size and ensuring consistency within the collection.

Create Metadata Account

Instruction

We can use token-metadata program’s CreateMetadataAccountV3 instruction to create metadata account for a fungible token(Mint account).
/// ---programs/token-metadata/program/src/instruction/metadata.rs--- pub fn create_metadata_accounts_v3( program_id: Pubkey, metadata_account: Pubkey, mint: Pubkey, mint_authority: Pubkey, payer: Pubkey, update_authority: Pubkey, name: String, symbol: String, uri: String, creators: Option<Vec<Creator>>, seller_fee_basis_points: u16, update_authority_is_signer: bool, is_mutable: bool, collection: Option<Collection>, uses: Option<Uses>, collection_details: Option<CollectionDetails>, ) -> Instruction { Instruction { program_id, accounts: vec![ AccountMeta::new(metadata_account, false), AccountMeta::new_readonly(mint, false), AccountMeta::new_readonly(mint_authority, true), AccountMeta::new(payer, true), AccountMeta::new_readonly(update_authority, update_authority_is_signer), AccountMeta::new_readonly(system_program::ID, false), ], data: MetadataInstruction::CreateMetadataAccountV3(CreateMetadataAccountArgsV3 { data: DataV2 { name, symbol, uri, seller_fee_basis_points, creators, collection, uses, }, is_mutable, collection_details, }) .try_to_vec() .unwrap(), } } /// Args for create call pub struct CreateMetadataAccountArgsV3 { /// Note that unique metadatas are disabled for now. pub data: DataV2, /// Whether you want your metadata to be updateable in the future. pub is_mutable: bool, /// If this is a collection parent NFT. pub collection_details: Option<CollectionDetails>, } pub struct DataV2 { /// 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, /// Array of creators, optional pub creators: Option<Vec<Creator>>, /// Collection pub collection: Option<Collection>, /// Uses pub uses: Option<Uses>, }

Process Instruction

process_instruction decodes input, finds corresponding method to handle instruction.
/// ---programs/token-metadata/program/src/processor/mod.rs--- /// Process Token Metadata instructions. /// /// The processor is divided into two parts: /// * It first tries to match the instruction into the new API; /// * If it is not one of the new instructions, it checks that any metadata /// account is not a pNFT before forwarding the transaction processing to /// the "legacy" processor. pub fn process_instruction<'a>( program_id: &'a Pubkey, accounts: &'a [AccountInfo<'a>], input: &[u8], ) -> ProgramResult { let (variant, _args) = input .split_first() .ok_or(MetadataError::InvalidInstruction)?; let instruction = match MetadataInstruction::try_from_slice(input) { Ok(instruction) => Ok(instruction), // Check if the instruction is a deprecated instruction. Err(_) => match *variant { CREATE_METADATA_ACCOUNT | UPDATE_METADATA_ACCOUNT | DEPRECATED_CREATE_MASTER_EDITION | DEPRECATED_MINT_NEW_EDITION_FROM_MASTER_EDITION_VIA_PRINTING_TOKEN | DEPRECATED_SET_RESERVATION_LIST | DEPRECATED_CREATE_RESERVATION_LIST | DEPRECATED_MINT_PRINTING_TOKENS_VIA_TOKEN | DEPRECATED_MINT_PRINTING_TOKENS | CREATE_METADATA_ACCOUNT_V2 | MIGRATE => Err(MetadataError::Removed.into()), _ => Err(ProgramError::InvalidInstructionData), }, }?; // checks if there is a locked token; this will block any instruction that // requires the token record account when the token is locked – 'Update' is // an example of an instruction that does not require the token record, so // it can be executed even when a token is locked if is_locked(program_id, accounts) && !matches!(instruction, MetadataInstruction::Unlock(_)) { return Err(MetadataError::LockedToken.into()); } // match on the new instruction set match instruction { // ... _ => { // pNFT accounts and SPL Token-2022 program can only be used by the "new" API; before // forwarding the transaction to the "legacy" processor we determine whether we are // dealing with a pNFT or not if !incompatible_accounts(program_id, accounts)? { process_legacy_instruction(program_id, accounts, instruction) } else { Err(MetadataError::InstructionNotSupported.into()) } } } }
process_create_metadata_accounts_v3 handles CreateMetadataAccountArgsV3 instruction.
Steps:
  1. Decode all related accounts
  1. Transfer fee (rent + metaplex fee) to metadata account
    1. metaplex fee can be retrieved by metaplex team
  1. Create metadata pda account and initialize data
  1. Set fee flag of the metadata account
    1. which indicates that metaplex fee has been transferred into the account and can be retrieved by metaplex team.
/// ---programs/token-metadata/program/src/processor/metadata/create_medatata_accounts_v3.rs--- pub fn process_create_metadata_accounts_v3<'a>( program_id: &'a Pubkey, accounts: &'a [AccountInfo<'a>], data: DataV2, is_mutable: bool, collection_details: Option<CollectionDetails>, ) -> ProgramResult { // Decode all related accounts all_account_infos!( accounts, metadata_account_info, mint_info, mint_authority_info, payer_account_info, update_authority_info, system_account_info ); // Transfer fee (rent + metaplex fee) to metadata account // Levy fees first, to fund the metadata account with rent + fee amount. levy(LevyArgs { payer_account_info, token_metadata_pda_info: metadata_account_info, })?; // Create metadata pda account and initialize data process_create_metadata_accounts_logic( program_id, CreateMetadataAccountsLogicArgs { metadata_account_info, mint_info, mint_authority_info, payer_account_info, update_authority_info, system_account_info, }, data, false, is_mutable, false, true, collection_details, None, None, )?; // Set fee flag of the metadata account // Set fee flag after metadata account is created. set_fee_flag(metadata_account_info) }

Process create metadata accounts logic

process_create_metadata_accounts_logic handles the logic for creating metadata accounts.
Parameters:
  • program_id: The public key of the program.
  • accounts: A struct containing various AccountInfo references needed for the operation.
  • data: Metadata information encapsulated in a DataV2 struct.
  • allow_direct_creator_writes: A boolean flag indicating if direct writes to creator fields are allowed.
  • is_mutable: A mutable boolean indicating if the metadata can be modified.
  • is_edition: A boolean flag indicating if the token is an edition.
  • add_token_standard: A boolean indicating whether to add a token standard.
  • collection_details: An optional struct containing collection details.
  • token_standard_override: An optional token standard to override the default.
  • programmable_config: An optional configuration for programmable tokens.
 
Steps:
  1. Destructure Accounts
    1. Destructures the accounts struct to extract individual account references.
  1. Retrieve Mint Authority
    1. Retrieves the current mint authority of the token.
  1. Assert Mint Authority Matches
    1. Ensures that the mint authority matches the provided mint authority info. If it does not match but the SEED_AUTHORITY is the signer, it allows the seeding process.
  1. Assert Owner In SPL Token Program IDs
    1. Verifies that the owner of the Mint is within the SPL token program IDs. This is to ensure that the Mint follows SPL Token standard.
  1. Calculate Metadata Seeds
    1. Calculates the seeds for the metadata account and the bump seed.
  1. Check Metadata Account Key
    1. Ensures that the provided metadata account info key matches the calculated metadata key.
  1. Create or Allocate Metadata Account
    1. Creates or allocates space for the metadata account.
  1. Deserialize Metadata
    1. Deserializes the metadata from the account info.
  1. Allow Direct Creator Writes
    1. Determines if direct writes to the creator fields are allowed.
  1. Validate Metadata Data
    1. Validates the provided metadata data.
  1. Retrieve Mint Decimals
    1. Retrieves the number of decimals for the mint.
  1. Update Metadata Fields
    1. Updates various fields in the metadata.
  1. Validate Uses
    1. Validates the uses of the metadata and updates the metadata with these uses.
  1. Validate Collection Update
    1. Validates the collection update and updates the metadata with the collection information.
  1. Set Collection Details
    1. Sets the collection details for the metadata.
  1. Set Token Standard
    1. Determines and sets the token standard for the metadata.
  1. Puff Out Data Fields
    1. Ensures the metadata fields are properly padded.
  1. Set Edition Nonce
    1. Calculates and sets the edition nonce for the metadata.
  1. Save Metadata
    1. Saves the updated metadata back to the account.
/// --- programs/token-metadata/program/src/utils/metadata.rs --- #[allow(clippy::too_many_arguments)] /// Create a new account instruction #[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 { /// Destructure Accounts let CreateMetadataAccountsLogicArgs { metadata_account_info, mint_info, mint_authority_info, payer_account_info, update_authority_info, system_account_info, } = accounts; let mut update_authority_key = *update_authority_info.key; /// Retrieve Mint Authority let existing_mint_authority = get_mint_authority(mint_info)?; /// Assert Mint Authority Matches // IMPORTANT NOTE: // This allows the Metaplex Foundation to Create but not update metadata for SPL tokens that // have not populated their metadata. assert_mint_authority_matches_mint(&existing_mint_authority, mint_authority_info).or_else( |e| { // Allow seeding by the authority seed populator if mint_authority_info.key == &SEED_AUTHORITY && mint_authority_info.is_signer { // When metadata is seeded, the mint authority should be able to change it if let COption::Some(auth) = existing_mint_authority { update_authority_key = auth; is_mutable = true; } Ok(()) } else { Err(e) } }, )?; /// Assert Owner In SPL Token Program IDs assert_owner_in(mint_info, &SPL_TOKEN_PROGRAM_IDS)?; /// Calculate Metadata Seeds let metadata_seeds = &[ PREFIX.as_bytes(), program_id.as_ref(), mint_info.key.as_ref(), ]; let (metadata_key, metadata_bump_seed) = Pubkey::find_program_address(metadata_seeds, program_id); let metadata_authority_signer_seeds = &[ PREFIX.as_bytes(), program_id.as_ref(), mint_info.key.as_ref(), &[metadata_bump_seed], ]; /// Check Metadata Account Key if metadata_account_info.key != &metadata_key { return Err(MetadataError::InvalidMetadataKey.into()); } /// Create or Allocate Metadata Account create_or_allocate_account_raw( *program_id, metadata_account_info, system_account_info, payer_account_info, MAX_METADATA_LEN, metadata_authority_signer_seeds, )?; /// Deserialize Metadata let mut metadata = Metadata::from_account_info(metadata_account_info)?; let compatible_data = data.to_v1(); /// Allow Direct Creator Writes // 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; /// Validate Metadata Data assert_data_valid( &compatible_data, &update_authority_key, &metadata, allow_direct_creator_writes, update_authority_info.is_signer, )?; /// Retrieve Mint Decimals let mint_decimals = get_mint_decimals(mint_info)?; /// Update Metadata Fields metadata.mint = *mint_info.key; metadata.key = Key::MetadataV1; metadata.data = data.to_v1(); metadata.is_mutable = is_mutable; metadata.update_authority = update_authority_key; metadata.programmable_config = programmable_config; /// Validate Uses assert_valid_use(&data.uses, &None)?; metadata.uses = data.uses; /// Validate Collection Update // 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, )?; metadata.collection = data.collection; /// Set Collection Details // We want to create new collections with a size of zero but we use the // collection details enum for forward compatibility. if let Some(details) = collection_details { match details { #[allow(deprecated)] CollectionDetails::V1 { size: _size } => { metadata.collection_details = Some(CollectionDetails::V1 { size: 0 }); } CollectionDetails::V2 { padding: _ } => { metadata.collection_details = None; } } } else { metadata.collection_details = None; } /// Set Token Standard metadata.token_standard = if add_token_standard { token_standard_override.or({ if is_edition { Some(TokenStandard::NonFungibleEdition) } else if mint_decimals == 0 { Some(TokenStandard::FungibleAsset) } else { Some(TokenStandard::Fungible) } }) } else { None }; /// Puff Out Data Fields puff_out_data_fields(&mut metadata); /// Set Edition Nonce let edition_seeds = &[ PREFIX.as_bytes(), program_id.as_ref(), metadata.mint.as_ref(), EDITION.as_bytes(), ]; let (_, edition_bump_seed) = Pubkey::find_program_address(edition_seeds, program_id); metadata.edition_nonce = Some(edition_bump_seed); /// Save Metadata // saves the changes to the account data metadata.save(&mut metadata_account_info.data.borrow_mut())?; Ok(()) }
 

get mint authority

get_mint_authority uses array_ref![data, 0, 36] to fetch the first 36 bytes stored in mint.
unpack_coption_key splits the fetched data to get 4 bytes tag and 32 bytes body. This is because type COption uses the first 4 bytes to indicate the enum type.
Then it checks whether the type is Some(Public key) by checking whether the tag is [1,0,0,0]. Note that enum in rust uses little-endian to store type discriminant data, starting from 0 for the first variant and incrementing by 1 for each subsequent variant.
/// --- mpl-utils-0.3.4/src/token/utils.rs --- pub fn get_mint_authority(account_info: &AccountInfo) -> Result<COption<Pubkey>, ProgramError> { // In token program, 36, 8, 1, 1 is the layout, where the first 36 is mint_authority // so we start at 0. let data = account_info.try_borrow_data().unwrap(); let authority_bytes = array_ref![data, 0, 36]; unpack_coption_key(authority_bytes) } /// Unpacks COption from a slice, taken from token program fn unpack_coption_key(src: &[u8; 36]) -> Result<COption<Pubkey>, ProgramError> { let (tag, body) = array_refs![src, 4, 32]; match *tag { [0, 0, 0, 0] => Ok(COption::None), [1, 0, 0, 0] => Ok(COption::Some(Pubkey::new_from_array(*body))), _ => Err(ProgramError::InvalidAccountData), } }
/// --- token/program/src/state.rs --- #[repr(C)] #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Mint { /// Optional authority used to mint new tokens. The mint authority may only /// be provided during mint creation. If no mint authority is present /// then the mint has a fixed supply and no further tokens may be /// minted. pub mint_authority: COption<Pubkey>, /// Total supply of tokens. pub supply: u64, /// Number of base 10 digits to the right of the decimal place. pub decimals: u8, /// Is `true` if this structure has been initialized pub is_initialized: bool, /// Optional authority to freeze token accounts. pub freeze_authority: COption<Pubkey>, } /// --- solana-program-1.18.11/src/program_option.rs --- pub enum COption<T> { /// No value None, /// Some value `T` Some(T), }

Assert Mint Authority Matches

assert_mint_authority_matches_mint first checks whether owner of the Mint has authorized(signed) to create metadata account of the Mint.
If the check failed, then it checks whether the SEED_AUTHORITY is the signer and sets the Mint owner as the metadata update authority. This means metaplex protocol allows a whitelisted program (SEED_AUTHORITY) to create metadata account for a Mint account which doesn’t have corresponding metadata account yet. Note the update authority is the owner of the Mint which ensures owner of Mint has necessary authority to control the metadata account.
/// --- programs/token-metadata/program/src/utils/metadata.rs --- // IMPORTANT NOTE: // This allows the Metaplex Foundation to Create but not update metadata for SPL tokens that // have not populated their metadata. assert_mint_authority_matches_mint(&existing_mint_authority, mint_authority_info).or_else( |e| { // Allow seeding by the authority seed populator if mint_authority_info.key == &SEED_AUTHORITY && mint_authority_info.is_signer { // When metadata is seeded, the mint authority should be able to change it if let COption::Some(auth) = existing_mint_authority { update_authority_key = auth; is_mutable = true; } Ok(()) } else { Err(e) } }, )?; /// --- programs/token-metadata/program/src/utils/metadata.rs --- // This equals the program address of the metadata program: // // AqH29mZfQFgRpfwaPoTMWSKJ5kqauoc1FwVBRksZyQrt // // IMPORTANT NOTE // This allows the upgrade authority of the Token Metadata program to create metadata for SPL tokens. // This only allows the upgrade authority to do create general metadata for the SPL token, it does not // allow the upgrade authority to add or change creators. pub const SEED_AUTHORITY: Pubkey = Pubkey::new_from_array([ 0x92, 0x17, 0x2c, 0xc4, 0x72, 0x5d, 0xc0, 0x41, 0xf9, 0xdd, 0x8c, 0x51, 0x52, 0x60, 0x04, 0x26, 0x00, 0x93, 0xa3, 0x0b, 0x02, 0x73, 0xdc, 0xfa, 0x74, 0x92, 0x17, 0xfc, 0x94, 0xa2, 0x40, 0x49, ]);
 
assert_mint_authority_matches_mint checks whether the owner of Mint equals the passed owner account, and whether it has signed. So to deploy a token metadata of certain Mint, we need authority (signature) of the owner of the Mint.
/// --- programs/token-metadata/program/src/assertions/misc.rs --- pub fn assert_mint_authority_matches_mint( mint_authority: &COption<Pubkey>, mint_authority_info: &AccountInfo, ) -> ProgramResult { match mint_authority { COption::None => { return Err(MetadataError::InvalidMintAuthority.into()); } COption::Some(key) => { if mint_authority_info.key != key { return Err(MetadataError::InvalidMintAuthority.into()); } } } if !mint_authority_info.is_signer { return Err(MetadataError::NotMintAuthority.into()); } Ok(()) }

Check Mint account owner is Token Program

/// --- programs/token-metadata/program/src/assertions/misc.rs --- pub fn assert_owner_in(account: &AccountInfo, owners: &[Pubkey]) -> ProgramResult { if owners.iter().any(|owner| cmp_pubkeys(owner, account.owner)) { Ok(()) } else { Err(MetadataError::IncorrectOwner.into()) } }

Create or Allocate Metadata Account

create_or_allocate_account_raw creates and initializes a new metadata account.
Parameters
  • program_id: Pubkey: The public key of the program to which the new account will be assigned.
  • new_account_info: &AccountInfo<'a>: Information about the new account being created.
  • system_program_info: &AccountInfo<'a>: Information about the system program (responsible for managing accounts and lamports).
  • payer_info: &AccountInfo<'a>: Information about the account that will pay for the creation and initialization of the new account.
  • size: usize: The size in bytes of the space to be allocated for the new account.
  • signer_seeds: &[&[u8]]: Seeds for deriving the PDA signer key.
 
Steps:
  1. Calculate Required Lamports
      • Retrieves the current rent parameters using Rent::get()?.
      • It calculates the minimum balance required to make the account rent-exempt for the given size. Note the minimum is 1 which precents from the account being destroyed.
      • It subtracts the current lamports in the new_account_info from the required lamports to determine the additional lamports needed.
  1. Transfer Lamports:
      • If additional lamports are needed, the function calls the invoke function to execute a system instruction to transfer the required lamports from the payer_info to the new_account_info.
  1. Allocate Space:
      • The function then calls invoke_signed to execute a system instruction to allocate the specified size of space for the new account.
  1. Assign the Account:
      • Finally, the function calls invoke_signed again to execute a system instruction to assign the new account to the specified program_id.
/// --- mpl-utils-0.3.4/src/account.rs --- /// Create account almost from scratch, lifted from /// <https://github.com/solana-labs/solana-program-library/tree/master/associated-token-account/program/src/processor.rs#L51-L98> pub fn create_or_allocate_account_raw<'a>( program_id: Pubkey, new_account_info: &AccountInfo<'a>, system_program_info: &AccountInfo<'a>, payer_info: &AccountInfo<'a>, size: usize, signer_seeds: &[&[u8]], ) -> ProgramResult { let rent = &Rent::get()?; let required_lamports = rent .minimum_balance(size) .max(1) .saturating_sub(new_account_info.lamports()); if required_lamports > 0 { msg!("Transfer {} lamports to the new account", required_lamports); invoke( &system_instruction::transfer(payer_info.key, new_account_info.key, required_lamports), &[ payer_info.clone(), new_account_info.clone(), system_program_info.clone(), ], )?; } let accounts = &[new_account_info.clone(), system_program_info.clone()]; msg!("Allocate space for the account"); invoke_signed( &system_instruction::allocate(new_account_info.key, size.try_into().unwrap()), accounts, &[signer_seeds], )?; msg!("Assign the account to the owning program"); invoke_signed( &system_instruction::assign(new_account_info.key, &program_id), accounts, &[signer_seeds], )?; Ok(()) }

Deserialize Metadata

from_account_info deserialize the data in account to be struct Metadata. There are some safety checks like the type (key) of the account.
/// --- programs/token-metadata/program/src/state/mod.rs --- fn is_correct_account_type(data: &[u8], data_type: Key, data_size: usize) -> bool { if data.is_empty() { return false; } let key: Option<Key> = Key::from_u8(data[0]); match key { Some(key) => { (key == data_type || key == Key::Uninitialized) && (data.len() == data_size || data_size == 0) } None => false, } fn safe_deserialize(mut data: &[u8]) -> Result<Self, BorshError> { if !Self::is_correct_account_type(data, Self::key(), Self::size()) { return Err(BorshError::new(ErrorKind::Other, "DataTypeMismatch")); } let result = Self::deserialize(&mut data)?; Ok(result) } fn from_account_info(a: &AccountInfo) -> Result<Self, ProgramError> { let data = &a.data.borrow_mut(); let ua = Self::safe_deserialize(data).map_err(|_| MetadataError::DataTypeMismatch)?; // Check that this is a `token-metadata` owned account. assert_owned_by(a, &ID)?; Ok(ua) }

Allow Direct Creator Writes

If the mint_authority is PDA generated and owned by BUBBLEGUM_PROGRAM_ADDRESS, then the metadata account allows direct creator writes.
/// --- programs/token-metadata/program/src/utils/compression.rs --- pub fn is_decompression(mint: &AccountInfo, mint_authority_info: &AccountInfo) -> bool { if BUBBLEGUM_ACTIVATED && mint_authority_info.is_signer && cmp_pubkeys(mint_authority_info.owner, &BUBBLEGUM_PROGRAM_ADDRESS) { let (expected, _) = find_compression_mint_authority(mint.key); return cmp_pubkeys(mint_authority_info.key, &expected); } false } pub fn find_compression_mint_authority(mint: &Pubkey) -> (Pubkey, u8) { let seeds = &[mint.as_ref()]; Pubkey::find_program_address(seeds, &BUBBLEGUM_PROGRAM_ADDRESS) }
 

Validate Metadata Data

assert_data_valid validates the metadata associated with a token, ensuring that it meets certain criteria and adheres to specified rules.
Parameters:
  • data: &Data: The new metadata to be validated.
  • update_authority: &Pubkey: The public key of the update authority.
  • existing_metadata: &Metadata: The existing metadata of the token.
  • allow_direct_creator_writes: bool: A flag indicating if direct writes by creators are allowed.
  • update_authority_is_signer: bool: A flag indicating if the update authority is a signer of the transaction.
Steps:
  1. Length Checks:
      • Ensures the name, symbol, and uri fields in the data do not exceed their respective maximum lengths.
      • If any of these fields exceed their limits, an appropriate error is returned.
      • Note that when those data are finally stored, it will be padded to the max length, so that there is a consistent storage layout across different metadata accounts.
  1. Basis Points Check:
      • Validates that the seller_fee_basis_points does not exceed 10,000 (which represents 100% in basis points).
  1. Creators Check:
      • Handles the creators field, ensuring it conforms to rules regarding the number and properties of creators.
      • If data.creators is None, it ensures no currently verified creators are being removed without proper authorization.
  1. New Creators Validation:
      • Ensures the list of new creators is not empty and does not exceed the maximum limit.
      • Uses a HashMap to detect duplicate creator addresses.
      • Validates the verified status of creators based on existing metadata and the allow_direct_creator_writes flag.
  1. Share Validation:
      • Ensures that the total share percentage across all creators adds up to 100%.
  1. Existing Creators Validation:
      • Ensures that existing verified creators are not unintentionally removed or unverified unless explicitly allowed.
/// --- programs/token-metadata/program/src/assertions/metadata.rs --- pub fn assert_data_valid( data: &Data, update_authority: &Pubkey, existing_metadata: &Metadata, allow_direct_creator_writes: bool, update_authority_is_signer: bool, ) -> ProgramResult { if data.name.len() > MAX_NAME_LENGTH { return Err(MetadataError::NameTooLong.into()); } if data.symbol.len() > MAX_SYMBOL_LENGTH { return Err(MetadataError::SymbolTooLong.into()); } if data.uri.len() > MAX_URI_LENGTH { return Err(MetadataError::UriTooLong.into()); } if data.seller_fee_basis_points > 10000 { return Err(MetadataError::InvalidBasisPoints.into()); } // If the user passes in creators we get a reference to it, otherwise if the user passes in // None we make sure no current creators are verified before returning and allowing them to set // creators field to None. let creators = match data.creators { Some(ref creators) => creators, None => { if let Some(ref existing_creators) = existing_metadata.data.creators { if existing_creators.iter().any(|c| c.verified) { return Err(MetadataError::CannotRemoveVerifiedCreator.into()); } } return Ok(()); } }; if creators.len() > MAX_CREATOR_LIMIT { return Err(MetadataError::CreatorsTooLong.into()); } if creators.is_empty() { return Err(MetadataError::CreatorsMustBeAtleastOne.into()); } // Store caller-supplied creator's array into a hashmap for direct lookup. let new_creators_map: HashMap<&Pubkey, &Creator> = creators.iter().map(|c| (&c.address, c)).collect(); // Do not allow duplicate entries in the creator's array. if new_creators_map.len() != creators.len() { return Err(MetadataError::DuplicateCreatorAddress.into()); } // If there is an existing creator's array, store this in a hashmap as well. let existing_creators_map: Option<HashMap<&Pubkey, &Creator>> = existing_metadata .data .creators .as_ref() .map(|existing_creators| existing_creators.iter().map(|c| (&c.address, c)).collect()); // Loop over new creator's map. let mut share_total: u8 = 0; for (address, creator) in &new_creators_map { // Add up creator shares. After looping through all creators, will // verify it adds up to 100%. share_total = share_total .checked_add(creator.share) .ok_or(MetadataError::NumericalOverflowError)?; // If this flag is set we are allowing any and all creators to be marked as verified // without further checking. This can only be done in special circumstances when the // metadata is fully trusted such as when minting a limited edition. Note we are still // checking that creator share adds up to 100%. if allow_direct_creator_writes { continue; } // If this specific creator (of this loop iteration) is a signer and an update // authority, then we are fine with this creator either setting or clearing its // own `creator.verified` flag. if update_authority_is_signer && **address == *update_authority { continue; } // If the previous two conditions are not true then we check the state in the existing // metadata creators array (if it exists) before allowing `creator.verified` to be set. if let Some(existing_creators_map) = &existing_creators_map { if existing_creators_map.contains_key(address) { // If this specific creator (of this loop iteration) is in the existing // creator's array, then it's `creator.verified` flag must match the existing // state. if creator.verified && !existing_creators_map[address].verified { return Err(MetadataError::CannotVerifyAnotherCreator.into()); } else if !creator.verified && existing_creators_map[address].verified { return Err(MetadataError::CannotUnverifyAnotherCreator.into()); } } else if creator.verified { // If this specific creator is not in the existing creator's array, then we // cannot set `creator.verified`. return Err(MetadataError::CannotVerifyAnotherCreator.into()); } } else if creator.verified { // If there is no existing creators array, we cannot set `creator.verified`. return Err(MetadataError::CannotVerifyAnotherCreator.into()); } } // Ensure share total is 100%. if share_total != 100 { return Err(MetadataError::ShareTotalMustBe100.into()); } // Next make sure there were not any existing creators that were already verified but not // listed in the new creator's array. if allow_direct_creator_writes { return Ok(()); } else if let Some(existing_creators_map) = &existing_creators_map { for (address, existing_creator) in existing_creators_map { // If this specific existing creator (of this loop iteration is a signer and an // update authority, then we are fine with this creator clearing its own // `creator.verified` flag. if update_authority_is_signer && **address == *update_authority { continue; } else if !new_creators_map.contains_key(address) && existing_creator.verified { return Err(MetadataError::CannotUnverifyAnotherCreator.into()); } } } Ok(()) }

Retrieve Mint Decimals

get_mint_decimals gets Mint decimals.
/// --- mpl-utils-0.3.4/src/token/utils.rs --- /// cheap method to just get supply off a mint without unpacking whole object pub fn get_mint_decimals(account_info: &AccountInfo) -> Result<u8, ProgramError> { // In token program, 36, 8, 1, 1, is the layout, where the first 1 is decimals u8. // so we start at 36. let data = account_info.try_borrow_data()?; // If we don't check this and an empty account is passed in, we get a panic when // we try to index into the data. if data.is_empty() { return Err(ProgramError::InvalidAccountData); } Ok(data[44]) }

Validate Uses

assert_valid_use checks whether the passed uses applies the rule, and whether it is compatible with the previous usage status of the NFT.
If the NFT has been used before (current.total != current.remaining), the new uses must be exactly same as the previous setting.
/// --- program/src/assertions/uses.rs --- pub fn assert_valid_use( incoming_use: &Option<Uses>, current_use: &Option<Uses>, ) -> Result<(), ProgramError> { if let Some(i) = incoming_use { if i.use_method == UseMethod::Single && (i.total != 1 || i.remaining != 1) { return Err(MetadataError::InvalidUseMethod.into()); } if i.use_method == UseMethod::Multiple && (i.total < 2 || i.total < i.remaining) { return Err(MetadataError::InvalidUseMethod.into()); } } match (incoming_use, current_use) { (Some(incoming), Some(current)) => { if incoming.use_method != current.use_method && current.total != current.remaining { return Err(MetadataError::CannotChangeUseMethodAfterFirstUse.into()); } if incoming.total != current.total && current.total != current.remaining { return Err(MetadataError::CannotChangeUsesAfterFirstUse.into()); } if incoming.remaining != current.remaining && current.total != current.remaining { return Err(MetadataError::CannotChangeUsesAfterFirstUse.into()); } Ok(()) } _ => Ok(()), } }

Validate Collection Update

assert_collection_update_is_valid checks whether a proposed update to the Collection field in the metadata account is allowed based on the verification status of the existing and incoming Collection. The function aims to ensure that changes to the Collection field maintain the integrity and consistency of the collection metadata.
Determine Validity of the Update:
If the incoming Collection is verified (is_incoming_verified is true):
  • The update is valid only if the existing Collection is also verified and the key of both collections match.
If the incoming Collection is not verified:
  • The update is valid only if the existing Collection is also not verified.
/// --- program/src/assertions/collection.rs --- /// Checks whether the collection update is allowed or not based on the `verified` status. pub fn assert_collection_update_is_valid( allow_direct_collection_verified_writes: bool, existing: &Option<Collection>, incoming: &Option<Collection>, ) -> Result<(), ProgramError> { let is_incoming_verified = if let Some(status) = incoming { status.verified } else { false }; let is_existing_verified = if let Some(status) = existing { status.verified } else { false }; let valid_update = if is_incoming_verified { // verified: can only update if the details match is_existing_verified && (existing.as_ref().unwrap().key == incoming.as_ref().unwrap().key) } else { // unverified: can only update if existing is unverified !is_existing_verified }; // overrule: if we are dealing with an edition or a Bubblegum decompression. if !valid_update && !allow_direct_collection_verified_writes { return Err(MetadataError::CollectionCannotBeVerifiedInThisInstruction.into()); } Ok(()) } /// --- program/src/state/collection.rs --- pub struct Collection { pub verified: bool, #[cfg_attr(feature = "serde-feature", serde(with = "As::<DisplayFromStr>"))] pub key: Pubkey, }

Update Collection Details

The CollectionDetails struct in the metadata account provides additional information about an NFT collection. This struct is for managing collections of NFTs, especially in terms of tracking the size and ensuring consistency within the collection. It helps in managing and organizing groups of related NFTs under a single collection, making it easier to handle operations related to collections.
Fields of CollectionDetails Struct
  1. V1 { size: u64 }: This variant contains the size of the collection, which indicates the number of NFTs that belong to the collection.
  1. V2 { padding: [u8; 8] }: This variant might be reserved for future use, allowing for additional fields or metadata to be included as needed. The padding field is typically used to ensure proper memory alignment or to reserve space for future enhancements.
/// --- program/src/utils/metadata.rs --- // We want to create new collections with a size of zero but we use the // collection details enum for forward compatibility. if let Some(details) = collection_details { match details { #[allow(deprecated)] CollectionDetails::V1 { size: _size } => { metadata.collection_details = Some(CollectionDetails::V1 { size: 0 }); } CollectionDetails::V2 { padding: _ } => { metadata.collection_details = None; } } } else { metadata.collection_details = None; }
 

Set Token Standard

/// --- program/src/utils/metadata.rs --- metadata.token_standard = if add_token_standard { token_standard_override.or({ if is_edition { Some(TokenStandard::NonFungibleEdition) } else if mint_decimals == 0 { Some(TokenStandard::FungibleAsset) } else { Some(TokenStandard::Fungible) } }) } else { None }; /// --- program/src/state/mod.rs --- pub enum TokenStandard { NonFungible, // This is a master edition FungibleAsset, // A token with metadata that can also have attributes Fungible, // A token with simple metadata NonFungibleEdition, // This is a limited edition ProgrammableNonFungible, // NonFungible with programmable configuration ProgrammableNonFungibleEdition, // NonFungible with programmable configuration }
 

Puff Out Data Fields

To make metadata account storage layout consistent, it puffs variable length fields like name, symbol and url to fixed lengths.
/// --- program/src/utils/mod.rs --- /// Strings need to be appended with `\0`s in order to have a deterministic length. /// This supports the `memcmp` filter on get program account calls. /// NOTE: it is assumed that the metadata fields are never larger than the respective MAX_LENGTH pub fn puff_out_data_fields(metadata: &mut Metadata) { metadata.data.name = puffed_out_string(&metadata.data.name, MAX_NAME_LENGTH); metadata.data.symbol = puffed_out_string(&metadata.data.symbol, MAX_SYMBOL_LENGTH); metadata.data.uri = puffed_out_string(&metadata.data.uri, MAX_URI_LENGTH); }
 

Set Edition Nonce

Calculates and sets the edition nonce for the metadata. Edition is a concept in Solana NFT.

Set Fee Flag

Fee flag indicates whether there is withdrawable fee.
/// --- program/src/utils/fee.rs --- pub(crate) fn set_fee_flag(pda_account_info: &AccountInfo) -> ProgramResult { let mut data = pda_account_info.try_borrow_mut_data()?; data[METADATA_FEE_FLAG_INDEX] = 1; Ok(()) }

Update Metadata

process_update_metadata_accounts_v2 is responsible for handling updates to an existing metadata account. This function ensures that various aspects of the metadata can be updated while maintaining integrity and adhering to specific rules and validations.
Parameters
  • program_id: The public key of the program, used to ensure that the accounts are owned by this program.
  • accounts: A slice of AccountInfo objects, which include the metadata account and the update authority account.
  • optional_data: An optional DataV2 struct containing new metadata fields.
  • update_authority: An optional new update authority public key.
  • primary_sale_happened: An optional boolean indicating whether the primary sale has happened.
  • is_mutable: An optional boolean indicating whether the metadata is mutable.
Steps:
  1. Account Deserialization:
      • all_account_infos! macro extracts the relevant account info objects.
      • metadata_account_info and update_authority_info are extracted for further processing.
  1. Metadata Extraction and Validation:
      • The Metadata struct is deserialized from the metadata_account_info data.
      • Check metadata_account_info belongs to the token metadata program.
      • The update authority is validated to ensure it matches the metadata's update authority.
  1. Update Data
      • If optional_data is provided and the metadata is mutable:
        • The provided data(compatible_data) is validated.
        • The metadata's data field is updated.
        • The collection data is conditionally updated based on its verification status.
        • The uses field is validated and updated.
  1. Update Authority Change:
      • If a new update_authority is provided, it updates the metadata's update authority.
  1. Primary Sale Status Change:
      • If primary_sale_happened is provided and is true, it updates the metadata's primary sale status.
  1. Mutability Status Change:
      • If is_mutable is provided, it updates the metadata's mutability status, ensuring it can only be set to false.
  1. Save:
      • The metadata fields are "puffed out" (padding is added to ensure data consistency).
      • The metadata is written back to the account data.
/// --- program/src/state/data.rs --- pub struct DataV2 { /// 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, /// Array of creators, optional pub creators: Option<Vec<Creator>>, /// Collection pub collection: Option<Collection>, /// Uses pub uses: Option<Uses>, } /// --- programs/token-metadata/program/src/processor/metadata/update_metadata_account_v2.rs --- // Update existing account instruction pub fn process_update_metadata_accounts_v2( program_id: &Pubkey, accounts: &[AccountInfo], optional_data: Option<DataV2>, update_authority: Option<Pubkey>, primary_sale_happened: Option<bool>, is_mutable: Option<bool>, ) -> ProgramResult { all_account_infos!(accounts, metadata_account_info, update_authority_info); let mut metadata = Metadata::from_account_info(metadata_account_info)?; assert_owned_by(metadata_account_info, program_id)?; assert_update_authority_is_correct(&metadata, update_authority_info)?; if let Some(data) = optional_data { if metadata.is_mutable { let compatible_data = data.to_v1(); assert_data_valid( &compatible_data, update_authority_info.key, &metadata, false, update_authority_info.is_signer, )?; metadata.data = compatible_data; // If the user passes in Collection data, only allow updating if it's unverified // or if it exactly matches the existing collection info. // If the user passes in None for the Collection data then only set it if it's unverified. if data.collection.is_some() { assert_collection_update_is_valid(false, &metadata.collection, &data.collection)?; metadata.collection = data.collection; } else if let Some(collection) = metadata.collection.as_ref() { // Can't change a verified collection in this command. if collection.verified { return Err(MetadataError::CannotUpdateVerifiedCollection.into()); } // If it's unverified, it's ok to set to None. metadata.collection = data.collection; } // If already None leave it as None. assert_valid_use(&data.uses, &metadata.uses)?; metadata.uses = data.uses; } else { return Err(MetadataError::DataIsImmutable.into()); } } if let Some(val) = update_authority { metadata.update_authority = val; } if let Some(val) = primary_sale_happened { // If received val is true, flip to true. if val || !metadata.primary_sale_happened { metadata.primary_sale_happened = val } else { return Err(MetadataError::PrimarySaleCanOnlyBeFlippedToTrue.into()); } } if let Some(val) = is_mutable { // If received value is false, flip to false. if !val || metadata.is_mutable { metadata.is_mutable = val } else { return Err(MetadataError::IsMutableCanOnlyBeFlippedToFalse.into()); } } puff_out_data_fields(&mut metadata); clean_write_metadata(&mut metadata, metadata_account_info) }

Check Account Belongs to Owner

/// --- program/src/assertions/misc.rs --- pub fn assert_owned_by(account: &AccountInfo, owner: &Pubkey) -> ProgramResult { mpl_utils::assert_owned_by(account, owner, MetadataError::IncorrectOwner) }

Check Update Authority

/// --- program/src/assertions/metadata.rs --- pub fn assert_update_authority_is_correct( metadata: &Metadata, update_authority_info: &AccountInfo, ) -> ProgramResult { if metadata.update_authority != *update_authority_info.key { return Err(MetadataError::UpdateAuthorityIncorrect.into()); } if !update_authority_info.is_signer { return Err(MetadataError::UpdateAuthorityIsNotSigner.into()); } Ok(()) }

Save Data

First clean the data without overwirte the fee flag. Then serialize new metadata and store them.
/// --- program/src/utils/metadata.rs --- pub fn clean_write_metadata( metadata: &mut Metadata, metadata_account_info: &AccountInfo, ) -> ProgramResult { // Clear all data to ensure it is serialized cleanly with no trailing data due to creators array resizing. let mut metadata_account_info_data = metadata_account_info.try_borrow_mut_data()?; // Don't overwrite fee flag. metadata_account_info_data[0..METADATA_FEE_FLAG_INDEX].fill(0); metadata.save(&mut metadata_account_info_data)?; Ok(()) } /// --- program/src/state/metadata.rs --- // The last byte of the account contains the fee flag, indicating // if the account has fees available for retrieval. pub const METADATA_FEE_FLAG_INDEX: usize = MAX_METADATA_LEN - 1; #[repr(C)] #[cfg_attr(feature = "serde-feature", derive(Serialize, Deserialize))] #[derive(Clone, BorshSerialize, Debug, PartialEq, Eq, ShankAccount)] pub struct Metadata { /// ... } impl Metadata { pub fn save(&self, data: &mut [u8]) -> Result<(), BorshError> { let mut bytes = Vec::with_capacity(MAX_METADATA_LEN); borsh::to_writer(&mut bytes, self)?; data[..bytes.len()].copy_from_slice(&bytes); Ok(()) } /// ... } /// --- borsh-0.9.3/src/ser/helpers.rs --- /// Serializes an object directly into a `Writer`. pub fn to_writer<T, W: Write>(mut writer: W, value: &T) -> Result<()> where T: BorshSerialize + ?Sized, { value.serialize(&mut writer) }

Conclusion

Solana and Ethereum both provide robust environments for fungible tokens with comprehensive metadata. However, they differ significantly in their technical implementations, metadata storage methods.