From 749bbcaaed8a801faa2b5c0a6290cd742b135fc6 Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Mon, 18 Aug 2025 18:50:02 +0200 Subject: [PATCH 1/2] Pointer-aware metadata sync --- Cargo.lock | 15 +- Cargo.toml | 8 +- package.json | 2 +- program/Cargo.toml | 4 +- program/src/error.rs | 18 + program/src/instruction.rs | 29 +- program/src/lib.rs | 1 + program/src/metadata.rs | 91 +++ program/src/processor.rs | 18 +- program/tests/helpers/common.rs | 5 + program/tests/helpers/extensions.rs | 19 +- program/tests/helpers/metadata.rs | 72 +++ program/tests/helpers/mod.rs | 1 + .../tests/helpers/sync_metadata_builder.rs | 75 ++- .../programs/mock-metadata-owner/Cargo.toml | 23 + .../mock-metadata-owner/src/entrypoint.rs | 19 + .../programs/mock-metadata-owner/src/lib.rs | 7 + .../mock-metadata-owner/src/processor.rs | 47 ++ program/tests/test_pointer_sync.rs | 534 ++++++++++++++++++ .../tests/test_sync_metadata_to_token_2022.rs | 136 ++--- 20 files changed, 954 insertions(+), 170 deletions(-) create mode 100644 program/src/metadata.rs create mode 100644 program/tests/helpers/metadata.rs create mode 100644 program/tests/programs/mock-metadata-owner/Cargo.toml create mode 100644 program/tests/programs/mock-metadata-owner/src/entrypoint.rs create mode 100644 program/tests/programs/mock-metadata-owner/src/lib.rs create mode 100644 program/tests/programs/mock-metadata-owner/src/processor.rs create mode 100644 program/tests/test_pointer_sync.rs diff --git a/Cargo.lock b/Cargo.lock index 7736c8c1..21f6e47c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3345,6 +3345,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "mock-metadata-owner" +version = "0.1.0" +dependencies = [ + "solana-account-info", + "solana-cpi", + "solana-program-entrypoint", + "solana-program-error", + "solana-pubkey", + "spl-token-2022 9.0.0", + "spl-token-metadata-interface", +] + [[package]] name = "mockall" version = "0.11.4" @@ -9780,6 +9793,7 @@ version = "0.1.0" dependencies = [ "borsh 0.10.4", "bytemuck", + "mock-metadata-owner", "mollusk-svm", "mollusk-svm-programs-token", "mpl-token-metadata", @@ -9793,7 +9807,6 @@ dependencies = [ "solana-msg", "solana-program-entrypoint", "solana-program-error", - "solana-program-option", "solana-program-pack", "solana-pubkey", "solana-rent", diff --git a/Cargo.toml b/Cargo.toml index 14bc9a97..abaa3e06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,11 @@ [workspace] resolver = "2" -members = ["clients/cli", "program", "program/tests/programs/test-transfer-hook"] +members = [ + "clients/cli", + "program", + "program/tests/programs/test-transfer-hook", + "program/tests/programs/mock-metadata-owner" +] [workspace.metadata.cli] solana = "2.3.4" @@ -34,6 +39,7 @@ anyhow = "1.0.98" borsh = "0.10.4" bytemuck = { version = "1.23.2", features = ["derive"] } clap = { version = "3.2.25", features = ["derive"] } +mock-metadata-owner = { path = "program/tests/programs/mock-metadata-owner" } mollusk-svm = "0.4.2" mollusk-svm-programs-token = "0.4.1" mpl-token-metadata = "5.1.0" diff --git a/package.json b/package.json index 2271c50d..3745c13e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "scripts": { - "programs:build": "cargo-build-sbf --manifest-path program/Cargo.toml && cargo-build-sbf --manifest-path program/tests/programs/test-transfer-hook/Cargo.toml", + "programs:build": "cargo-build-sbf --manifest-path program/Cargo.toml && cargo-build-sbf --manifest-path program/tests/programs/test-transfer-hook/Cargo.toml && cargo-build-sbf --manifest-path program/tests/programs/mock-metadata-owner/Cargo.toml", "programs:test": "zx ./scripts/rust/test-sbf.mjs program", "programs:format": "zx ./scripts/rust/format.mjs program", "programs:lint": "zx ./scripts/rust/lint.mjs program", diff --git a/program/Cargo.toml b/program/Cargo.toml index 86568d1b..0d09688b 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -23,7 +23,6 @@ solana-instruction = { workspace = true } solana-msg = { workspace = true } solana-program-entrypoint = { workspace = true } solana-program-error = { workspace = true } -solana-program-option = { workspace = true } solana-program-pack = { workspace = true } solana-pubkey = { workspace = true } solana-rent = { workspace = true } @@ -34,6 +33,7 @@ spl-pod = { workspace = true } spl-token = { workspace = true } spl-token-metadata-interface = { workspace = true } spl-transfer-hook-interface = { workspace = true } +spl-type-length-value = { workspace = true } thiserror = { workspace = true } # Should depend on the next crate version after 7.0.0 when https://github.com/solana-program/token-2022/pull/253 is deployed @@ -41,10 +41,10 @@ spl-token-2022 = { workspace = true } [dev-dependencies] borsh = { workspace = true } +mock-metadata-owner = { workspace = true } mollusk-svm = { workspace = true } mollusk-svm-programs-token = { workspace = true } solana-account = { workspace = true } -spl-type-length-value = { workspace = true } spl-tlv-account-resolution = { workspace = true } test-case = { workspace = true } test-transfer-hook = { workspace = true } diff --git a/program/src/error.rs b/program/src/error.rs index 3fa56901..1619d0c5 100644 --- a/program/src/error.rs +++ b/program/src/error.rs @@ -50,6 +50,18 @@ pub enum TokenWrapError { /// `Metaplex` metadata account address does not match expected PDA #[error("Metaplex metadata account address does not match expected PDA")] MetaplexMetadataMismatch, + /// Metadata pointer extension missing on mint + #[error("Metadata pointer extension missing on mint")] + MetadataPointerMissing, + /// Metadata pointer is unset (None) + #[error("Metadata pointer is unset (None)")] + MetadataPointerUnset, + /// Provided source metadata account does not match pointer + #[error("Provided source metadata account does not match pointer")] + MetadataPointerMismatch, + /// External metadata program returned no data + #[error("External metadata program returned no data")] + ExternalProgramReturnedNoData, } impl From for ProgramError { @@ -83,6 +95,12 @@ impl ToStr for TokenWrapError { TokenWrapError::EscrowInGoodState => "Error: EscrowInGoodState", TokenWrapError::UnwrappedMintHasNoMetadata => "Error: UnwrappedMintHasNoMetadata", TokenWrapError::MetaplexMetadataMismatch => "Error: MetaplexMetadataMismatch", + TokenWrapError::MetadataPointerMissing => "Error: MetadataPointerMissing", + TokenWrapError::MetadataPointerUnset => "Error: MetadataPointerUnset", + TokenWrapError::MetadataPointerMismatch => "Error: MetadataPointerMismatch", + &TokenWrapError::ExternalProgramReturnedNoData => { + "Error: ExternalProgramReturnedNoData" + } } } } diff --git a/program/src/instruction.rs b/program/src/instruction.rs index c2484784..07f62d4c 100644 --- a/program/src/instruction.rs +++ b/program/src/instruction.rs @@ -112,9 +112,17 @@ pub enum TokenWrapInstruction { /// This instruction copies the metadata fields from an unwrapped mint to /// its wrapped mint `TokenMetadata` extension. /// - /// Supports (unwrapped to wrapped): - /// - Token-2022 to Token-2022 - /// - SPL-token to Token-2022 + /// Token-2022 source (via `MetadataPointer`): + /// - `pointer == self`: Read `TokenMetadata` from the mint (no extra acct) + /// - `pointer -> Token-2022 account`: Read `TokenMetadata` from that + /// account (must be passed) + /// - `pointer -> Metaplex PDA`: convert fields (must pass PDA) + /// - `pointer -> third-party program`: CPI `Emit` to the account owner and + /// use returned fields (must pass the metadata account and its owner + /// program). + /// + /// SPL Token source: + /// - `Metaplex` PDA: convert fields (must pass PDA) /// /// If the `TokenMetadata` extension on the wrapped mint if not present, it /// will initialize it. The client is responsible for funding the wrapped @@ -128,8 +136,10 @@ pub enum TokenWrapInstruction { /// 1. `[]` Wrapped mint authority PDA /// 2. `[]` Unwrapped mint /// 3. `[]` Token-2022 program - /// 4. `[]` (Optional) `Metaplex` Metadata PDA. Required if the unwrapped - /// mint is an `spl-token` mint. + /// 4. `[]` (Optional) Source metadata account. Required if metadata pointer + /// indicates external account. + /// 5. `[]` (Optional) Owner program. Required when metadata account is + /// owned by a third-party program. SyncMetadataToToken2022, } @@ -312,7 +322,8 @@ pub fn sync_metadata_to_token_2022( wrapped_mint: &Pubkey, wrapped_mint_authority: &Pubkey, unwrapped_mint: &Pubkey, - metaplex_metadata: Option<&Pubkey>, + source_metadata: Option<&Pubkey>, + owner_program: Option<&Pubkey>, ) -> Instruction { let mut accounts = vec![ AccountMeta::new(*wrapped_mint, false), @@ -321,10 +332,14 @@ pub fn sync_metadata_to_token_2022( AccountMeta::new_readonly(spl_token_2022::id(), false), ]; - if let Some(pubkey) = metaplex_metadata { + if let Some(pubkey) = source_metadata { accounts.push(AccountMeta::new_readonly(*pubkey, false)); } + if let Some(owner) = owner_program { + accounts.push(AccountMeta::new_readonly(*owner, false)); + } + let data = TokenWrapInstruction::SyncMetadataToToken2022.pack(); Instruction::new_with_bytes(*program_id, &data, accounts) } diff --git a/program/src/lib.rs b/program/src/lib.rs index f46bd04b..68bf0108 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -5,6 +5,7 @@ mod entrypoint; pub mod error; pub mod instruction; +pub mod metadata; pub mod metaplex; pub mod mint_customizer; pub mod processor; diff --git a/program/src/metadata.rs b/program/src/metadata.rs new file mode 100644 index 00000000..9851483d --- /dev/null +++ b/program/src/metadata.rs @@ -0,0 +1,91 @@ +//! Metadata resolution helpers for pointer-aware metadata sync + +use { + crate::{error::TokenWrapError, metaplex::metaplex_to_token_2022_metadata}, + mpl_token_metadata::ID as MPL_TOKEN_METADATA_ID, + solana_account_info::AccountInfo, + solana_cpi::{get_return_data, invoke}, + solana_program_error::ProgramError, + spl_token_2022::{ + extension::{ + metadata_pointer::MetadataPointer, BaseStateWithExtensions, PodStateWithExtensions, + }, + id as token_2022_id, + pod::PodMint, + }, + spl_token_metadata_interface::{instruction::emit, state::TokenMetadata}, + spl_type_length_value::variable_len_pack::VariableLenPack, +}; + +/// Fetches metadata from a third-party program implementing +/// `TokenMetadataInstruction` by invoking its `Emit` instruction and decoding +/// the `TokenMetadata` struct from the return data. +pub fn cpi_emit_and_decode<'a>( + owner_program_info: &AccountInfo<'a>, + metadata_info: &AccountInfo<'a>, +) -> Result { + invoke( + &emit(owner_program_info.key, metadata_info.key, None, None), + &[metadata_info.clone()], + )?; + + if let Some((program_key, data)) = get_return_data() { + // This check ensures this data comes from the program we just called + if program_key == *owner_program_info.key { + return TokenMetadata::unpack_from_slice(&data); + } + } + + Err(TokenWrapError::ExternalProgramReturnedNoData.into()) +} + +/// Resolve the canonical metadata source for an unwrapped Token-2022 mint +/// by following its `MetadataPointer`. +/// +/// Supported pointer targets: +/// - Self +/// - Token-2022 account +/// - `Metaplex` PDA +/// - Third-party program +pub fn resolve_token_2022_source_metadata<'a>( + unwrapped_mint_info: &AccountInfo<'a>, + maybe_source_metadata_info: Option<&AccountInfo<'a>>, + maybe_owner_program_info: Option<&AccountInfo<'a>>, +) -> Result { + let data = unwrapped_mint_info.try_borrow_data()?; + let mint_state = PodStateWithExtensions::::unpack(&data)?; + let pointer = mint_state + .get_extension::() + .map_err(|_| TokenWrapError::MetadataPointerMissing)?; + let metadata_addr = + Option::from(pointer.metadata_address).ok_or(TokenWrapError::MetadataPointerUnset)?; + + // Scenario 1: points to self, read off unwrapped mint + if metadata_addr == *unwrapped_mint_info.key { + return mint_state.get_variable_len_extension::(); + } + + // Metadata account must be passed by this point + let metadata_info = maybe_source_metadata_info.ok_or(ProgramError::NotEnoughAccountKeys)?; + if metadata_info.key != &metadata_addr { + return Err(TokenWrapError::MetadataPointerMismatch.into()); + } + + if metadata_info.owner == &token_2022_id() { + // Scenario 2: points to another token-2022 mint + let data = metadata_info.try_borrow_data()?; + let state = PodStateWithExtensions::::unpack(&data)?; + state.get_variable_len_extension::() + } else if metadata_info.owner == &MPL_TOKEN_METADATA_ID { + // Scenario 3: points to a Metaplex PDA + metaplex_to_token_2022_metadata(unwrapped_mint_info, metadata_info) + } else { + // Scenario 4: points to an external program + let owner_program_info = + maybe_owner_program_info.ok_or(ProgramError::NotEnoughAccountKeys)?; + if owner_program_info.key != metadata_info.owner { + return Err(ProgramError::InvalidAccountOwner); + } + cpi_emit_and_decode(owner_program_info, metadata_info) + } +} diff --git a/program/src/processor.rs b/program/src/processor.rs index 7d5a8914..41935a01 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -8,6 +8,7 @@ use { get_wrapped_mint_backpointer_address_signer_seeds, get_wrapped_mint_backpointer_address_with_seed, get_wrapped_mint_signer_seeds, instruction::TokenWrapInstruction, + metadata::resolve_token_2022_source_metadata, metaplex::metaplex_to_token_2022_metadata, mint_customizer::{ default_token_2022::DefaultToken2022Customizer, interface::MintCustomizer, @@ -533,6 +534,8 @@ pub fn process_sync_metadata_to_token_2022(accounts: &[AccountInfo]) -> ProgramR let wrapped_mint_authority_info = next_account_info(account_info_iter)?; let unwrapped_mint_info = next_account_info(account_info_iter)?; let token_program_info = next_account_info(account_info_iter)?; + let source_metadata_info = account_info_iter.next(); + let owner_program_info = account_info_iter.next(); if *token_program_info.key != spl_token_2022::id() { return Err(ProgramError::IncorrectProgramId); @@ -554,15 +557,16 @@ pub fn process_sync_metadata_to_token_2022(accounts: &[AccountInfo]) -> ProgramR } let unwrapped_metadata = if *unwrapped_mint_info.owner == spl_token_2022::id() { - // Source is Token-2022: read from extension - let unwrapped_mint_data = unwrapped_mint_info.try_borrow_data()?; - let unwrapped_mint_state = PodStateWithExtensions::::unpack(&unwrapped_mint_data)?; - unwrapped_mint_state - .get_variable_len_extension::() - .map_err(|_| TokenWrapError::UnwrappedMintHasNoMetadata)? + // Source is Token-2022: resolve metadata pointer + resolve_token_2022_source_metadata( + unwrapped_mint_info, + source_metadata_info, + owner_program_info, + )? } else if *unwrapped_mint_info.owner == spl_token::id() { // Source is spl-token: read from Metaplex PDA - let metaplex_metadata_info = next_account_info(account_info_iter)?; + let metaplex_metadata_info = + source_metadata_info.ok_or(ProgramError::NotEnoughAccountKeys)?; let (expected_metaplex_pda, _) = MetaplexMetadata::find_pda(unwrapped_mint_info.key); if *metaplex_metadata_info.owner != mpl_token_metadata::ID { return Err(ProgramError::InvalidAccountOwner); diff --git a/program/tests/helpers/common.rs b/program/tests/helpers/common.rs index b9494b4a..84bde565 100644 --- a/program/tests/helpers/common.rs +++ b/program/tests/helpers/common.rs @@ -64,6 +64,11 @@ pub fn init_mollusk() -> Mollusk { "test_transfer_hook", &mollusk_svm::program::loader_keys::LOADER_V3, ); + mollusk.add_program( + &mock_metadata_owner::ID, + "mock_metadata_owner", + &mollusk_svm::program::loader_keys::LOADER_V3, + ); mollusk } diff --git a/program/tests/helpers/extensions.rs b/program/tests/helpers/extensions.rs index db6d2f26..9c91fe9c 100644 --- a/program/tests/helpers/extensions.rs +++ b/program/tests/helpers/extensions.rs @@ -6,6 +6,7 @@ use { extension::{ confidential_transfer::ConfidentialTransferMint, immutable_owner::ImmutableOwner, + metadata_pointer::MetadataPointer, mint_close_authority::MintCloseAuthority, non_transferable::{NonTransferable, NonTransferableAccount}, transfer_fee::{TransferFee, TransferFeeAmount, TransferFeeConfig}, @@ -34,7 +35,9 @@ pub enum MintExtension { uri: String, additional_metadata: Vec<(String, String)>, }, - MetadataPointer, + MetadataPointer { + metadata_address: Option, + }, } impl MintExtension { @@ -46,7 +49,7 @@ impl MintExtension { MintExtension::ConfidentialTransfer => ExtensionType::ConfidentialTransferMint, MintExtension::NonTransferable => ExtensionType::NonTransferable, MintExtension::TokenMetadata { .. } => ExtensionType::TokenMetadata, - MintExtension::MetadataPointer => ExtensionType::MetadataPointer, + MintExtension::MetadataPointer { .. } => ExtensionType::MetadataPointer, } } } @@ -120,15 +123,9 @@ pub fn init_mint_extensions( .init_variable_len_extension::(&token_metadata, false) .unwrap(); } - MintExtension::MetadataPointer => { - let wrapped_mint_authority = get_wrapped_mint_authority(mint_key); - let extension = state - .init_extension::( - false, - ) - .unwrap(); - extension.authority = Some(wrapped_mint_authority).try_into().unwrap(); - extension.metadata_address = Some(*mint_key).try_into().unwrap(); + MintExtension::MetadataPointer { metadata_address } => { + let pointer = state.init_extension::(false).unwrap(); + pointer.metadata_address = (*metadata_address).try_into().unwrap(); } } } diff --git a/program/tests/helpers/metadata.rs b/program/tests/helpers/metadata.rs new file mode 100644 index 00000000..080ee7c1 --- /dev/null +++ b/program/tests/helpers/metadata.rs @@ -0,0 +1,72 @@ +use { + mpl_token_metadata::accounts::Metadata as MetaplexMetadata, + spl_token_metadata_interface::state::TokenMetadata, std::collections::HashMap, +}; + +pub fn assert_metaplex_fields_synced( + wrapped_metadata: &TokenMetadata, + metaplex_metadata: &MetaplexMetadata, +) { + let additional_meta_map: HashMap<_, _> = wrapped_metadata + .additional_metadata + .iter() + .cloned() + .collect(); + + assert_eq!( + additional_meta_map.get("seller_fee_basis_points").unwrap(), + &metaplex_metadata.seller_fee_basis_points.to_string() + ); + assert_eq!( + additional_meta_map.get("primary_sale_happened").unwrap(), + &metaplex_metadata.primary_sale_happened.to_string() + ); + assert_eq!( + additional_meta_map.get("is_mutable").unwrap(), + &metaplex_metadata.is_mutable.to_string() + ); + if let Some(edition_nonce) = metaplex_metadata.edition_nonce { + assert_eq!( + additional_meta_map.get("edition_nonce").unwrap(), + &edition_nonce.to_string() + ); + } + if let Some(token_standard) = &metaplex_metadata.token_standard { + assert_eq!( + additional_meta_map.get("token_standard").unwrap(), + &serde_json::to_string(token_standard).unwrap() + ); + } + if let Some(collection) = &metaplex_metadata.collection { + assert_eq!( + additional_meta_map.get("collection").unwrap(), + &serde_json::to_string(collection).unwrap() + ); + } + if let Some(uses) = &metaplex_metadata.uses { + assert_eq!( + additional_meta_map.get("uses").unwrap(), + &serde_json::to_string(uses).unwrap() + ); + } + if let Some(collection_details) = &metaplex_metadata.collection_details { + assert_eq!( + additional_meta_map.get("collection_details").unwrap(), + &serde_json::to_string(collection_details).unwrap() + ); + } + if let Some(creators) = &metaplex_metadata.creators { + if !creators.is_empty() { + assert_eq!( + additional_meta_map.get("creators").unwrap(), + &serde_json::to_string(creators).unwrap() + ); + } + } + if let Some(config) = &metaplex_metadata.programmable_config { + assert_eq!( + additional_meta_map.get("config").unwrap(), + &serde_json::to_string(config).unwrap() + ); + } +} diff --git a/program/tests/helpers/mod.rs b/program/tests/helpers/mod.rs index 0b94093f..57f2e8a4 100644 --- a/program/tests/helpers/mod.rs +++ b/program/tests/helpers/mod.rs @@ -2,6 +2,7 @@ pub mod close_stuck_escrow_builder; pub mod common; pub mod create_mint_builder; pub mod extensions; +pub mod metadata; pub mod mint_builder; pub mod sync_metadata_builder; pub mod token_account_builder; diff --git a/program/tests/helpers/sync_metadata_builder.rs b/program/tests/helpers/sync_metadata_builder.rs index dd1c8903..d5f7c63e 100644 --- a/program/tests/helpers/sync_metadata_builder.rs +++ b/program/tests/helpers/sync_metadata_builder.rs @@ -4,10 +4,9 @@ use { extensions::MintExtension, mint_builder::MintBuilder, }, - borsh::BorshSerialize, - mollusk_svm::{result::Check, Mollusk}, - mpl_token_metadata::{accounts::Metadata as MetaplexMetadata, types::Key}, + mollusk_svm::{program::create_program_account_loader_v3, result::Check, Mollusk}, solana_account::Account, + solana_instruction::AccountMeta, solana_pubkey::Pubkey, spl_token_wrap::{ get_wrapped_mint_address, get_wrapped_mint_authority, id, @@ -26,7 +25,7 @@ pub struct SyncMetadataBuilder<'a> { unwrapped_mint: Option, wrapped_mint: Option, wrapped_mint_authority: Option, - metaplex_metadata: Option, + source_metadata: Option, } impl Default for SyncMetadataBuilder<'_> { @@ -37,7 +36,7 @@ impl Default for SyncMetadataBuilder<'_> { unwrapped_mint: None, wrapped_mint: None, wrapped_mint_authority: None, - metaplex_metadata: None, + source_metadata: None, } } } @@ -62,8 +61,8 @@ impl<'a> SyncMetadataBuilder<'a> { self } - pub fn metaplex_metadata(mut self, account: KeyedAccount) -> Self { - self.metaplex_metadata = Some(account); + pub fn source_metadata(mut self, account: KeyedAccount) -> Self { + self.source_metadata = Some(account); self } @@ -77,8 +76,8 @@ impl<'a> SyncMetadataBuilder<'a> { MintBuilder::new() .token_program(TokenProgram::SplToken2022) .with_extension(MintExtension::TokenMetadata { - name: "Unwrapped".to_string(), - symbol: "UP".to_string(), + name: "Alphabet".to_string(), + symbol: "ABC".to_string(), uri: "uri://unwrapped.com".to_string(), additional_metadata: vec![], }) @@ -97,50 +96,32 @@ impl<'a> SyncMetadataBuilder<'a> { .token_program(TokenProgram::SplToken2022) .mint_key(wrapped_mint_address) .mint_authority(wrapped_mint_authority) - .lamports(1_000_000_000) // Add sufficient lamports for rent + .with_extension(MintExtension::MetadataPointer { + metadata_address: Some(wrapped_mint_address), + }) + .lamports(1_000_000_000) .build() }); - let metaplex_metadata: Option = self.metaplex_metadata.or_else(|| { - if unwrapped_mint.account.owner == spl_token::id() { - let metadata = MetaplexMetadata { - key: Key::MetadataV1, - update_authority: Default::default(), - mint: unwrapped_mint.key, - name: "x".to_string(), - symbol: "y".to_string(), - uri: "z".to_string(), - seller_fee_basis_points: 0, - creators: None, - primary_sale_happened: false, - is_mutable: false, - edition_nonce: None, - token_standard: None, - collection: None, - uses: None, - collection_details: None, - programmable_config: None, - }; - Some(KeyedAccount { - key: MetaplexMetadata::find_pda(&unwrapped_mint.key).0, - account: Account { - lamports: 1_000_000_000, - data: metadata.try_to_vec().unwrap(), - owner: mpl_token_metadata::ID, - ..Default::default() - }, - }) + let source_metadata_key_opt = self.source_metadata.as_ref().map(|k| k.key); + let owner_program_opt = self.source_metadata.as_ref().and_then(|k| { + let owner = k.account.owner; + let is_metaplex = owner == mpl_token_metadata::ID; + let is_token2022 = owner == spl_token_2022::id(); + if !is_metaplex && !is_token2022 { + Some(owner) } else { None } }); - let instruction = sync_metadata_to_token_2022( + let mut instruction = sync_metadata_to_token_2022( &id(), &wrapped_mint.key, &wrapped_mint_authority, &unwrapped_mint.key, - metaplex_metadata.as_ref().map(|ka| &ka.key), + source_metadata_key_opt.as_ref(), + owner_program_opt.as_ref(), ); let mut accounts = vec![ @@ -150,10 +131,20 @@ impl<'a> SyncMetadataBuilder<'a> { TokenProgram::SplToken2022.keyed_account(), ]; - if let Some(metadata) = metaplex_metadata { + if let Some(metadata) = self.source_metadata.as_ref() { accounts.push(metadata.pair()); } + if let Some(program) = owner_program_opt { + instruction + .accounts + .push(AccountMeta::new_readonly(program, false)); + accounts.push(( + program, + create_program_account_loader_v3(&Pubkey::new_unique()), + )); + } + if self.checks.is_empty() { self.checks.push(Check::success()); } diff --git a/program/tests/programs/mock-metadata-owner/Cargo.toml b/program/tests/programs/mock-metadata-owner/Cargo.toml new file mode 100644 index 00000000..93628978 --- /dev/null +++ b/program/tests/programs/mock-metadata-owner/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "mock-metadata-owner" +version = "0.1.0" +edition = { workspace = true } +publish = false + +[features] +no-entrypoint = [] + +[dependencies] +solana-account-info = { workspace = true } +solana-cpi = { workspace = true } +solana-program-entrypoint = { workspace = true } +solana-program-error = { workspace = true } +solana-pubkey = { workspace = true } +spl-token-2022 = { workspace = true } +spl-token-metadata-interface = { workspace = true } + +[lib] +crate-type = ["cdylib", "lib"] + +[lints] +workspace = true diff --git a/program/tests/programs/mock-metadata-owner/src/entrypoint.rs b/program/tests/programs/mock-metadata-owner/src/entrypoint.rs new file mode 100644 index 00000000..85f4a535 --- /dev/null +++ b/program/tests/programs/mock-metadata-owner/src/entrypoint.rs @@ -0,0 +1,19 @@ +//! Program entrypoint + +#![cfg(all(target_os = "solana", not(feature = "no-entrypoint")))] + +use { + solana_account_info::AccountInfo, solana_program_error::ProgramResult, solana_pubkey::Pubkey, + spl_token_metadata_interface::instruction::TokenMetadataInstruction, +}; + +solana_program_entrypoint::entrypoint!(process_instruction); + +fn process_instruction( + program_id: &Pubkey, + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> ProgramResult { + let ix = TokenMetadataInstruction::unpack(instruction_data)?; + crate::processor::process_instruction(program_id, accounts, ix) +} diff --git a/program/tests/programs/mock-metadata-owner/src/lib.rs b/program/tests/programs/mock-metadata-owner/src/lib.rs new file mode 100644 index 00000000..166a69c7 --- /dev/null +++ b/program/tests/programs/mock-metadata-owner/src/lib.rs @@ -0,0 +1,7 @@ +use solana_pubkey::Pubkey; + +pub mod entrypoint; +pub mod processor; + +pub const ID: Pubkey = Pubkey::new_from_array([3u8; 32]); // success case +pub const NO_RETURN: Pubkey = Pubkey::new_from_array([4u8; 32]); diff --git a/program/tests/programs/mock-metadata-owner/src/processor.rs b/program/tests/programs/mock-metadata-owner/src/processor.rs new file mode 100644 index 00000000..9e99da3a --- /dev/null +++ b/program/tests/programs/mock-metadata-owner/src/processor.rs @@ -0,0 +1,47 @@ +//! This mock program simulates a third-party metadata program that chooses to +//! store its metadata directly within the `TokenMetadata` extension of a +//! Token-2022 mint account. +//! +//! This mock does not implement other instructions like `UpdateField`, as it's +//! only intended for testing the `Emit` functionality from a caller's +//! perspective. + +use { + crate::NO_RETURN, + solana_account_info::{next_account_info, AccountInfo}, + solana_cpi::set_return_data, + solana_program_error::ProgramResult, + solana_pubkey::Pubkey, + spl_token_2022::{ + extension::{BaseStateWithExtensions, PodStateWithExtensions}, + pod::PodMint, + }, + spl_token_metadata_interface::{instruction::TokenMetadataInstruction, state::TokenMetadata}, +}; + +pub fn process_instruction( + _program_id: &Pubkey, + accounts: &[AccountInfo], + ix: TokenMetadataInstruction, +) -> ProgramResult { + let TokenMetadataInstruction::Emit(emit) = ix else { + unimplemented!("Instruction not implemented") + }; + + let account_info_iter = &mut accounts.iter(); + let metadata_info = next_account_info(account_info_iter)?; + + if *metadata_info.key == NO_RETURN { + return Ok(()); + } + + let data = metadata_info.try_borrow_data()?; + let state = PodStateWithExtensions::::unpack(&data)?; + let metadata_bytes = state.get_extension_bytes::()?; + + let range = TokenMetadata::get_slice(metadata_bytes, emit.start, emit.end).unwrap(); + + set_return_data(range); + + Ok(()) +} diff --git a/program/tests/test_pointer_sync.rs b/program/tests/test_pointer_sync.rs new file mode 100644 index 00000000..8c470adf --- /dev/null +++ b/program/tests/test_pointer_sync.rs @@ -0,0 +1,534 @@ +use { + crate::helpers::{ + common::{init_mollusk, KeyedAccount, TokenProgram}, + extensions::{MintExtension, MintExtension::MetadataPointer}, + metadata::assert_metaplex_fields_synced, + mint_builder::MintBuilder, + sync_metadata_builder::SyncMetadataBuilder, + token_account_builder::TokenAccountBuilder, + }, + mollusk_svm::{program::create_program_account_loader_v3, result::Check}, + mpl_token_metadata::{ + accounts::Metadata as MetaplexMetadata, + types::{CollectionDetails, Creator, Key, TokenStandard, UseMethod, Uses}, + }, + solana_account::Account, + solana_program_error::ProgramError, + solana_pubkey::Pubkey, + spl_token_2022::{ + extension::{BaseStateWithExtensions, PodStateWithExtensions, PodStateWithExtensionsMut}, + pod::PodMint, + }, + spl_token_metadata_interface::state::TokenMetadata, + spl_token_wrap::{ + error::TokenWrapError, get_wrapped_mint_address, get_wrapped_mint_authority, id, + instruction::sync_metadata_to_token_2022, + }, +}; + +pub mod helpers; + +#[test] +fn test_pointer_missing_fails() { + let unwrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .build(); // No metadata extension + + SyncMetadataBuilder::new() + .unwrapped_mint(unwrapped_mint) + .check(Check::err(TokenWrapError::MetadataPointerMissing.into())) + .execute(); +} + +#[test] +fn test_pointer_unset_fails() { + let unwrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .with_extension(MetadataPointer { + metadata_address: None, + }) + .build(); + + SyncMetadataBuilder::new() + .unwrapped_mint(unwrapped_mint) + .check(Check::err(TokenWrapError::MetadataPointerUnset.into())) + .execute(); +} + +#[test] +fn test_token_2022_self_pointer_success() { + let mint_key = Pubkey::new_unique(); + let unwrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .mint_key(mint_key) + .with_extension(MetadataPointer { + metadata_address: Some(mint_key), + }) + .with_extension(MintExtension::TokenMetadata { + name: "Test Token".to_string(), + symbol: "TEST".to_string(), + uri: "https://example.com/test.json".to_string(), + additional_metadata: vec![], + }) + .build(); + + SyncMetadataBuilder::new() + .unwrapped_mint(unwrapped_mint) + .execute(); +} + +#[test] +fn test_pointer_present_but_no_account_fails() { + let external_address = Pubkey::new_unique(); + let unwrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .with_extension(MetadataPointer { + metadata_address: Some(external_address), + }) + .build(); + + // Don't provide the source_metadata account to the instruction + SyncMetadataBuilder::new() + .unwrapped_mint(unwrapped_mint) + .check(Check::err(ProgramError::NotEnoughAccountKeys)) + .execute(); +} + +#[test] +fn test_pointer_mismatch_fails() { + let pointer_address = Pubkey::new_unique(); + let unwrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .with_extension(MetadataPointer { + metadata_address: Some(pointer_address), + }) + .build(); + + let wrong_metadata_account = KeyedAccount { + key: Pubkey::new_unique(), + account: Account::default(), + }; + + SyncMetadataBuilder::new() + .unwrapped_mint(unwrapped_mint) + .source_metadata(wrong_metadata_account) + .check(Check::err(TokenWrapError::MetadataPointerMismatch.into())) + .execute(); +} + +#[test] +fn test_fail_pointer_to_token_2022_account_metadata_unsupported() { + let mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .build(); + let source_metadata_account = TokenAccountBuilder::new() + .token_program(TokenProgram::SplToken2022) + .mint(mint) + .build(); + + let unwrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .with_extension(MetadataPointer { + metadata_address: Some(source_metadata_account.key), + }) + .build(); + + SyncMetadataBuilder::new() + .unwrapped_mint(unwrapped_mint) + // Source is not allowed to be a token account + .source_metadata(source_metadata_account) + .check(Check::err(ProgramError::InvalidAccountData)) + .execute(); +} + +#[test] +fn test_pointing_to_token_2022_success() { + let metadata_source_mint_key = Pubkey::new_unique(); + let unwrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .with_extension(MetadataPointer { + metadata_address: Some(metadata_source_mint_key), + }) + .build(); + + let source_metadata_extension = MintExtension::TokenMetadata { + name: "Mock Token".to_string(), + symbol: "MOCK".to_string(), + uri: "https://example.com/mock.json".to_string(), + additional_metadata: vec![("mock_key".to_string(), "mock_value".to_string())], + }; + let mut mock_metadata_account = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .mint_key(metadata_source_mint_key) + .with_extension(source_metadata_extension.clone()) + .build(); + mock_metadata_account.account.owner = mock_metadata_owner::ID; + + let result = SyncMetadataBuilder::new() + .unwrapped_mint(unwrapped_mint) + .source_metadata(mock_metadata_account) + .execute(); + + let wrapped_mint_state = + PodStateWithExtensions::::unpack(&result.wrapped_mint.account.data).unwrap(); + let wrapped_metadata = wrapped_mint_state + .get_variable_len_extension::() + .unwrap(); + + if let MintExtension::TokenMetadata { + name, + symbol, + uri, + additional_metadata, + } = source_metadata_extension + { + assert_eq!(wrapped_metadata.name, name); + assert_eq!(wrapped_metadata.symbol, symbol); + assert_eq!(wrapped_metadata.uri, uri); + assert_eq!(wrapped_metadata.additional_metadata, additional_metadata); + } else { + panic!("Unexpected extension type"); + } +} + +#[test] +fn test_pointer_to_metaplex_success() { + let unwrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .build(); + + let metaplex_metadata_obj = MetaplexMetadata { + key: Key::MetadataV1, + update_authority: Pubkey::new_unique(), + mint: unwrapped_mint.key, + name: "Metaplex Token".to_string(), + symbol: "MPL".to_string(), + uri: "https://metaplex.dev/meta.json".to_string(), + seller_fee_basis_points: 250, + creators: Some(vec![Creator { + address: Pubkey::new_unique(), + verified: true, + share: 100, + }]), + primary_sale_happened: true, + is_mutable: true, + edition_nonce: Some(1), + token_standard: Some(TokenStandard::NonFungible), + collection: Some(mpl_token_metadata::types::Collection { + key: Pubkey::new_unique(), + verified: false, + }), + uses: Some(Uses { + use_method: UseMethod::Burn, + remaining: 0, + total: 0, + }), + collection_details: Some(CollectionDetails::V1 { size: 1 }), + programmable_config: None, + }; + + let (metaplex_pda, _) = MetaplexMetadata::find_pda(&unwrapped_mint.key); + let metaplex_account = KeyedAccount { + key: metaplex_pda, + account: Account { + lamports: 1_000_000_000, + data: borsh::to_vec(&metaplex_metadata_obj).unwrap(), + owner: mpl_token_metadata::ID, + executable: false, + rent_epoch: 0, + }, + }; + + // Point the Token-2022 mint's metadata pointer at the Metaplex PDA + let unwrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .with_extension(MetadataPointer { + metadata_address: Some(metaplex_account.key), + }) + .build(); + + let result = SyncMetadataBuilder::new() + .unwrapped_mint(unwrapped_mint.clone()) + .source_metadata(metaplex_account) + .execute(); + + let mut binding = result.wrapped_mint.account.data.clone(); + let wrapped_state = PodStateWithExtensionsMut::::unpack(&mut binding).unwrap(); + let wrapped_tm = wrapped_state + .get_variable_len_extension::() + .unwrap(); + + assert_eq!(wrapped_tm.name, "Metaplex Token"); + assert_eq!(wrapped_tm.symbol, "MPL"); + assert_eq!(wrapped_tm.uri, "https://metaplex.dev/meta.json"); + assert_eq!( + Option::::from(wrapped_tm.update_authority).unwrap(), + result.wrapped_mint_authority.key + ); + assert_eq!(wrapped_tm.mint, result.wrapped_mint.key); + assert_metaplex_fields_synced(&wrapped_tm, &metaplex_metadata_obj); +} + +#[test] +fn test_pointer_to_metaplex_with_invalid_data_fails() { + let unwrapped_mint_key = Pubkey::new_unique(); + let (metaplex_pda, _) = MetaplexMetadata::find_pda(&unwrapped_mint_key); + + // Point the unwrapped mint's metadata pointer to the Metaplex PDA + let unwrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .mint_key(unwrapped_mint_key) + .with_extension(MetadataPointer { + metadata_address: Some(metaplex_pda), + }) + .build(); + + // Create the Metaplex account with invalid data that cannot be deserialized + let metaplex_account_invalid_data = KeyedAccount { + key: metaplex_pda, + account: Account { + lamports: 1_000_000_000, + data: vec![1, 2, 3], // Invalid data + owner: mpl_token_metadata::ID, + ..Default::default() + }, + }; + + SyncMetadataBuilder::new() + .unwrapped_mint(unwrapped_mint) + .source_metadata(metaplex_account_invalid_data) + .check(Check::err(ProgramError::InvalidAccountData)) + .execute(); +} + +#[test] +fn test_third_party_missing_owner_program_fails() { + let mollusk = init_mollusk(); + + let mock_metadata_key = Pubkey::new_unique(); + let unwrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .with_extension(MetadataPointer { + metadata_address: Some(mock_metadata_key), + }) + .build(); + + let wrapped_mint_address = get_wrapped_mint_address(&unwrapped_mint.key, &spl_token_2022::id()); + let wrapped_mint_authority = get_wrapped_mint_authority(&wrapped_mint_address); + let wrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .mint_key(wrapped_mint_address) + .with_extension(MetadataPointer { + metadata_address: Some(wrapped_mint_address), + }) + .build(); + + let mock_metadata_account = KeyedAccount { + key: mock_metadata_key, + account: Account::default(), + }; + + let ix = sync_metadata_to_token_2022( + &id(), + &wrapped_mint.key, + &wrapped_mint_authority, + &unwrapped_mint.key, + Some(&mock_metadata_key), + None, // Missing owner program + ); + + let accounts = &[ + wrapped_mint.pair(), + (wrapped_mint_authority, Account::default()), + unwrapped_mint.pair(), + TokenProgram::SplToken2022.keyed_account(), + mock_metadata_account.pair(), + ]; + mollusk.process_and_validate_instruction( + &ix, + accounts, + &[Check::err(ProgramError::NotEnoughAccountKeys)], + ); +} + +#[test] +fn test_pointer_to_third_party_with_wrong_owner_program_fails() { + let mollusk = init_mollusk(); + + let mock_metadata_key = Pubkey::new_unique(); + let unwrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .with_extension(MetadataPointer { + metadata_address: Some(mock_metadata_key), + }) + .build(); + + let wrapped_mint_address = get_wrapped_mint_address(&unwrapped_mint.key, &spl_token_2022::id()); + let wrapped_mint_authority = get_wrapped_mint_authority(&wrapped_mint_address); + let wrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .mint_key(wrapped_mint_address) + .with_extension(MetadataPointer { + metadata_address: Some(wrapped_mint_address), + }) + .build(); + + // The metadata account is owned by the mock program + let mock_metadata_account = KeyedAccount { + key: mock_metadata_key, + account: Account { + owner: mock_metadata_owner::ID, + ..Default::default() + }, + }; + + // But we provide a *different* program as the owner in the instruction + let wrong_owner_program_key = Pubkey::new_unique(); + + let ix = sync_metadata_to_token_2022( + &id(), + &wrapped_mint.key, + &wrapped_mint_authority, + &unwrapped_mint.key, + Some(&mock_metadata_key), + Some(&wrong_owner_program_key), + ); + + let accounts = &[ + wrapped_mint.pair(), + (wrapped_mint_authority, Account::default()), + unwrapped_mint.pair(), + TokenProgram::SplToken2022.keyed_account(), + mock_metadata_account.pair(), + ( + wrong_owner_program_key, + create_program_account_loader_v3(&wrong_owner_program_key), + ), + ]; + + mollusk.process_and_validate_instruction( + &ix, + accounts, + &[Check::err(ProgramError::InvalidAccountOwner)], + ); +} + +#[test] +fn test_pointer_to_third_party_success() { + let mollusk = init_mollusk(); + + let source_metadata_extension = MintExtension::TokenMetadata { + name: "Mock External Token".to_string(), + symbol: "MOCK".to_string(), + uri: "https://example.com/mock.json".to_string(), + additional_metadata: vec![("external_key".to_string(), "external_value".to_string())], + }; + + let mock_metadata_key = Pubkey::new_unique(); + + let unwrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .with_extension(MetadataPointer { + metadata_address: Some(mock_metadata_key), + }) + .build(); + + let mut mock_metadata_account = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .mint_key(mock_metadata_key) + .with_extension(source_metadata_extension.clone()) + .build(); + mock_metadata_account.account.owner = mock_metadata_owner::ID; + + let wrapped_mint_address = get_wrapped_mint_address(&unwrapped_mint.key, &spl_token_2022::id()); + let wrapped_mint_authority = get_wrapped_mint_authority(&wrapped_mint_address); + let wrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .mint_key(wrapped_mint_address) + .with_extension(MetadataPointer { + metadata_address: Some(wrapped_mint_address), + }) + .with_extension(MintExtension::TokenMetadata { + name: "".to_string(), + symbol: "".to_string(), + uri: "".to_string(), + additional_metadata: vec![], + }) + .build(); + + let ix = sync_metadata_to_token_2022( + &id(), + &wrapped_mint.key, + &wrapped_mint_authority, + &unwrapped_mint.key, + Some(&mock_metadata_key), + Some(&mock_metadata_owner::ID), + ); + + let accounts = &[ + wrapped_mint.pair(), + (wrapped_mint_authority, Account::default()), + unwrapped_mint.pair(), + TokenProgram::SplToken2022.keyed_account(), + mock_metadata_account.pair(), + ( + mock_metadata_owner::ID, + create_program_account_loader_v3(&mock_metadata_owner::ID), + ), + ]; + + let result = mollusk.process_and_validate_instruction(&ix, accounts, &[Check::success()]); + + let final_wrapped_mint_account = result.get_account(&wrapped_mint.key).unwrap(); + let wrapped_mint_state = + PodStateWithExtensions::::unpack(&final_wrapped_mint_account.data).unwrap(); + let wrapped_metadata = wrapped_mint_state + .get_variable_len_extension::() + .unwrap(); + + if let MintExtension::TokenMetadata { + name, + symbol, + uri, + additional_metadata, + } = source_metadata_extension + { + assert_eq!(wrapped_metadata.name, name); + assert_eq!(wrapped_metadata.symbol, symbol); + assert_eq!(wrapped_metadata.uri, uri); + assert_eq!(wrapped_metadata.additional_metadata, additional_metadata); + assert_eq!( + Option::::from(wrapped_metadata.update_authority).unwrap(), + wrapped_mint_authority + ); + assert_eq!(wrapped_metadata.mint, wrapped_mint.key); + } else { + panic!("Unexpected extension type"); + } +} + +#[test] +fn test_pointer_to_third_party_no_return_fails() { + let unwrapped_mint = MintBuilder::new() + .token_program(TokenProgram::SplToken2022) + .with_extension(MetadataPointer { + metadata_address: Some(mock_metadata_owner::NO_RETURN), + }) + .build(); + + let source_metadata = KeyedAccount { + key: mock_metadata_owner::NO_RETURN, + account: Account { + owner: mock_metadata_owner::ID, + ..Default::default() + }, + }; + + SyncMetadataBuilder::new() + .unwrapped_mint(unwrapped_mint) + .source_metadata(source_metadata) + .check(Check::err( + TokenWrapError::ExternalProgramReturnedNoData.into(), + )) + .execute(); +} diff --git a/program/tests/test_sync_metadata_to_token_2022.rs b/program/tests/test_sync_metadata_to_token_2022.rs index 3f929ebd..6b5f7fb4 100644 --- a/program/tests/test_sync_metadata_to_token_2022.rs +++ b/program/tests/test_sync_metadata_to_token_2022.rs @@ -1,7 +1,8 @@ use { crate::helpers::{ common::{init_mollusk, KeyedAccount, TokenProgram}, - extensions::{MintExtension, MintExtension::MetadataPointer as MetadataPointerExt}, + extensions::MintExtension, + metadata::assert_metaplex_fields_synced, mint_builder::MintBuilder, sync_metadata_builder::SyncMetadataBuilder, }, @@ -24,7 +25,6 @@ use { error::TokenWrapError, get_wrapped_mint_address, get_wrapped_mint_authority, id, instruction::sync_metadata_to_token_2022, }, - std::collections::HashMap, }; pub mod helpers; @@ -60,6 +60,7 @@ fn test_fail_incorrect_token_program() { &wrapped_mint_authority, &unwrapped_mint.key, None, + None, ); instruction.accounts[3] = AccountMeta::new_readonly(fake_program.key, false); @@ -126,20 +127,6 @@ fn test_fail_wrapped_mint_authority_pda_mismatch() { .execute(); } -#[test] -fn test_fail_unwrapped_mint_has_no_metadata() { - let unwrapped_mint = MintBuilder::new() - .token_program(TokenProgram::SplToken2022) - .build(); // No metadata extension - - SyncMetadataBuilder::new() - .unwrapped_mint(unwrapped_mint) - .check(Check::err( - TokenWrapError::UnwrappedMintHasNoMetadata.into(), - )) - .execute(); -} - #[test] fn test_fail_spl_token_missing_metaplex_account() { let mollusk = init_mollusk(); @@ -155,7 +142,9 @@ fn test_fail_spl_token_missing_metaplex_account() { .token_program(TokenProgram::SplToken2022) .mint_key(wrapped_mint_address) .mint_authority(wrapped_mint_authority) - .with_extension(MetadataPointerExt) + .with_extension(MintExtension::MetadataPointer { + metadata_address: Some(wrapped_mint_address), + }) .lamports(1_000_000_000) .build(); @@ -165,6 +154,7 @@ fn test_fail_spl_token_missing_metaplex_account() { &wrapped_mint_authority, &unwrapped_mint.key, None, // Metaplex account is omitted + None, // Metadata owner is omitted ); let accounts = &[ @@ -200,7 +190,7 @@ fn test_fail_sync_metadata_with_wrong_metaplex_owner() { SyncMetadataBuilder::new() .unwrapped_mint(unwrapped_mint) - .metaplex_metadata(malicious_metadata_account) + .source_metadata(malicious_metadata_account) .check(Check::err(ProgramError::InvalidAccountOwner)) .execute(); } @@ -220,7 +210,7 @@ fn test_fail_spl_token_with_invalid_metaplex_pda() { SyncMetadataBuilder::new() .unwrapped_mint(unwrapped_mint) - .metaplex_metadata(invalid_metaplex_pda) + .source_metadata(invalid_metaplex_pda) .check(Check::err(TokenWrapError::MetaplexMetadataMismatch.into())) .execute(); } @@ -242,7 +232,7 @@ fn test_fail_spl_token_without_metaplex_metadata() { SyncMetadataBuilder::new() .unwrapped_mint(unwrapped_mint) - .metaplex_metadata(missing_metaplex_account) + .source_metadata(missing_metaplex_account) .check(Check::err(ProgramError::InvalidAccountData)) .execute(); } @@ -258,9 +248,14 @@ fn test_success_initialize_from_token_2022() { ("key2".to_string(), "value2".to_string()), ], }; + let unwrapped_mint_addr = Pubkey::new_unique(); let unwrapped_mint = MintBuilder::new() .token_program(TokenProgram::SplToken2022) .with_extension(unwrapped_metadata.clone()) + .mint_key(unwrapped_mint_addr) + .with_extension(MintExtension::MetadataPointer { + metadata_address: Some(unwrapped_mint_addr), + }) .build(); let wrapped_mint_address = get_wrapped_mint_address(&unwrapped_mint.key, &spl_token_2022::id()); @@ -269,7 +264,9 @@ fn test_success_initialize_from_token_2022() { .token_program(TokenProgram::SplToken2022) .mint_key(wrapped_mint_address) .mint_authority(wrapped_mint_authority) - .with_extension(MetadataPointerExt) + .with_extension(MintExtension::MetadataPointer { + metadata_address: Some(wrapped_mint_address), + }) .lamports(1_000_000_000) .build(); @@ -326,18 +323,25 @@ fn test_success_update_from_token_2022() { ("key3".to_string(), "value3".to_string()), // new ], }; + + let unwrapped_mint_key = Pubkey::new_unique(); let unwrapped_mint = MintBuilder::new() .token_program(TokenProgram::SplToken2022) + .mint_key(unwrapped_mint_key) .with_extension(new_metadata.clone()) + .with_extension(MintExtension::MetadataPointer { + metadata_address: Some(unwrapped_mint_key), + }) .build(); + let wrapped_mint_address = get_wrapped_mint_address(&unwrapped_mint.key, &spl_token_2022::id()); + let wrapped_mint = MintBuilder::new() .token_program(TokenProgram::SplToken2022) - .mint_key(get_wrapped_mint_address( - &unwrapped_mint.key, - &spl_token_2022::id(), - )) - .with_extension(MetadataPointerExt) + .mint_key(wrapped_mint_address) + .with_extension(MintExtension::MetadataPointer { + metadata_address: Some(wrapped_mint_address), + }) .with_extension(old_metadata) .lamports(1_000_000_000) .build(); @@ -374,74 +378,6 @@ fn test_success_update_from_token_2022() { } } -fn assert_metaplex_fields_synced( - wrapped_metadata: &TokenMetadata, - metaplex_metadata: &MetaplexMetadata, -) { - let additional_meta_map: HashMap<_, _> = wrapped_metadata - .additional_metadata - .iter() - .cloned() - .collect(); - - assert_eq!( - additional_meta_map.get("seller_fee_basis_points").unwrap(), - &metaplex_metadata.seller_fee_basis_points.to_string() - ); - assert_eq!( - additional_meta_map.get("primary_sale_happened").unwrap(), - &metaplex_metadata.primary_sale_happened.to_string() - ); - assert_eq!( - additional_meta_map.get("is_mutable").unwrap(), - &metaplex_metadata.is_mutable.to_string() - ); - if let Some(edition_nonce) = metaplex_metadata.edition_nonce { - assert_eq!( - additional_meta_map.get("edition_nonce").unwrap(), - &edition_nonce.to_string() - ); - } - if let Some(token_standard) = &metaplex_metadata.token_standard { - assert_eq!( - additional_meta_map.get("token_standard").unwrap(), - &serde_json::to_string(token_standard).unwrap() - ); - } - if let Some(collection) = &metaplex_metadata.collection { - assert_eq!( - additional_meta_map.get("collection").unwrap(), - &serde_json::to_string(collection).unwrap() - ); - } - if let Some(uses) = &metaplex_metadata.uses { - assert_eq!( - additional_meta_map.get("uses").unwrap(), - &serde_json::to_string(uses).unwrap() - ); - } - if let Some(collection_details) = &metaplex_metadata.collection_details { - assert_eq!( - additional_meta_map.get("collection_details").unwrap(), - &serde_json::to_string(collection_details).unwrap() - ); - } - if let Some(creators) = &metaplex_metadata.creators { - if !creators.is_empty() { - assert_eq!( - additional_meta_map.get("creators").unwrap(), - &serde_json::to_string(creators).unwrap() - ); - } - } - if let Some(config) = &metaplex_metadata.programmable_config { - assert_eq!( - additional_meta_map.get("config").unwrap(), - &serde_json::to_string(config).unwrap() - ); - } -} - #[test] fn test_success_initialize_from_spl_token() { let unwrapped_mint = MintBuilder::new() @@ -495,14 +431,16 @@ fn test_success_initialize_from_spl_token() { .token_program(TokenProgram::SplToken2022) .mint_key(wrapped_mint_address) .mint_authority(wrapped_mint_authority) - .with_extension(MetadataPointerExt) + .with_extension(MintExtension::MetadataPointer { + metadata_address: Some(wrapped_mint_address), + }) .lamports(1_000_000_000) .build(); let result = SyncMetadataBuilder::new() .unwrapped_mint(unwrapped_mint) .wrapped_mint(wrapped_mint.clone()) - .metaplex_metadata(metaplex_metadata) + .source_metadata(metaplex_metadata) .execute(); let wrapped_mint_state = @@ -544,7 +482,9 @@ fn test_success_update_from_spl_token() { .token_program(TokenProgram::SplToken2022) .mint_key(wrapped_mint_address) .mint_authority(wrapped_mint_authority) - .with_extension(MintExtension::MetadataPointer) + .with_extension(MintExtension::MetadataPointer { + metadata_address: Some(wrapped_mint_address), + }) .with_extension(old_wrapped_metadata) .lamports(1_000_000_000) .build(); @@ -585,7 +525,7 @@ fn test_success_update_from_spl_token() { let result = SyncMetadataBuilder::new() .unwrapped_mint(unwrapped_mint) .wrapped_mint(wrapped_mint.clone()) - .metaplex_metadata(metaplex_metadata) + .source_metadata(metaplex_metadata) .execute(); let wrapped_mint_state = From 239ccfeaa399a92d8c9e9e7b32b60129c9a6e061 Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Thu, 21 Aug 2025 16:06:11 +0200 Subject: [PATCH 2/2] Simplify ix docs --- program/src/instruction.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/program/src/instruction.rs b/program/src/instruction.rs index 07f62d4c..d1cda9af 100644 --- a/program/src/instruction.rs +++ b/program/src/instruction.rs @@ -112,17 +112,13 @@ pub enum TokenWrapInstruction { /// This instruction copies the metadata fields from an unwrapped mint to /// its wrapped mint `TokenMetadata` extension. /// - /// Token-2022 source (via `MetadataPointer`): - /// - `pointer == self`: Read `TokenMetadata` from the mint (no extra acct) - /// - `pointer -> Token-2022 account`: Read `TokenMetadata` from that - /// account (must be passed) - /// - `pointer -> Metaplex PDA`: convert fields (must pass PDA) - /// - `pointer -> third-party program`: CPI `Emit` to the account owner and - /// use returned fields (must pass the metadata account and its owner - /// program). + /// Supports (unwrapped to wrapped): + /// - Token-2022 to Token-2022 + /// - SPL-token to Token-2022 /// - /// SPL Token source: - /// - `Metaplex` PDA: convert fields (must pass PDA) + /// If source mint is a Token-2022, it must have a `MetadataPointer` and the + /// account it points to must be provided. If source mint is an SPL-Token, + /// the `Metaplex` PDA must be provided. /// /// If the `TokenMetadata` extension on the wrapped mint if not present, it /// will initialize it. The client is responsible for funding the wrapped