Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions program/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -34,17 +33,18 @@ 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
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 }
Expand Down
18 changes: 18 additions & 0 deletions program/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TokenWrapError> for ProgramError {
Expand Down Expand Up @@ -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"
}
}
}
}
Expand Down
19 changes: 15 additions & 4 deletions program/src/instruction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ pub enum TokenWrapInstruction {
/// - Token-2022 to Token-2022
/// - SPL-token to Token-2022
///
/// 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
/// mint account with enough lamports to cover the rent for the
Expand All @@ -128,8 +132,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,
}

Expand Down Expand Up @@ -312,7 +318,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),
Expand All @@ -321,10 +328,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)
}
1 change: 1 addition & 0 deletions program/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
91 changes: 91 additions & 0 deletions program/src/metadata.rs
Original file line number Diff line number Diff line change
@@ -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<TokenMetadata, ProgramError> {
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<TokenMetadata, ProgramError> {
let data = unwrapped_mint_info.try_borrow_data()?;
let mint_state = PodStateWithExtensions::<PodMint>::unpack(&data)?;
let pointer = mint_state
.get_extension::<MetadataPointer>()
.map_err(|_| TokenWrapError::MetadataPointerMissing)?;
Comment on lines +57 to +59
Copy link
Member Author

@grod220 grod220 Aug 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would love some guidance here. For token-2022's, is their metadata legit only if they have a pointer? Aka, should we support the scenario of a token-2022 with metadata extension with no pointer?

Copy link

@buffalojoec buffalojoec Aug 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right, the Token Metadata standard in Token-2022 only recognizes metadata explicitly registered with a MetadataPointer extension. It's also expected that the metadata adheres to the SPL Token Metadata interface (name, symbol, uri, plus optional additional keys).

However, Token-2022 has no way to enforce this interface adherence on the fields specifically. So, maybe we give it our best effort, and default to blank fields for name, symbol, and uri, while populating all of the additional fields as you do for Metaplex?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not quite sure what you mean that it adheres to the interface, but not the fields specifically. The section with update_fields_if_changed() will ensure the fields will be in sync so if the source data can successfully deserialize then I think we're good.

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::<TokenMetadata>();
}

// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this actually a possible scenario? I wasn't aware 😅

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metadata pointer can be updated for any pubkey. However, if you find this scenario is not legitimate, I can follow up and remove it. I'm not super familiar how folks are using the pointer outside the self and PDA case. I was surprised to find the third-party program case myself.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to remove it -- if a mint is pointing its metadata at another mint, I would worry that it's trying to spoof that other mint.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will follow up with a PR!

let data = metadata_info.try_borrow_data()?;
let state = PodStateWithExtensions::<PodMint>::unpack(&data)?;
state.get_variable_len_extension::<TokenMetadata>()
} 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)
}
}
18 changes: 11 additions & 7 deletions program/src/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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::<PodMint>::unpack(&unwrapped_mint_data)?;
unwrapped_mint_state
.get_variable_len_extension::<TokenMetadata>()
.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);
Expand Down
5 changes: 5 additions & 0 deletions program/tests/helpers/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
19 changes: 8 additions & 11 deletions program/tests/helpers/extensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -34,7 +35,9 @@ pub enum MintExtension {
uri: String,
additional_metadata: Vec<(String, String)>,
},
MetadataPointer,
MetadataPointer {
metadata_address: Option<Pubkey>,
},
}

impl MintExtension {
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -120,15 +123,9 @@ pub fn init_mint_extensions(
.init_variable_len_extension::<TokenMetadata>(&token_metadata, false)
.unwrap();
}
MintExtension::MetadataPointer => {
let wrapped_mint_authority = get_wrapped_mint_authority(mint_key);
let extension = state
.init_extension::<spl_token_2022::extension::metadata_pointer::MetadataPointer>(
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::<MetadataPointer>(false).unwrap();
pointer.metadata_address = (*metadata_address).try_into().unwrap();
}
}
}
Expand Down
Loading