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
, andtransferFrom
.
- 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.
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:
- Seller fee
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.
- Uses
- the usage type of the NFT. Whether it can be used multiple times or it’s just a one-time usage.
- the total number of times the NFT can be used (
total
) - the remaining times the NFT can be used (
remaining
)
The
uses
field is an optional struct of type Uses
. This struct typically includes information about:- edition_nonce
- collection
Record the NFT collection this NFT belongs to.
- collection_details
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:
- Decode all related accounts
- Transfer fee (rent + metaplex fee) to metadata account
metaplex fee can be retrieved by metaplex team
- Create metadata pda account and initialize data
- Set fee flag of the metadata account
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:
- Destructure Accounts
Destructures the
accounts
struct to extract individual account references.- Retrieve Mint Authority
Retrieves the current mint authority of the token.
- Assert Mint Authority Matches
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.- Assert Owner In SPL Token Program IDs
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.- Calculate Metadata Seeds
Calculates the seeds for the metadata account and the bump seed.
- Check Metadata Account Key
Ensures that the provided metadata account info key matches the calculated metadata key.
- Create or Allocate Metadata Account
Creates or allocates space for the metadata account.
- Deserialize Metadata
Deserializes the metadata from the account info.
- Allow Direct Creator Writes
Determines if direct writes to the creator fields are allowed.
- Validate Metadata Data
Validates the provided metadata data.
- Retrieve Mint Decimals
Retrieves the number of decimals for the mint.
- Update Metadata Fields
Updates various fields in the metadata.
- Validate Uses
Validates the uses of the metadata and updates the metadata with these uses.
- Validate Collection Update
Validates the collection update and updates the metadata with the collection information.
- Set Collection Details
Sets the collection details for the metadata.
- Set Token Standard
Determines and sets the token standard for the metadata.
- Puff Out Data Fields
Ensures the metadata fields are properly padded.
- Set Edition Nonce
Calculates and sets the edition nonce for the metadata.
- Save Metadata
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:
- 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.
- Transfer Lamports:
- If additional lamports are needed, the function calls the
invoke
function to execute a system instruction to transfer the required lamports from thepayer_info
to thenew_account_info
.
- Allocate Space:
- The function then calls
invoke_signed
to execute a system instruction to allocate the specifiedsize
of space for the new account.
- Assign the Account:
- Finally, the function calls
invoke_signed
again to execute a system instruction to assign the new account to the specifiedprogram_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:
- Length Checks:
- Ensures the
name
,symbol
, anduri
fields in thedata
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.
- Basis Points Check:
- Validates that the
seller_fee_basis_points
does not exceed 10,000 (which represents 100% in basis points).
- Creators Check:
- Handles the
creators
field, ensuring it conforms to rules regarding the number and properties of creators. - If
data.creators
isNone
, it ensures no currently verified creators are being removed without proper authorization.
- 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 theallow_direct_creator_writes
flag.
- Share Validation:
- Ensures that the total share percentage across all creators adds up to 100%.
- 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 thekey
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- V1 { size: u64 }: This variant contains the size of the collection, which indicates the number of NFTs that belong to the collection.
- 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 ofAccountInfo
objects, which include the metadata account and the update authority account.
optional_data
: An optionalDataV2
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:
- Account Deserialization:
all_account_infos!
macro extracts the relevant account info objects.metadata_account_info
andupdate_authority_info
are extracted for further processing.
- Metadata Extraction and Validation:
- The
Metadata
struct is deserialized from themetadata_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.
- 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.
- Update Authority Change:
- If a new
update_authority
is provided, it updates the metadata's update authority.
- Primary Sale Status Change:
- If
primary_sale_happened
is provided and istrue
, it updates the metadata's primary sale status.
- Mutability Status Change:
- If
is_mutable
is provided, it updates the metadata's mutability status, ensuring it can only be set tofalse
.
- 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.