diff --git a/token-metadata/js/idl/mpl_token_metadata.json b/token-metadata/js/idl/mpl_token_metadata.json index 02f0969549..94070a484a 100644 --- a/token-metadata/js/idl/mpl_token_metadata.json +++ b/token-metadata/js/idl/mpl_token_metadata.json @@ -1045,6 +1045,13 @@ "isMut": false, "isSigner": false, "desc": "MasterEdition2 Account of the Collection Token" + }, + { + "name": "collectionAuthorityRecord", + "isMut": false, + "isSigner": false, + "desc": "Collection Authority Record PDA", + "optional": true } ], "args": [], @@ -3556,6 +3563,28 @@ "type": "u8", "value": 53 } + }, + { + "name": "Collect", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true, + "desc": "Authority to collect fees" + }, + { + "name": "pdaAccount", + "isMut": false, + "isSigner": false, + "desc": "PDA to retrieve fees from" + } + ], + "args": [], + "discriminant": { + "type": "u8", + "value": 54 + } } ], "accounts": [ @@ -6573,6 +6602,21 @@ "code": 189, "name": "InvalidInstruction", "msg": "Invalid or removed instruction" + }, + { + "code": 190, + "name": "MissingDelegateRecord", + "msg": "Missing delegate record" + }, + { + "code": 191, + "name": "InvalidFeeAccount", + "msg": "" + }, + { + "code": 192, + "name": "InvalidMetadataFlags", + "msg": "" } ], "metadata": { diff --git a/token-metadata/js/src/generated/errors/index.ts b/token-metadata/js/src/generated/errors/index.ts index 7374e876d1..ddf5cd9ace 100644 --- a/token-metadata/js/src/generated/errors/index.ts +++ b/token-metadata/js/src/generated/errors/index.ts @@ -4069,6 +4069,66 @@ export class InvalidInstructionError extends Error { createErrorFromCodeLookup.set(0xbd, () => new InvalidInstructionError()); createErrorFromNameLookup.set('InvalidInstruction', () => new InvalidInstructionError()); +/** + * MissingDelegateRecord: 'Missing delegate record' + * + * @category Errors + * @category generated + */ +export class MissingDelegateRecordError extends Error { + readonly code: number = 0xbe; + readonly name: string = 'MissingDelegateRecord'; + constructor() { + super('Missing delegate record'); + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, MissingDelegateRecordError); + } + } +} + +createErrorFromCodeLookup.set(0xbe, () => new MissingDelegateRecordError()); +createErrorFromNameLookup.set('MissingDelegateRecord', () => new MissingDelegateRecordError()); + +/** + * InvalidFeeAccount: '' + * + * @category Errors + * @category generated + */ +export class InvalidFeeAccountError extends Error { + readonly code: number = 0xbf; + readonly name: string = 'InvalidFeeAccount'; + constructor() { + super(''); + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, InvalidFeeAccountError); + } + } +} + +createErrorFromCodeLookup.set(0xbf, () => new InvalidFeeAccountError()); +createErrorFromNameLookup.set('InvalidFeeAccount', () => new InvalidFeeAccountError()); + +/** + * InvalidMetadataFlags: '' + * + * @category Errors + * @category generated + */ +export class InvalidMetadataFlagsError extends Error { + readonly code: number = 0xc0; + readonly name: string = 'InvalidMetadataFlags'; + constructor() { + super(''); + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, InvalidMetadataFlagsError); + } + } +} + +createErrorFromCodeLookup.set(0xc0, () => new InvalidMetadataFlagsError()); +createErrorFromNameLookup.set('InvalidMetadataFlags', () => new InvalidMetadataFlagsError()); + /** * Attempts to resolve a custom program error from the provided error code. * @category Errors diff --git a/token-metadata/js/src/generated/instructions/Collect.ts b/token-metadata/js/src/generated/instructions/Collect.ts new file mode 100644 index 0000000000..b3c516ecb9 --- /dev/null +++ b/token-metadata/js/src/generated/instructions/Collect.ts @@ -0,0 +1,70 @@ +/** + * This code was GENERATED using the solita package. + * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. + * + * See: https://github.com/metaplex-foundation/solita + */ + +import * as beet from '@metaplex-foundation/beet'; +import * as web3 from '@solana/web3.js'; + +/** + * @category Instructions + * @category Collect + * @category generated + */ +export const CollectStruct = new beet.BeetArgsStruct<{ instructionDiscriminator: number }>( + [['instructionDiscriminator', beet.u8]], + 'CollectInstructionArgs', +); +/** + * Accounts required by the _Collect_ instruction + * + * @property [**signer**] authority Authority to collect fees + * @property [] pdaAccount PDA to retrieve fees from + * @category Instructions + * @category Collect + * @category generated + */ +export type CollectInstructionAccounts = { + authority: web3.PublicKey; + pdaAccount: web3.PublicKey; +}; + +export const collectInstructionDiscriminator = 54; + +/** + * Creates a _Collect_ instruction. + * + * @param accounts that will be accessed while the instruction is processed + * @category Instructions + * @category Collect + * @category generated + */ +export function createCollectInstruction( + accounts: CollectInstructionAccounts, + programId = new web3.PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s'), +) { + const [data] = CollectStruct.serialize({ + instructionDiscriminator: collectInstructionDiscriminator, + }); + const keys: web3.AccountMeta[] = [ + { + pubkey: accounts.authority, + isWritable: false, + isSigner: true, + }, + { + pubkey: accounts.pdaAccount, + isWritable: false, + isSigner: false, + }, + ]; + + const ix = new web3.TransactionInstruction({ + programId, + keys, + data, + }); + return ix; +} diff --git a/token-metadata/js/src/generated/instructions/VerifyCollection.ts b/token-metadata/js/src/generated/instructions/VerifyCollection.ts index ddd507fa13..c0cbf0a8e4 100644 --- a/token-metadata/js/src/generated/instructions/VerifyCollection.ts +++ b/token-metadata/js/src/generated/instructions/VerifyCollection.ts @@ -26,6 +26,7 @@ export const VerifyCollectionStruct = new beet.BeetArgsStruct<{ instructionDiscr * @property [] collectionMint Mint of the Collection * @property [] collection Metadata Account of the Collection * @property [] collectionMasterEditionAccount MasterEdition2 Account of the Collection Token + * @property [] collectionAuthorityRecord (optional) Collection Authority Record PDA * @category Instructions * @category VerifyCollection * @category generated @@ -37,6 +38,7 @@ export type VerifyCollectionInstructionAccounts = { collectionMint: web3.PublicKey; collection: web3.PublicKey; collectionMasterEditionAccount: web3.PublicKey; + collectionAuthorityRecord?: web3.PublicKey; }; export const verifyCollectionInstructionDiscriminator = 18; @@ -44,6 +46,11 @@ export const verifyCollectionInstructionDiscriminator = 18; /** * Creates a _VerifyCollection_ instruction. * + * Optional accounts that are not provided will be omitted from the accounts + * array passed with the instruction. + * An optional account that is set cannot follow an optional account that is unset. + * Otherwise an Error is raised. + * * @param accounts that will be accessed while the instruction is processed * @category Instructions * @category VerifyCollection @@ -89,6 +96,14 @@ export function createVerifyCollectionInstruction( }, ]; + if (accounts.collectionAuthorityRecord != null) { + keys.push({ + pubkey: accounts.collectionAuthorityRecord, + isWritable: false, + isSigner: false, + }); + } + const ix = new web3.TransactionInstruction({ programId, keys, diff --git a/token-metadata/js/src/generated/instructions/index.ts b/token-metadata/js/src/generated/instructions/index.ts index 2bfb8256cd..ec73b135e7 100644 --- a/token-metadata/js/src/generated/instructions/index.ts +++ b/token-metadata/js/src/generated/instructions/index.ts @@ -5,6 +5,7 @@ export * from './Burn'; export * from './BurnEditionNft'; export * from './BurnNft'; export * from './CloseEscrowAccount'; +export * from './Collect'; export * from './ConvertMasterEditionV1ToV2'; export * from './Create'; export * from './CreateEscrowAccount'; diff --git a/token-metadata/js/test/burn.test.ts b/token-metadata/js/test/burn.test.ts index 7c255325ee..476a6a761b 100644 --- a/token-metadata/js/test/burn.test.ts +++ b/token-metadata/js/test/burn.test.ts @@ -7,7 +7,7 @@ import { getAccount, TOKEN_PROGRAM_ID } from '@solana/spl-token'; killStuckProcess(); -test('Burn: NonFungible asset', async (t) => { +test.only('Burn: NonFungible asset', async (t) => { const API = new InitTransactions(); const { fstTxHandler: handler, payerPair: payer, connection } = await API.payer(); @@ -37,12 +37,12 @@ test('Burn: NonFungible asset', async (t) => { await updateTx.assertSuccess(t); - // All three accounts are closed. + // All three accounts are closed. Metadata account should have a data length of 0 but may be open if it contains fees. const metadataAccount = await connection.getAccountInfo(metadata); const editionAccount = await connection.getAccountInfo(masterEdition); const tokenAccount = await connection.getAccountInfo(token); - t.equal(metadataAccount, null); + t?.equal(metadataAccount.data.length, 0); t.equal(editionAccount, null); t.equal(tokenAccount, null); }); @@ -85,7 +85,7 @@ test('Burn: ProgrammableNonFungible asset', async (t) => { const tokenAccount = await connection.getAccountInfo(token); const tokenRecordAccount = await connection.getAccountInfo(tokenRecord); - t.equal(metadataAccount, null); + t?.equal(metadataAccount.data.length, 0); t.equal(editionAccount, null); t.equal(tokenAccount, null); t.equal(tokenRecordAccount, null); diff --git a/token-metadata/program/src/error.rs b/token-metadata/program/src/error.rs index 4c7b514af5..c7d5ee2951 100644 --- a/token-metadata/program/src/error.rs +++ b/token-metadata/program/src/error.rs @@ -753,6 +753,14 @@ pub enum MetadataError { /// 190 #[error("Missing delegate record")] MissingDelegateRecord, + + /// 191 + #[error("")] + InvalidFeeAccount, + + /// 192 + #[error("")] + InvalidMetadataFlags, } impl PrintProgramError for MetadataError { diff --git a/token-metadata/program/src/instruction/collection.rs b/token-metadata/program/src/instruction/collection.rs index 8a7e608cfc..ac6ae2adf8 100644 --- a/token-metadata/program/src/instruction/collection.rs +++ b/token-metadata/program/src/instruction/collection.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use solana_program::{ instruction::{AccountMeta, Instruction}, pubkey::Pubkey, + system_program, }; use crate::instruction::MetadataInstruction; @@ -41,7 +42,7 @@ pub fn approve_collection_authority( AccountMeta::new(payer, true), AccountMeta::new_readonly(metadata, false), AccountMeta::new_readonly(mint, false), - AccountMeta::new_readonly(solana_program::system_program::ID, false), + AccountMeta::new_readonly(system_program::ID, false), ], data: MetadataInstruction::ApproveCollectionAuthority .try_to_vec() diff --git a/token-metadata/program/src/instruction/edition.rs b/token-metadata/program/src/instruction/edition.rs index dae153b6c4..28682823f9 100644 --- a/token-metadata/program/src/instruction/edition.rs +++ b/token-metadata/program/src/instruction/edition.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use solana_program::{ instruction::{AccountMeta, Instruction}, pubkey::Pubkey, + system_program, }; use crate::{ @@ -60,7 +61,7 @@ pub fn create_master_edition_v3( AccountMeta::new(payer, true), AccountMeta::new(metadata, false), AccountMeta::new_readonly(spl_token::ID, false), - AccountMeta::new_readonly(solana_program::system_program::ID, false), + AccountMeta::new_readonly(system_program::ID, false), ]; Instruction { @@ -122,7 +123,7 @@ pub fn mint_new_edition_from_master_edition_via_token( AccountMeta::new_readonly(new_metadata_update_authority, false), AccountMeta::new_readonly(metadata, false), AccountMeta::new_readonly(spl_token::ID, false), - AccountMeta::new_readonly(solana_program::system_program::ID, false), + AccountMeta::new_readonly(system_program::ID, false), ]; Instruction { diff --git a/token-metadata/program/src/instruction/fee.rs b/token-metadata/program/src/instruction/fee.rs new file mode 100644 index 0000000000..c7dd1e22bc --- /dev/null +++ b/token-metadata/program/src/instruction/fee.rs @@ -0,0 +1,24 @@ +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; + +use crate::state::fee::FEE_AUTHORITY; + +use super::*; + +pub fn collect_fees(recipient: Pubkey, fee_accounts: Vec) -> Instruction { + let mut accounts = vec![ + AccountMeta::new(FEE_AUTHORITY, true), + AccountMeta::new(recipient, false), + ]; + + for fee_account in fee_accounts { + accounts.push(AccountMeta::new(fee_account, false)); + } + Instruction { + program_id: crate::ID, + accounts, + data: MetadataInstruction::Collect.try_to_vec().unwrap(), + } +} diff --git a/token-metadata/program/src/instruction/metadata.rs b/token-metadata/program/src/instruction/metadata.rs index 1c1ab243b9..f42ddb6eba 100644 --- a/token-metadata/program/src/instruction/metadata.rs +++ b/token-metadata/program/src/instruction/metadata.rs @@ -2,6 +2,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use solana_program::{ instruction::{AccountMeta, Instruction}, pubkey::Pubkey, + system_program, }; #[cfg(feature = "serde-feature")] use { @@ -481,7 +482,7 @@ pub fn create_metadata_accounts_v3( AccountMeta::new_readonly(mint_authority, true), AccountMeta::new(payer, true), AccountMeta::new_readonly(update_authority, update_authority_is_signer), - AccountMeta::new_readonly(solana_program::system_program::ID, false), + AccountMeta::new_readonly(system_program::ID, false), ], data: MetadataInstruction::CreateMetadataAccountV3(CreateMetadataAccountArgsV3 { data: DataV2 { diff --git a/token-metadata/program/src/instruction/mod.rs b/token-metadata/program/src/instruction/mod.rs index e0ddc52f99..8b4717ec1d 100644 --- a/token-metadata/program/src/instruction/mod.rs +++ b/token-metadata/program/src/instruction/mod.rs @@ -4,6 +4,7 @@ mod collection; mod delegate; mod edition; pub(crate) mod escrow; +mod fee; mod freeze; mod metadata; mod state; @@ -17,6 +18,7 @@ pub use collection::*; pub use delegate::*; pub use edition::*; pub use escrow::*; +pub use fee::collect_fees; pub use freeze::*; pub use metadata::*; use mpl_token_metadata_context_derive::AccountContext; @@ -266,6 +268,7 @@ pub enum MetadataInstruction { #[account(3, name="collection_mint", desc="Mint of the Collection")] #[account(4, name="collection", desc="Metadata Account of the Collection")] #[account(5, name="collection_master_edition_account", desc="MasterEdition2 Account of the Collection Token")] + #[account(6, optional, name="collection_authority_record", desc="Collection Authority Record PDA")] VerifyCollection, /// Utilize or Use an NFT , burns the NFT and returns the lamports to the update authority if the use method is burn and its out of uses. @@ -786,6 +789,11 @@ pub enum MetadataInstruction { #[account(6, name="sysvar_instructions", desc="Instructions sysvar account")] #[default_optional_accounts] Unverify(VerificationArgs), + + /// Collect fees stored on PDA accounts. + #[account(0, signer, name="authority", desc="Authority to collect fees")] + #[account(1, name="pda_account", desc="PDA to retrieve fees from")] + Collect, } pub struct Context<'a, T> { diff --git a/token-metadata/program/src/instruction/uses.rs b/token-metadata/program/src/instruction/uses.rs index 4d1b3c55a6..f86a2accff 100644 --- a/token-metadata/program/src/instruction/uses.rs +++ b/token-metadata/program/src/instruction/uses.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use solana_program::{ instruction::{AccountMeta, Instruction}, pubkey::Pubkey, + system_program, }; use crate::{instruction::MetadataInstruction, processor::AuthorizationData}; @@ -78,7 +79,7 @@ pub fn approve_use_authority( AccountMeta::new_readonly(mint, false), AccountMeta::new_readonly(burner, false), AccountMeta::new_readonly(spl_token::ID, false), - AccountMeta::new_readonly(solana_program::system_program::ID, false), + AccountMeta::new_readonly(system_program::ID, false), ], data: MetadataInstruction::ApproveUseAuthority(ApproveUseAuthorityArgs { number_of_uses }) .try_to_vec() @@ -122,7 +123,7 @@ pub fn revoke_use_authority( AccountMeta::new_readonly(mint, false), AccountMeta::new_readonly(metadata, false), AccountMeta::new_readonly(spl_token::ID, false), - AccountMeta::new_readonly(solana_program::system_program::ID, false), + AccountMeta::new_readonly(system_program::ID, false), ], data: MetadataInstruction::RevokeUseAuthority .try_to_vec() @@ -168,7 +169,7 @@ pub fn utilize( AccountMeta::new_readonly(owner, false), AccountMeta::new_readonly(spl_token::ID, false), AccountMeta::new_readonly(spl_associated_token_account::ID, false), - AccountMeta::new_readonly(solana_program::system_program::ID, false), + AccountMeta::new_readonly(system_program::ID, false), ]; if let Some(use_authority_record_pda) = use_authority_record_pda { accounts.push(AccountMeta::new(use_authority_record_pda, false)); diff --git a/token-metadata/program/src/processor/burn/burn.rs b/token-metadata/program/src/processor/burn/burn.rs index 9d7b096ca5..bac3f43fad 100644 --- a/token-metadata/program/src/processor/burn/burn.rs +++ b/token-metadata/program/src/processor/burn/burn.rs @@ -229,6 +229,7 @@ fn burn_v1(program_id: &Pubkey, ctx: Context, args: BurnArgs) -> ProgramRe close_program_account( &ctx.accounts.token_record_info.unwrap().clone(), &ctx.accounts.authority_info.clone(), + Key::TokenRecord, )?; } TokenStandard::Fungible | TokenStandard::FungibleAsset => { diff --git a/token-metadata/program/src/processor/burn/burn_nft.rs b/token-metadata/program/src/processor/burn/burn_nft.rs index 7289c3a160..a433092899 100644 --- a/token-metadata/program/src/processor/burn/burn_nft.rs +++ b/token-metadata/program/src/processor/burn/burn_nft.rs @@ -26,12 +26,7 @@ pub fn process_burn_nft<'a>(program_id: &Pubkey, accounts: &'a [AccountInfo<'a>] let edition_info = next_account_info(account_info_iter)?; let spl_token_program_info = next_account_info(account_info_iter)?; - let collection_nft_provided = accounts.len() == 7; - let collection_metadata_info = if collection_nft_provided { - Some(next_account_info(account_info_iter)?) - } else { - None - }; + let collection_metadata_info = account_info_iter.next(); // Validate accounts diff --git a/token-metadata/program/src/processor/burn/nonfungible.rs b/token-metadata/program/src/processor/burn/nonfungible.rs index 5e9d283926..f417e0f91e 100644 --- a/token-metadata/program/src/processor/burn/nonfungible.rs +++ b/token-metadata/program/src/processor/burn/nonfungible.rs @@ -96,8 +96,16 @@ pub(crate) fn burn_nonfungible(ctx: &Context, args: BurnNonFungibleArgs) - // CPIs panic if there's an error so unwrapping is fine here. mpl_utils::token::spl_token_close(close_params).unwrap(); - close_program_account(ctx.accounts.metadata_info, ctx.accounts.authority_info)?; - close_program_account(edition_info, ctx.accounts.authority_info)?; + close_program_account( + ctx.accounts.metadata_info, + ctx.accounts.authority_info, + Key::MetadataV1, + )?; + close_program_account( + edition_info, + ctx.accounts.authority_info, + Key::MasterEditionV2, + )?; if let Some(collection_metadata_info) = ctx.accounts.collection_metadata_info { if collection_metadata_info.data_is_empty() { diff --git a/token-metadata/program/src/processor/burn/nonfungible_edition.rs b/token-metadata/program/src/processor/burn/nonfungible_edition.rs index 0dfa0f5fbb..60b2b9b309 100644 --- a/token-metadata/program/src/processor/burn/nonfungible_edition.rs +++ b/token-metadata/program/src/processor/burn/nonfungible_edition.rs @@ -124,8 +124,12 @@ pub(crate) fn burn_nonfungible_edition(ctx: &Context) -> ProgramResult { }; spl_token_close(params)?; - close_program_account(ctx.accounts.metadata_info, ctx.accounts.authority_info)?; - close_program_account(edition_info, ctx.accounts.authority_info)?; + close_program_account( + ctx.accounts.metadata_info, + ctx.accounts.authority_info, + Key::MetadataV1, + )?; + close_program_account(edition_info, ctx.accounts.authority_info, Key::EditionV1)?; // **EDITION HOUSEKEEPING** // Set the particular bit for this edition to 0 to allow reprinting, @@ -143,7 +147,11 @@ pub(crate) fn burn_nonfungible_edition(ctx: &Context) -> ProgramResult { // If the entire edition marker is empty, then we can close the account. // Otherwise, serialize the new edition marker and update the account data. if edition_marker.ledger.iter().all(|i| *i == 0) { - close_program_account(edition_marker_info, ctx.accounts.authority_info)?; + close_program_account( + edition_marker_info, + ctx.accounts.authority_info, + Key::EditionMarker, + )?; } else { edition_marker.save(edition_marker_info)?; } diff --git a/token-metadata/program/src/processor/collection/revoke_collection_authority.rs b/token-metadata/program/src/processor/collection/revoke_collection_authority.rs index c4844f947d..87a64b7e83 100644 --- a/token-metadata/program/src/processor/collection/revoke_collection_authority.rs +++ b/token-metadata/program/src/processor/collection/revoke_collection_authority.rs @@ -8,7 +8,7 @@ use solana_program::{ use crate::{ assertions::{assert_owned_by, collection::assert_is_collection_delegated_authority}, error::MetadataError, - state::{Metadata, TokenMetadataAccount}, + state::{Key, Metadata, TokenMetadataAccount}, utils::close_program_account, }; @@ -48,5 +48,9 @@ pub fn process_revoke_collection_authority( mint_info.key, )?; - close_program_account(collection_authority_record, revoke_authority) + close_program_account( + collection_authority_record, + revoke_authority, + Key::CollectionAuthorityRecord, + ) } diff --git a/token-metadata/program/src/processor/collection/set_and_verify_collection.rs b/token-metadata/program/src/processor/collection/set_and_verify_collection.rs index 6857543efe..004b88d1fa 100644 --- a/token-metadata/program/src/processor/collection/set_and_verify_collection.rs +++ b/token-metadata/program/src/processor/collection/set_and_verify_collection.rs @@ -1,4 +1,3 @@ -use borsh::BorshSerialize; use mpl_utils::assert_signer; use solana_program::{ account_info::{next_account_info, AccountInfo}, @@ -13,10 +12,12 @@ use crate::{ }, error::MetadataError, state::{Collection, Metadata, TokenMetadataAccount}, + utils::clean_write_metadata, }; pub fn set_and_verify_collection(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { let account_info_iter = &mut accounts.iter(); + let metadata_info = next_account_info(account_info_iter)?; let collection_authority_info = next_account_info(account_info_iter)?; let payer_info = next_account_info(account_info_iter)?; @@ -24,7 +25,6 @@ pub fn set_and_verify_collection(program_id: &Pubkey, accounts: &[AccountInfo]) let collection_mint = next_account_info(account_info_iter)?; let collection_info = next_account_info(account_info_iter)?; let edition_account_info = next_account_info(account_info_iter)?; - let using_delegated_collection_authority = accounts.len() == 8; assert_signer(collection_authority_info)?; assert_signer(payer_info)?; @@ -50,22 +50,15 @@ pub fn set_and_verify_collection(program_id: &Pubkey, accounts: &[AccountInfo]) } } - if using_delegated_collection_authority { - let collection_authority_record = next_account_info(account_info_iter)?; - assert_has_collection_authority( - collection_authority_info, - &collection_data, - collection_mint.key, - Some(collection_authority_record), - )?; - } else { - assert_has_collection_authority( - collection_authority_info, - &collection_data, - collection_mint.key, - None, - )?; - } + let delegated_collection_authority_opt = account_info_iter.next(); + + assert_has_collection_authority( + collection_authority_info, + &collection_data, + collection_mint.key, + delegated_collection_authority_opt, + )?; + metadata.collection = Some(Collection { key: *collection_mint.key, verified: true, @@ -82,6 +75,5 @@ pub fn set_and_verify_collection(program_id: &Pubkey, accounts: &[AccountInfo]) return Err(MetadataError::SizedCollection.into()); } - metadata.serialize(&mut *metadata_info.try_borrow_mut_data()?)?; - Ok(()) + clean_write_metadata(&mut metadata, metadata_info) } diff --git a/token-metadata/program/src/processor/collection/set_and_verify_sized_collection_item.rs b/token-metadata/program/src/processor/collection/set_and_verify_sized_collection_item.rs index 789aff5483..f7a606a55d 100644 --- a/token-metadata/program/src/processor/collection/set_and_verify_sized_collection_item.rs +++ b/token-metadata/program/src/processor/collection/set_and_verify_sized_collection_item.rs @@ -20,6 +20,7 @@ pub fn set_and_verify_sized_collection_item( accounts: &[AccountInfo], ) -> ProgramResult { let account_info_iter = &mut accounts.iter(); + let metadata_info = next_account_info(account_info_iter)?; let collection_authority_info = next_account_info(account_info_iter)?; let payer_info = next_account_info(account_info_iter)?; @@ -27,7 +28,6 @@ pub fn set_and_verify_sized_collection_item( let collection_mint = next_account_info(account_info_iter)?; let collection_info = next_account_info(account_info_iter)?; let edition_account_info = next_account_info(account_info_iter)?; - let using_delegated_collection_authority = accounts.len() == 8; assert_signer(collection_authority_info)?; assert_signer(payer_info)?; @@ -53,22 +53,15 @@ pub fn set_and_verify_sized_collection_item( return Err(MetadataError::UpdateAuthorityIncorrect.into()); } - if using_delegated_collection_authority { - let collection_authority_record = next_account_info(account_info_iter)?; - assert_has_collection_authority( - collection_authority_info, - &collection_metadata, - collection_mint.key, - Some(collection_authority_record), - )?; - } else { - assert_has_collection_authority( - collection_authority_info, - &collection_metadata, - collection_mint.key, - None, - )?; - } + let delegated_collection_authority_opt = account_info_iter.next(); + + assert_has_collection_authority( + collection_authority_info, + &collection_metadata, + collection_mint.key, + delegated_collection_authority_opt, + )?; + metadata.collection = Some(Collection { key: *collection_mint.key, verified: true, @@ -83,7 +76,5 @@ pub fn set_and_verify_sized_collection_item( // Update the collection size if this is a valid parent collection NFT. increment_collection_size(&mut collection_metadata, collection_info)?; - clean_write_metadata(&mut metadata, metadata_info)?; - - Ok(()) + clean_write_metadata(&mut metadata, metadata_info) } diff --git a/token-metadata/program/src/processor/collection/set_collection_size.rs b/token-metadata/program/src/processor/collection/set_collection_size.rs index caff2fa0fd..50a3a6f87d 100644 --- a/token-metadata/program/src/processor/collection/set_collection_size.rs +++ b/token-metadata/program/src/processor/collection/set_collection_size.rs @@ -25,8 +25,6 @@ pub fn set_collection_size( let collection_update_authority_account_info = next_account_info(account_info_iter)?; let collection_mint_account_info = next_account_info(account_info_iter)?; - let using_delegated_collection_authority = accounts.len() == 4; - // Owned by token-metadata program. assert_owned_by(parent_nft_metadata_account_info, program_id)?; @@ -40,22 +38,14 @@ pub fn set_collection_size( return Err(MetadataError::UpdateAuthorityIsNotSigner.into()); } - if using_delegated_collection_authority { - let collection_authority_record = next_account_info(account_info_iter)?; - assert_has_collection_authority( - collection_update_authority_account_info, - &metadata, - collection_mint_account_info.key, - Some(collection_authority_record), - )?; - } else { - assert_has_collection_authority( - collection_update_authority_account_info, - &metadata, - collection_mint_account_info.key, - None, - )?; - } + let delegated_collection_authority_opt = account_info_iter.next(); + + assert_has_collection_authority( + collection_update_authority_account_info, + &metadata, + collection_mint_account_info.key, + delegated_collection_authority_opt, + )?; // Only unsized collections can have the size set, and only once. if metadata.collection_details.is_some() { @@ -64,6 +54,5 @@ pub fn set_collection_size( metadata.collection_details = Some(CollectionDetails::V1 { size }); } - clean_write_metadata(&mut metadata, parent_nft_metadata_account_info)?; - Ok(()) + clean_write_metadata(&mut metadata, parent_nft_metadata_account_info) } diff --git a/token-metadata/program/src/processor/collection/unverify_collection.rs b/token-metadata/program/src/processor/collection/unverify_collection.rs index 0f38a52ab8..41f3db48a8 100644 --- a/token-metadata/program/src/processor/collection/unverify_collection.rs +++ b/token-metadata/program/src/processor/collection/unverify_collection.rs @@ -17,14 +17,13 @@ use crate::{ pub fn unverify_collection(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { let account_info_iter = &mut accounts.iter(); + let metadata_info = next_account_info(account_info_iter)?; let collection_authority_info = next_account_info(account_info_iter)?; let collection_mint_info = next_account_info(account_info_iter)?; let collection_metadata_info = next_account_info(account_info_iter)?; let _edition_account_info = next_account_info(account_info_iter)?; - let using_delegated_collection_authority = accounts.len() == 6; - // Account validation. assert_owned_by(metadata_info, program_id)?; assert_signer(collection_authority_info)?; @@ -86,27 +85,17 @@ pub fn unverify_collection(program_id: &Pubkey, accounts: &[AccountInfo]) -> Pro return Err(MetadataError::SizedCollection.into()); } - if using_delegated_collection_authority { - let collection_authority_record = next_account_info(account_info_iter)?; - assert_has_collection_authority( - collection_authority_info, - &collection_metadata, - collection_mint_info.key, - Some(collection_authority_record), - )?; - } else { - assert_has_collection_authority( - collection_authority_info, - &collection_metadata, - collection_mint_info.key, - None, - )?; - } + let delegated_collection_authority_opt = account_info_iter.next(); + + assert_has_collection_authority( + collection_authority_info, + &collection_metadata, + collection_mint_info.key, + delegated_collection_authority_opt, + )?; } // Unverify and update the metadata collection.verified = false; - clean_write_metadata(&mut metadata, metadata_info)?; - - Ok(()) + clean_write_metadata(&mut metadata, metadata_info) } diff --git a/token-metadata/program/src/processor/collection/unverify_sized_collection_item.rs b/token-metadata/program/src/processor/collection/unverify_sized_collection_item.rs index e54bb3a38d..cd93969782 100644 --- a/token-metadata/program/src/processor/collection/unverify_sized_collection_item.rs +++ b/token-metadata/program/src/processor/collection/unverify_sized_collection_item.rs @@ -20,6 +20,7 @@ pub fn unverify_sized_collection_item( accounts: &[AccountInfo], ) -> ProgramResult { let account_info_iter = &mut accounts.iter(); + let metadata_info = next_account_info(account_info_iter)?; let collection_authority_info = next_account_info(account_info_iter)?; let payer_info = next_account_info(account_info_iter)?; @@ -27,8 +28,6 @@ pub fn unverify_sized_collection_item( let collection_metadata_info = next_account_info(account_info_iter)?; let _edition_account_info = next_account_info(account_info_iter)?; - let using_delegated_collection_authority = accounts.len() == 7; - assert_signer(collection_authority_info)?; assert_signer(payer_info)?; @@ -82,26 +81,17 @@ pub fn unverify_sized_collection_item( // Now we can deserialize the collection metadata account. let mut collection_metadata = Metadata::from_account_info(collection_metadata_info)?; - if using_delegated_collection_authority { - let collection_authority_record = next_account_info(account_info_iter)?; - assert_has_collection_authority( - collection_authority_info, - &collection_metadata, - collection_mint_info.key, - Some(collection_authority_record), - )?; - } else { - assert_has_collection_authority( - collection_authority_info, - &collection_metadata, - collection_mint_info.key, - None, - )?; - } + let delegated_collection_authority_opt = account_info_iter.next(); + + assert_has_collection_authority( + collection_authority_info, + &collection_metadata, + collection_mint_info.key, + delegated_collection_authority_opt, + )?; decrement_collection_size(&mut collection_metadata, collection_metadata_info)?; } collection.verified = false; - clean_write_metadata(&mut metadata, metadata_info)?; - Ok(()) + clean_write_metadata(&mut metadata, metadata_info) } diff --git a/token-metadata/program/src/processor/collection/verify_collection.rs b/token-metadata/program/src/processor/collection/verify_collection.rs index 05935d81ef..200b781b22 100644 --- a/token-metadata/program/src/processor/collection/verify_collection.rs +++ b/token-metadata/program/src/processor/collection/verify_collection.rs @@ -1,4 +1,3 @@ -use borsh::BorshSerialize; use mpl_utils::assert_signer; use solana_program::{ account_info::{next_account_info, AccountInfo}, @@ -13,17 +12,19 @@ use crate::{ }, error::MetadataError, state::{Metadata, TokenMetadataAccount}, + utils::clean_write_metadata, }; pub fn verify_collection(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { let account_info_iter = &mut accounts.iter(); + let metadata_info = next_account_info(account_info_iter)?; let collection_authority_info = next_account_info(account_info_iter)?; let payer_info = next_account_info(account_info_iter)?; let collection_mint = next_account_info(account_info_iter)?; let collection_info = next_account_info(account_info_iter)?; let edition_account_info = next_account_info(account_info_iter)?; - let using_delegated_collection_authority = accounts.len() == 7; + assert_signer(collection_authority_info)?; assert_signer(payer_info)?; @@ -42,22 +43,14 @@ pub fn verify_collection(program_id: &Pubkey, accounts: &[AccountInfo]) -> Progr edition_account_info, )?; - if using_delegated_collection_authority { - let collection_authority_record = next_account_info(account_info_iter)?; - assert_has_collection_authority( - collection_authority_info, - &collection_metadata, - collection_mint.key, - Some(collection_authority_record), - )?; - } else { - assert_has_collection_authority( - collection_authority_info, - &collection_metadata, - collection_mint.key, - None, - )?; - } + let delegated_collection_authority_opt = account_info_iter.next(); + + assert_has_collection_authority( + collection_authority_info, + &collection_metadata, + collection_mint.key, + delegated_collection_authority_opt, + )?; // This handler can only verify non-sized NFTs if collection_metadata.collection_details.is_some() { @@ -67,7 +60,8 @@ pub fn verify_collection(program_id: &Pubkey, accounts: &[AccountInfo]) -> Progr // If the NFT has collection data, we set it to be verified if let Some(collection) = &mut metadata.collection { collection.verified = true; - metadata.serialize(&mut *metadata_info.try_borrow_mut_data()?)?; + clean_write_metadata(&mut metadata, metadata_info)?; } + Ok(()) } diff --git a/token-metadata/program/src/processor/collection/verify_sized_collection_item.rs b/token-metadata/program/src/processor/collection/verify_sized_collection_item.rs index 772f0a5b56..c7c0e2192b 100644 --- a/token-metadata/program/src/processor/collection/verify_sized_collection_item.rs +++ b/token-metadata/program/src/processor/collection/verify_sized_collection_item.rs @@ -20,13 +20,13 @@ pub fn verify_sized_collection_item( accounts: &[AccountInfo], ) -> ProgramResult { let account_info_iter = &mut accounts.iter(); + let metadata_info = next_account_info(account_info_iter)?; let collection_authority_info = next_account_info(account_info_iter)?; let payer_info = next_account_info(account_info_iter)?; let collection_mint = next_account_info(account_info_iter)?; let collection_info = next_account_info(account_info_iter)?; let edition_account_info = next_account_info(account_info_iter)?; - let using_delegated_collection_authority = accounts.len() == 7; assert_signer(collection_authority_info)?; assert_signer(payer_info)?; @@ -53,22 +53,14 @@ pub fn verify_sized_collection_item( edition_account_info, )?; - if using_delegated_collection_authority { - let collection_authority_record = next_account_info(account_info_iter)?; - assert_has_collection_authority( - collection_authority_info, - &collection_metadata, - collection_mint.key, - Some(collection_authority_record), - )?; - } else { - assert_has_collection_authority( - collection_authority_info, - &collection_metadata, - collection_mint.key, - None, - )?; - } + let delegated_collection_authority_opt = account_info_iter.next(); + + assert_has_collection_authority( + collection_authority_info, + &collection_metadata, + collection_mint.key, + delegated_collection_authority_opt, + )?; // If the NFT has unverified collection data, we set it to be verified and then update the collection // size on the Collection Parent. @@ -80,5 +72,6 @@ pub fn verify_sized_collection_item( } else { return Err(MetadataError::CollectionNotFound.into()); } + Ok(()) } diff --git a/token-metadata/program/src/processor/fee/mod.rs b/token-metadata/program/src/processor/fee/mod.rs new file mode 100644 index 0000000000..dda686a961 --- /dev/null +++ b/token-metadata/program/src/processor/fee/mod.rs @@ -0,0 +1,77 @@ +use mpl_utils::assert_signer; +use num_traits::FromPrimitive; +use solana_program::{account_info::next_account_info, rent::Rent, system_program, sysvar::Sysvar}; + +use crate::{ + state::{fee::FEE_AUTHORITY, MAX_METADATA_LEN}, + utils::fee::clear_fee_flag, +}; + +use super::*; + +pub(crate) fn process_collect_fees(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + + let authority_info = next_account_info(account_info_iter)?; + + assert_signer(authority_info)?; + + if *authority_info.key != FEE_AUTHORITY { + return Err(MetadataError::UpdateAuthorityIncorrect.into()); + } + + let recipient_info = next_account_info(account_info_iter)?; + + for account_info in account_info_iter { + if account_info.owner != program_id { + return Err(MetadataError::InvalidFeeAccount.into()); + } + + collect_fee_from_account(account_info, recipient_info)?; + } + + Ok(()) +} + +fn collect_fee_from_account(account_info: &AccountInfo, dest_info: &AccountInfo) -> ProgramResult { + // Scope refcell borrow + let account_key = { + let data = account_info.data.borrow(); + + // Burned accounts with fees will have no data, so should be assigned the `Uninitialized` key. + let key_byte = data.first().unwrap_or(&0); + + FromPrimitive::from_u8(*key_byte).ok_or(MetadataError::InvalidFeeAccount)? + }; + + let rent = Rent::get()?; + let metadata_rent = rent.minimum_balance(MAX_METADATA_LEN); + + let (fee_amount, rent_amount) = match account_key { + Key::Uninitialized => { + account_info.assign(&system_program::ID); + + (account_info.lamports(), 0) + } + Key::MetadataV1 => { + let fee_amount = account_info + .lamports() + .checked_sub(metadata_rent) + .ok_or(MetadataError::NumericalOverflowError)?; + + (fee_amount, metadata_rent) + } + _ => return Err(MetadataError::InvalidFeeAccount.into()), + }; + + let dest_starting_lamports = dest_info.lamports(); + **dest_info.lamports.borrow_mut() = dest_starting_lamports + .checked_add(fee_amount) + .ok_or(MetadataError::NumericalOverflowError)?; + **account_info.lamports.borrow_mut() = rent_amount; + + // Clear fee flag. + clear_fee_flag(account_info)?; + + Ok(()) +} diff --git a/token-metadata/program/src/processor/metadata/create.rs b/token-metadata/program/src/processor/metadata/create.rs index 8ab3a7953a..bfccac3d7a 100644 --- a/token-metadata/program/src/processor/metadata/create.rs +++ b/token-metadata/program/src/processor/metadata/create.rs @@ -13,8 +13,9 @@ use crate::{ TOKEN_STANDARD_INDEX, }, utils::{ - create_master_edition, process_create_metadata_accounts_logic, - CreateMetadataAccountsLogicArgs, + create_master_edition, + fee::{levy, set_fee_flag, LevyArgs}, + process_create_metadata_accounts_logic, CreateMetadataAccountsLogicArgs, }, }; @@ -48,6 +49,12 @@ fn create_v1(program_id: &Pubkey, ctx: Context, args: CreateArgs) -> Pro return Err(MetadataError::InvalidTokenStandard.into()); } + // Levy fees first, to fund the metadata account with rent + fee amount. + levy(LevyArgs { + payer_account_info: ctx.accounts.payer_info, + token_metadata_pda_info: ctx.accounts.metadata_info, + })?; + // if the account does not exist, we will allocate a new mint if ctx.accounts.mint_info.data_is_empty() { @@ -205,5 +212,6 @@ fn create_v1(program_id: &Pubkey, ctx: Context, args: CreateArgs) -> Pro // saves the metadata state metadata.save(&mut ctx.accounts.metadata_info.try_borrow_mut_data()?)?; - Ok(()) + // Set fee flag after metadata account is created. + set_fee_flag(ctx.accounts.metadata_info) } diff --git a/token-metadata/program/src/processor/metadata/create_medatata_accounts_v3.rs b/token-metadata/program/src/processor/metadata/create_medatata_accounts_v3.rs index a478b2faf9..ba6cd98e43 100644 --- a/token-metadata/program/src/processor/metadata/create_medatata_accounts_v3.rs +++ b/token-metadata/program/src/processor/metadata/create_medatata_accounts_v3.rs @@ -6,7 +6,10 @@ use solana_program::{ use crate::{ state::{CollectionDetails, DataV2}, - utils::{process_create_metadata_accounts_logic, CreateMetadataAccountsLogicArgs}, + utils::{ + fee::{levy, set_fee_flag, LevyArgs}, + process_create_metadata_accounts_logic, CreateMetadataAccountsLogicArgs, + }, }; pub fn process_create_metadata_accounts_v3<'a>( @@ -24,6 +27,12 @@ pub fn process_create_metadata_accounts_v3<'a>( let update_authority_info = next_account_info(account_info_iter)?; let system_account_info = next_account_info(account_info_iter)?; + // Levy fees first, to fund the metadata account with rent + fee amount. + levy(LevyArgs { + payer_account_info, + token_metadata_pda_info: metadata_account_info, + })?; + process_create_metadata_accounts_logic( program_id, CreateMetadataAccountsLogicArgs { @@ -40,5 +49,8 @@ pub fn process_create_metadata_accounts_v3<'a>( false, true, collection_details, - ) + )?; + + // Set fee flag after metadata account is created. + set_fee_flag(metadata_account_info) } diff --git a/token-metadata/program/src/processor/metadata/set_token_standard.rs b/token-metadata/program/src/processor/metadata/set_token_standard.rs index f26e668aab..19353c4f3e 100644 --- a/token-metadata/program/src/processor/metadata/set_token_standard.rs +++ b/token-metadata/program/src/processor/metadata/set_token_standard.rs @@ -32,25 +32,25 @@ pub fn process_set_token_standard(program_id: &Pubkey, accounts: &[AccountInfo]) // Update authority is a signer and matches update authority on metadata. assert_update_authority_is_correct(&metadata, update_authority_account_info)?; - // Edition account provided. - let token_standard = if accounts.len() == 4 { - let edition_account_info = next_account_info(account_info_iter)?; + let edition_info_opt = account_info_iter.next(); + // Edition account provided. + let token_standard = if let Some(edition_info) = edition_info_opt { let edition_path = Vec::from([ PREFIX.as_bytes(), program_id.as_ref(), mint_account_info.key.as_ref(), EDITION.as_bytes(), ]); - assert_owned_by(edition_account_info, program_id)?; - assert_derivation(program_id, edition_account_info, &edition_path)?; - check_token_standard(mint_account_info, Some(edition_account_info))? + assert_owned_by(edition_info, program_id)?; + assert_derivation(program_id, edition_info, &edition_path)?; + + check_token_standard(mint_account_info, Some(edition_info))? } else { check_token_standard(mint_account_info, None)? }; metadata.token_standard = Some(token_standard); - clean_write_metadata(&mut metadata, metadata_account_info)?; - Ok(()) + clean_write_metadata(&mut metadata, metadata_account_info) } diff --git a/token-metadata/program/src/processor/metadata/transfer.rs b/token-metadata/program/src/processor/metadata/transfer.rs index ba34e9c813..b3c2aa4e0b 100644 --- a/token-metadata/program/src/processor/metadata/transfer.rs +++ b/token-metadata/program/src/processor/metadata/transfer.rs @@ -24,8 +24,8 @@ use crate::{ instruction::{Context, Transfer, TransferArgs}, pda::find_token_record_account, state::{ - AuthorityRequest, AuthorityResponse, AuthorityType, Metadata, Operation, TokenDelegateRole, - TokenMetadataAccount, TokenRecord, TokenStandard, + AuthorityRequest, AuthorityResponse, AuthorityType, Key, Metadata, Operation, + TokenDelegateRole, TokenMetadataAccount, TokenRecord, TokenStandard, }, utils::{ assert_derivation, auth_rules_validate, clear_close_authority, close_program_account, @@ -449,7 +449,11 @@ fn transfer_v1(program_id: &Pubkey, ctx: Context, args: TransferArgs) // Close the source Token Record account, but do it after the CPI calls // so as to avoid Unbalanced Accounts errors due to the CPI context not knowing // about the manual lamport math done here. - close_program_account(owner_token_record_info, ctx.accounts.payer_info)?; + close_program_account( + owner_token_record_info, + ctx.accounts.payer_info, + Key::TokenRecord, + )?; } } _ => mpl_utils::token::spl_token_transfer(token_transfer_params).unwrap(), diff --git a/token-metadata/program/src/processor/metadata/update_metadata_account_v2.rs b/token-metadata/program/src/processor/metadata/update_metadata_account_v2.rs index c575849d7b..759927cda6 100644 --- a/token-metadata/program/src/processor/metadata/update_metadata_account_v2.rs +++ b/token-metadata/program/src/processor/metadata/update_metadata_account_v2.rs @@ -90,6 +90,5 @@ pub fn process_update_metadata_accounts_v2( } puff_out_data_fields(&mut metadata); - clean_write_metadata(&mut metadata, metadata_account_info)?; - Ok(()) + clean_write_metadata(&mut metadata, metadata_account_info) } diff --git a/token-metadata/program/src/processor/mod.rs b/token-metadata/program/src/processor/mod.rs index 05b92aaa73..278b0987c0 100644 --- a/token-metadata/program/src/processor/mod.rs +++ b/token-metadata/program/src/processor/mod.rs @@ -4,6 +4,7 @@ mod collection; mod delegate; mod edition; pub(crate) mod escrow; +mod fee; mod freeze; mod metadata; mod state; @@ -162,6 +163,7 @@ pub fn process_instruction<'a>( msg!("IX: Unverify"); verification::unverify(program_id, accounts, args) } + MetadataInstruction::Collect => fee::process_collect_fees(program_id, accounts), _ => { // pNFT accounts can only be used by the "new" API; before forwarding // the transaction to the "legacy" processor we determine whether we are diff --git a/token-metadata/program/src/processor/uses/revoke_use_authority.rs b/token-metadata/program/src/processor/uses/revoke_use_authority.rs index bb58dd5bb1..310467a02a 100644 --- a/token-metadata/program/src/processor/uses/revoke_use_authority.rs +++ b/token-metadata/program/src/processor/uses/revoke_use_authority.rs @@ -16,7 +16,7 @@ use crate::{ }, }, error::MetadataError, - state::{Metadata, TokenMetadataAccount, UseAuthorityRecord, UseMethod}, + state::{Key, Metadata, TokenMetadataAccount, UseAuthorityRecord, UseMethod}, utils::close_program_account, }; @@ -83,5 +83,9 @@ pub fn process_revoke_use_authority( // Drop use_authority_record_info account data borrow. drop(data); - close_program_account(use_authority_record_info, owner_info) + close_program_account( + use_authority_record_info, + owner_info, + Key::UseAuthorityRecord, + ) } diff --git a/token-metadata/program/src/processor/uses/utilize.rs b/token-metadata/program/src/processor/uses/utilize.rs index 97f56ce902..715a1bf6f3 100644 --- a/token-metadata/program/src/processor/uses/utilize.rs +++ b/token-metadata/program/src/processor/uses/utilize.rs @@ -30,6 +30,7 @@ pub fn process_utilize( number_of_uses: u64, ) -> ProgramResult { let account_info_iter = &mut accounts.iter().peekable(); + let metadata_info = next_account_info(account_info_iter)?; let token_account_info = next_account_info(account_info_iter)?; let mint_info = next_account_info(account_info_iter)?; @@ -37,7 +38,8 @@ pub fn process_utilize( let owner_info = next_account_info(account_info_iter)?; let token_program_account_info = next_account_info(account_info_iter)?; let _ata_program_account_info = next_account_info(account_info_iter)?; - let _system_account_info = next_account_info(account_info_iter)?; + let _system_program_account_info = next_account_info(account_info_iter)?; + // consume the next account only if it is Rent let approved_authority_is_using = if account_info_iter .next_if(|info| info.key == &Rent::id()) @@ -51,6 +53,7 @@ pub fn process_utilize( }; let metadata: Metadata = Metadata::from_account_info(metadata_info)?; + if metadata.uses.is_none() { return Err(MetadataError::Unusable.into()); } diff --git a/token-metadata/program/src/state/fee.rs b/token-metadata/program/src/state/fee.rs new file mode 100644 index 0000000000..ad9123b3ff --- /dev/null +++ b/token-metadata/program/src/state/fee.rs @@ -0,0 +1,9 @@ +use super::*; + +pub(crate) const FEE_AUTHORITY: Pubkey = pubkey!("Levytx9LLPzAtDJJD7q813Zsm8zg9e1pb53mGxTKpD7"); + +// create_metadata_accounts_v3, create, print edition commands +pub const CREATE_FEE: u64 = 10_000_000; + +pub const FEE_FLAG_SET: u8 = 1; +pub const FEE_FLAG_CLEARED: u8 = 0; diff --git a/token-metadata/program/src/state/master_edition.rs b/token-metadata/program/src/state/master_edition.rs index a41b7b3bf1..0a516f223b 100644 --- a/token-metadata/program/src/state/master_edition.rs +++ b/token-metadata/program/src/state/master_edition.rs @@ -10,6 +10,10 @@ pub const MAX_MASTER_EDITION_LEN: usize = 1 + 9 + 8 + 264; // edition account. pub const TOKEN_STANDARD_INDEX: usize = MAX_MASTER_EDITION_LEN - 1; +// The second to last byte of the account contains the fee flag, indicating +// if the account has fees available for retrieval. +pub const MASTER_EDITION_FEE_FLAG_INDEX: usize = MAX_MASTER_EDITION_LEN - 2; + pub trait MasterEdition { fn key(&self) -> Key; fn supply(&self) -> u64; @@ -106,7 +110,7 @@ impl MasterEdition for MasterEditionV2 { } fn save(&self, account: &AccountInfo) -> ProgramResult { - let mut storage = &mut account.data.borrow_mut()[..TOKEN_STANDARD_INDEX]; + let mut storage = &mut account.data.borrow_mut()[..MASTER_EDITION_FEE_FLAG_INDEX]; BorshSerialize::serialize(self, &mut storage)?; Ok(()) } @@ -166,7 +170,7 @@ impl MasterEdition for MasterEditionV1 { } fn save(&self, account: &AccountInfo) -> ProgramResult { - let mut storage = &mut account.data.borrow_mut()[..TOKEN_STANDARD_INDEX]; + let mut storage = &mut account.data.borrow_mut()[..MASTER_EDITION_FEE_FLAG_INDEX]; BorshSerialize::serialize(self, &mut storage)?; Ok(()) } diff --git a/token-metadata/program/src/state/metadata.rs b/token-metadata/program/src/state/metadata.rs index 344882429d..22f0c4c695 100644 --- a/token-metadata/program/src/state/metadata.rs +++ b/token-metadata/program/src/state/metadata.rs @@ -39,6 +39,10 @@ pub const MAX_DATA_SIZE: usize = 4 + 4 + MAX_CREATOR_LIMIT * MAX_CREATOR_LEN; +// 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; + #[macro_export] macro_rules! metadata_seeds { ($mint:expr) => {{ diff --git a/token-metadata/program/src/state/mod.rs b/token-metadata/program/src/state/mod.rs index f9c2340abf..1001b7c27d 100644 --- a/token-metadata/program/src/state/mod.rs +++ b/token-metadata/program/src/state/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod delegate; pub(crate) mod edition; pub(crate) mod edition_marker; pub(crate) mod escrow; +pub mod fee; pub(crate) mod master_edition; pub(crate) mod metadata; pub(crate) mod migrate; @@ -25,6 +26,7 @@ pub use delegate::*; pub use edition::*; pub use edition_marker::*; pub use escrow::*; +pub use fee::*; pub use master_edition::*; pub use metadata::*; pub use migrate::*; @@ -35,7 +37,7 @@ pub use programmable::*; pub use reservation::*; use shank::ShankAccount; use solana_program::{ - account_info::AccountInfo, entrypoint::ProgramResult, program_error::ProgramError, + account_info::AccountInfo, entrypoint::ProgramResult, program_error::ProgramError, pubkey, pubkey::Pubkey, }; use spl_token::state::Account as TokenAccount; diff --git a/token-metadata/program/src/utils/fee.rs b/token-metadata/program/src/utils/fee.rs new file mode 100644 index 0000000000..2e823dfa0e --- /dev/null +++ b/token-metadata/program/src/utils/fee.rs @@ -0,0 +1,52 @@ +use solana_program::{ + account_info::AccountInfo, entrypoint::ProgramResult, program::invoke, rent::Rent, + sysvar::Sysvar, +}; + +use crate::state::{fee::CREATE_FEE, Metadata, TokenMetadataAccount, METADATA_FEE_FLAG_INDEX}; + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub(crate) struct LevyArgs<'a> { + pub payer_account_info: &'a AccountInfo<'a>, + pub token_metadata_pda_info: &'a AccountInfo<'a>, +} + +pub(crate) fn levy(args: LevyArgs) -> ProgramResult { + // Fund metadata account with rent + Metaplex fee. + let rent = Rent::get()?; + + let fee = CREATE_FEE + rent.minimum_balance(Metadata::size()); + + invoke( + &solana_program::system_instruction::transfer( + args.payer_account_info.key, + args.token_metadata_pda_info.key, + fee, + ), + &[ + args.payer_account_info.clone(), + args.token_metadata_pda_info.clone(), + ], + )?; + + Ok(()) +} + +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(()) +} + +pub(crate) fn clear_fee_flag(pda_account_info: &AccountInfo) -> ProgramResult { + let mut data = pda_account_info.try_borrow_mut_data()?; + + // Clear the flag if the index exists. + if let Some(flag) = data.get_mut(METADATA_FEE_FLAG_INDEX) { + *flag = 0; + } + + Ok(()) +} diff --git a/token-metadata/program/src/utils/mod.rs b/token-metadata/program/src/utils/mod.rs index ed05ae1eb7..3face8b515 100644 --- a/token-metadata/program/src/utils/mod.rs +++ b/token-metadata/program/src/utils/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod collection; pub(crate) mod compression; +pub(crate) mod fee; pub(crate) mod master_edition; pub(crate) mod metadata; pub(crate) mod programmable_asset; @@ -19,7 +20,8 @@ pub use mpl_utils::{ pub use programmable_asset::*; use solana_program::{ account_info::AccountInfo, borsh::try_from_slice_unchecked, entrypoint::ProgramResult, - program::invoke_signed, program_error::ProgramError, pubkey::Pubkey, system_program, + program::invoke_signed, program_error::ProgramError, pubkey::Pubkey, rent::Rent, + sysvar::Sysvar, }; use spl_token::instruction::{set_authority, AuthorityType}; @@ -187,21 +189,40 @@ pub fn zero_account(s: &str, size: usize) -> String { s.to_owned() + std::str::from_utf8(&array_of_zeroes).unwrap() } -pub fn close_program_account<'a>( +pub(crate) fn close_program_account<'a>( account_info: &AccountInfo<'a>, funds_dest_account_info: &AccountInfo<'a>, + key: Key, ) -> ProgramResult { + let rent = Rent::get()?; + + let rent_lamports = match key { + // Metadata accounts could have fees stored, so we only want to withdraw + // the actual rent lamport amount. + Key::MetadataV1 => rent.minimum_balance(Metadata::size()), + // Other accounts the rent is just the current lamport balance. + _ => account_info.lamports(), + }; + + let remaining_lamports = account_info + .lamports() + .checked_sub(rent_lamports) + .ok_or(MetadataError::NumericalOverflowError)?; + // Transfer lamports from the account to the destination account. let dest_starting_lamports = funds_dest_account_info.lamports(); **funds_dest_account_info.lamports.borrow_mut() = dest_starting_lamports - .checked_add(account_info.lamports()) - .unwrap(); - **account_info.lamports.borrow_mut() = 0; + .checked_add(rent_lamports) + .ok_or(MetadataError::NumericalOverflowError)?; + **account_info.lamports.borrow_mut() = remaining_lamports; - // Realloc the account data size to 0 bytes and teassign ownership of - // the account to the system program + // Realloc the account data size to 0 bytes. account_info.realloc(0, false)?; - account_info.assign(&system_program::ID); + + // Only re-assign to the system program if it does not have fees on it. + if remaining_lamports == 0 { + account_info.assign(&solana_program::system_program::ID); + } Ok(()) } diff --git a/token-metadata/program/tests/burn_edition_nft.rs b/token-metadata/program/tests/burn_edition_nft.rs index 9f7f5d65ed..5f2bfc2d48 100644 --- a/token-metadata/program/tests/burn_edition_nft.rs +++ b/token-metadata/program/tests/burn_edition_nft.rs @@ -82,7 +82,9 @@ mod burn_edition_nft { .get_account(print_edition.pubkey) .await .unwrap(); - assert!(edition_marker_account.is_none()); + if let Some(account) = edition_marker_account { + assert_eq!(account.data.len(), 0); + } // Master Edition on original NFT still exists. let master_edition_account = context diff --git a/token-metadata/program/tests/burn_nft.rs b/token-metadata/program/tests/burn_nft.rs index 8061532034..fd4bbcec44 100644 --- a/token-metadata/program/tests/burn_nft.rs +++ b/token-metadata/program/tests/burn_nft.rs @@ -83,9 +83,19 @@ mod burn_nft { .await .unwrap(); - assert!(md_account.is_none()); + // Token Metadata accounts may still be open because they are no longer being re-assigned + // to the system program immediately, but if they exist they should have a + // data length of 0. + + if let Some(account) = md_account { + assert_eq!(account.data.len(), 0); + } + + if let Some(account) = master_edition_account { + assert_eq!(account.data.len(), 0); + } + assert!(token_account.is_none()); - assert!(master_edition_account.is_none()); } #[tokio::test] diff --git a/token-metadata/program/tests/fees.rs b/token-metadata/program/tests/fees.rs new file mode 100644 index 0000000000..bae4decaa7 --- /dev/null +++ b/token-metadata/program/tests/fees.rs @@ -0,0 +1,162 @@ +#![cfg(feature = "test-bpf")] +pub mod utils; + +use solana_program_test::*; +use utils::*; + +mod fees { + use mpl_token_metadata::{ + instruction::{collect_fees, BurnArgs}, + state::{CREATE_FEE, FEE_FLAG_CLEARED, METADATA_FEE_FLAG_INDEX}, + }; + use solana_program::{native_token::LAMPORTS_PER_SOL, pubkey::Pubkey}; + use solana_sdk::{ + signature::{read_keypair_file, Keypair}, + signer::Signer, + transaction::Transaction, + }; + + use super::*; + + #[tokio::test] + async fn charge_create_metadata_v3() { + let mut context = program_test().start_with_context().await; + + let md = Metadata::new(); + md.create_v3_default(&mut context).await.unwrap(); + + md.assert_create_fees_charged(&mut context).await.unwrap(); + } + + #[tokio::test] + async fn charge_create() { + let mut context = program_test().start_with_context().await; + + let mut nft = DigitalAsset::new(); + nft.create( + &mut context, + mpl_token_metadata::state::TokenStandard::NonFungible, + None, + ) + .await + .unwrap(); + + nft.assert_create_fees_charged(&mut context).await.unwrap(); + } + + #[tokio::test] + // Used for local QA testing and requires a keypair so excluded from CI. + #[ignore] + async fn collect_fees_max_accounts() { + // Create NFTs and then collect the fees from the metadata accounts. + let mut context = program_test().start_with_context().await; + + let authority_funding = 10 * LAMPORTS_PER_SOL; + + let authority = + read_keypair_file("Levytx9LLPzAtDJJD7q813Zsm8zg9e1pb53mGxTKpD7.json").unwrap(); + authority + .airdrop(&mut context, authority_funding) + .await + .unwrap(); + + let recipient = Keypair::new(); + + let num_accounts = 25; + + let mut nfts = vec![]; + for _ in 0..num_accounts { + let mut nft = DigitalAsset::new(); + nft.create( + &mut context, + mpl_token_metadata::state::TokenStandard::NonFungible, + None, + ) + .await + .unwrap(); + nfts.push(nft); + } + + let fee_accounts: Vec = nfts.iter().map(|nft| nft.metadata).collect(); + + let ix = collect_fees(recipient.pubkey(), fee_accounts.clone()); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&authority.pubkey()), + &[&authority], + context.last_blockhash, + ); + println!("Transaction size: {:?}", tx.message().serialize().len()); + context.banks_client.process_transaction(tx).await.unwrap(); + + let expected_balance = num_accounts * CREATE_FEE; + + let recipient_balance = get_account(&mut context, &recipient.pubkey()) + .await + .lamports; + + assert_eq!(recipient_balance, expected_balance); + + // Fee flag in metadata accounts is cleared. + for account in fee_accounts { + let account = get_account(&mut context, &account).await; + + assert_eq!(account.data[METADATA_FEE_FLAG_INDEX], FEE_FLAG_CLEARED); + } + } + + #[tokio::test] + // Used for local QA testing and requires a keypair so excluded from CI. + #[ignore] + async fn collect_fees_burned_account() { + // Create NFTs and then collect the fees from the metadata accounts. + let mut context = program_test().start_with_context().await; + + let nft_authority = context.payer.dirty_clone(); + + let fee_authority_funding = LAMPORTS_PER_SOL; + + let fee_authority = + read_keypair_file("Levytx9LLPzAtDJJD7q813Zsm8zg9e1pb53mGxTKpD7.json").unwrap(); + fee_authority + .airdrop(&mut context, fee_authority_funding) + .await + .unwrap(); + + let recipient = Keypair::new(); + + let mut nft = DigitalAsset::new(); + nft.create_and_mint( + &mut context, + mpl_token_metadata::state::TokenStandard::NonFungible, + None, + None, + 1, + ) + .await + .unwrap(); + + let args = BurnArgs::V1 { amount: 1 }; + + nft.burn(&mut context, nft_authority, args, None, None) + .await + .unwrap(); + + let ix = collect_fees(recipient.pubkey(), vec![nft.metadata]); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&fee_authority.pubkey()), + &[&fee_authority], + context.last_blockhash, + ); + context.banks_client.process_transaction(tx).await.unwrap(); + + let expected_balance = CREATE_FEE; + + let recipient_balance = get_account(&mut context, &recipient.pubkey()) + .await + .lamports; + + assert_eq!(recipient_balance, expected_balance); + } +} diff --git a/token-metadata/program/tests/utils/digital_asset.rs b/token-metadata/program/tests/utils/digital_asset.rs index dad36957c9..d30b0ce0ff 100644 --- a/token-metadata/program/tests/utils/digital_asset.rs +++ b/token-metadata/program/tests/utils/digital_asset.rs @@ -17,7 +17,8 @@ use mpl_token_metadata::{ state::{ AssetData, Collection, CollectionDetails, Creator, Metadata, PrintSupply, ProgrammableConfig, TokenDelegateRole, TokenMetadataAccount, TokenRecord, TokenStandard, - EDITION, EDITION_MARKER_BIT_SIZE, PREFIX, + CREATE_FEE, EDITION, EDITION_MARKER_BIT_SIZE, FEE_FLAG_SET, METADATA_FEE_FLAG_INDEX, + PREFIX, }, ID, }; @@ -1262,9 +1263,16 @@ impl DigitalAsset { .get_account(self.edition.unwrap()) .await?; - assert!(md_account.is_none()); - assert!(token_account.is_none()); + // The Metadata accounts may still be open because they are no longer being re-assigned + // to the system program immediately, but if they exist they should have a + // data length of 0. + + if let Some(account) = md_account { + assert_eq!(account.data.len(), 0); + } + assert!(edition_account.is_none()); + assert!(token_account.is_none()); Ok(()) } @@ -1298,7 +1306,25 @@ impl DigitalAsset { .get_account(token_record_pubkey) .await?; - assert!(token_record_account.is_none()); + if let Some(account) = token_record_account { + assert_eq!(account.data.len(), 0); + } + Ok(()) + } + + pub async fn assert_create_fees_charged( + &self, + context: &mut ProgramTestContext, + ) -> Result<(), BanksClientError> { + let account = get_account(context, &self.metadata).await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_exempt = rent.minimum_balance(account.data.len()); + + let expected_lamports = rent_exempt + CREATE_FEE; + + assert_eq!(account.lamports, expected_lamports); + assert_eq!(account.data[METADATA_FEE_FLAG_INDEX], FEE_FLAG_SET); Ok(()) } diff --git a/token-metadata/program/tests/utils/metadata.rs b/token-metadata/program/tests/utils/metadata.rs index d94e064a54..7f613819f4 100644 --- a/token-metadata/program/tests/utils/metadata.rs +++ b/token-metadata/program/tests/utils/metadata.rs @@ -2,7 +2,8 @@ use mpl_token_metadata::{ instruction, state::{ Collection, CollectionDetails, Creator, DataV2, Metadata as TmMetadata, - TokenMetadataAccount, TokenStandard, Uses, PREFIX, + TokenMetadataAccount, TokenStandard, Uses, CREATE_FEE, FEE_FLAG_SET, + METADATA_FEE_FLAG_INDEX, PREFIX, }, ID, }; @@ -643,6 +644,23 @@ impl Metadata { context.banks_client.process_transaction(tx).await } + + pub async fn assert_create_fees_charged( + &self, + context: &mut ProgramTestContext, + ) -> Result<(), BanksClientError> { + let account = get_account(context, &self.pubkey).await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_exempt = rent.minimum_balance(account.data.len()); + + let expected_lamports = rent_exempt + CREATE_FEE; + + assert_eq!(account.lamports, expected_lamports); + assert_eq!(account.data[METADATA_FEE_FLAG_INDEX], FEE_FLAG_SET); + + Ok(()) + } } impl Default for Metadata {