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
10 changes: 7 additions & 3 deletions program/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ pub enum TokenWrapError {
/// External metadata program returned no data
#[error("External metadata program returned no data")]
ExternalProgramReturnedNoData,

// 15
/// Instruction can only be used with spl-token wrapped mints
#[error("Instruction can only be used with spl-token wrapped mints")]
NoSyncingToToken2022,
}

impl From<TokenWrapError> for ProgramError {
Expand Down Expand Up @@ -98,9 +103,8 @@ impl ToStr for TokenWrapError {
TokenWrapError::MetadataPointerMissing => "Error: MetadataPointerMissing",
TokenWrapError::MetadataPointerUnset => "Error: MetadataPointerUnset",
TokenWrapError::MetadataPointerMismatch => "Error: MetadataPointerMismatch",
&TokenWrapError::ExternalProgramReturnedNoData => {
"Error: ExternalProgramReturnedNoData"
}
TokenWrapError::ExternalProgramReturnedNoData => "Error: ExternalProgramReturnedNoData",
TokenWrapError::NoSyncingToToken2022 => "Error: NoSyncingToToken2022",
}
}
}
Expand Down
69 changes: 69 additions & 0 deletions program/src/instruction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,39 @@ pub enum TokenWrapInstruction {
/// 5. `[]` (Optional) Owner program. Required when metadata account is
/// owned by a third-party program.
SyncMetadataToToken2022,

/// This instruction copies the metadata fields from an unwrapped mint to
/// its wrapped mint `Metaplex` metadata account.
///
/// Supports (unwrapped to wrapped):
/// - Token-2022 to SPL-token
/// - SPL-token to SPL-token
///
/// This instruction will create the `Metaplex` metadata account if it
/// doesn't exist, or update it if it does. The `wrapped_mint_authority`
/// PDA must be pre-funded with enough lamports to cover the rent for
/// the `Metaplex` metadata account's creation or updates, as it will
/// act as the payer for the `Metaplex` program CPI.
Comment on lines +148 to +152
Copy link
Member Author

Choose a reason for hiding this comment

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

Sadly, the metaplex metadata program requires a payer for the create instruction. Pre-funding the PDA is not an option, so this instruction uses the wrapped_mint_authority PDA as the signer and requires the user to transfer to that account.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yuck, that really stinks. I don't love the solution, but I can't come up with anything better. It's slick since the wrapped mint authority is required to sign create / update metadata

///
/// 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.
///
/// Accounts expected by this instruction:
///
/// 0. `[w]` `Metaplex` metadata account
/// 1. `[w]` Wrapped mint authority (PDA)
/// 2. `[]` Wrapped SPL Token mint
/// 3. `[]` Unwrapped mint
/// 4. `[]` `Metaplex` Token Metadata Program
/// 5. `[]` System program
/// 6. `[]` Rent sysvar
/// 7. `[]` (Optional) Source metadata account. Required if unwrapped mint
/// is an SPL-Token or, if a Token-2022, its metadata pointer indicates
/// an external account.
/// 8. `[]` (Optional) Owner program. Required when metadata account is
/// owned by a third-party program.
SyncMetadataToSplToken,
}

impl TokenWrapInstruction {
Expand Down Expand Up @@ -164,6 +197,9 @@ impl TokenWrapInstruction {
TokenWrapInstruction::SyncMetadataToToken2022 => {
buf.push(4);
}
TokenWrapInstruction::SyncMetadataToSplToken => {
buf.push(5);
}
}
buf
}
Expand All @@ -190,6 +226,7 @@ impl TokenWrapInstruction {
}
Some((&3, [])) => Ok(TokenWrapInstruction::CloseStuckEscrow),
Some((&4, [])) => Ok(TokenWrapInstruction::SyncMetadataToToken2022),
Some((&5, [])) => Ok(TokenWrapInstruction::SyncMetadataToSplToken),
_ => Err(ProgramError::InvalidInstructionData),
}
}
Expand Down Expand Up @@ -339,3 +376,35 @@ pub fn sync_metadata_to_token_2022(
let data = TokenWrapInstruction::SyncMetadataToToken2022.pack();
Instruction::new_with_bytes(*program_id, &data, accounts)
}

/// Creates `SyncMetadataToSplToken` instruction.
pub fn sync_metadata_to_spl_token(
program_id: &Pubkey,
metaplex_metadata: &Pubkey,
wrapped_mint_authority: &Pubkey,
wrapped_mint: &Pubkey,
unwrapped_mint: &Pubkey,
source_metadata: Option<&Pubkey>,
owner_program: Option<&Pubkey>,
) -> Instruction {
let mut accounts = vec![
AccountMeta::new(*metaplex_metadata, false),
AccountMeta::new(*wrapped_mint_authority, false),
AccountMeta::new_readonly(*wrapped_mint, false),
AccountMeta::new_readonly(*unwrapped_mint, false),
AccountMeta::new_readonly(mpl_token_metadata::ID, false),
AccountMeta::new_readonly(solana_system_interface::program::id(), false),
AccountMeta::new_readonly(solana_sysvar::rent::id(), false),
];

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::SyncMetadataToSplToken.pack();
Instruction::new_with_bytes(*program_id, &data, accounts)
}
34 changes: 33 additions & 1 deletion program/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use {
crate::{error::TokenWrapError, metaplex::metaplex_to_token_2022_metadata},
mpl_token_metadata::ID as MPL_TOKEN_METADATA_ID,
mpl_token_metadata::{accounts::Metadata as MetaplexMetadata, ID as MPL_TOKEN_METADATA_ID},
solana_account_info::AccountInfo,
solana_cpi::{get_return_data, invoke},
solana_program_error::ProgramError,
Expand Down Expand Up @@ -88,3 +88,35 @@ pub fn resolve_token_2022_source_metadata<'a>(
cpi_emit_and_decode(owner_program_info, metadata_info)
}
}

/// Extracts the token metadata from the unwrapped mint
pub fn extract_token_metadata<'a>(
unwrapped_mint_info: &AccountInfo<'a>,
source_metadata_info: Option<&AccountInfo<'a>>,
owner_program_info: Option<&AccountInfo<'a>>,
) -> Result<TokenMetadata, ProgramError> {
let unwrapped_metadata = if *unwrapped_mint_info.owner == spl_token_2022::id() {
// 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 =
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);
}
if *metaplex_metadata_info.key != expected_metaplex_pda {
return Err(TokenWrapError::MetaplexMetadataMismatch.into());
}
metaplex_to_token_2022_metadata(unwrapped_mint_info, metaplex_metadata_info)?
} else {
return Err(ProgramError::IncorrectProgramId);
};

Ok(unwrapped_metadata)
}
Comment on lines +92 to +122
Copy link
Member Author

@grod220 grod220 Aug 22, 2025

Choose a reason for hiding this comment

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

Used for both sync instructions so abstracting

72 changes: 69 additions & 3 deletions program/src/metaplex.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
//! `Metaplex` related helpers

use {
mpl_token_metadata::accounts::Metadata as MetaplexMetadata, solana_account_info::AccountInfo,
solana_program_error::ProgramError, spl_pod::optional_keys::OptionalNonZeroPubkey,
mpl_token_metadata::{
accounts::Metadata as MetaplexMetadata,
types::{
Collection as MetaplexCollection, Creator as MetaplexCreator, DataV2,
Uses as MetaplexUses,
},
},
solana_account_info::AccountInfo,
solana_program_error::ProgramError,
spl_pod::optional_keys::OptionalNonZeroPubkey,
spl_token_metadata_interface::state::TokenMetadata,
};

Expand Down Expand Up @@ -31,9 +39,16 @@ fn extract_additional_metadata(

if let Some(creators) = &metaplex_metadata.creators {
if !creators.is_empty() {
// When syncing, verification status cannot be preserved as we do not have the
// creator's signature. Setting all creators to unverified.
let mut unverified_creators = creators.clone();
for creator in &mut unverified_creators {
creator.verified = false;
}
additional_metadata.push((
"creators".to_string(),
serde_json::to_string(creators).map_err(|_| ProgramError::InvalidAccountData)?,
serde_json::to_string(&unverified_creators)
.map_err(|_| ProgramError::InvalidAccountData)?,
));
}
}
Expand Down Expand Up @@ -96,3 +111,54 @@ pub fn metaplex_to_token_2022_metadata(
additional_metadata,
})
}

/// Converts Token-2022 `TokenMetadata` to the `Metaplex` `DataV2` format.
pub fn token_2022_metadata_to_metaplex(
token_metadata: &TokenMetadata,
) -> Result<DataV2, ProgramError> {
let mut creators: Option<Vec<MetaplexCreator>> = None;
let mut seller_fee_basis_points = 0;
let mut collection: Option<MetaplexCollection> = None;
let mut uses: Option<MetaplexUses> = None;

for (key, value) in &token_metadata.additional_metadata {
match key.as_str() {
"creators" => {
let mut deserialized_creators: Vec<MetaplexCreator> =
serde_json::from_str(value).map_err(|_| ProgramError::InvalidAccountData)?;
// When syncing, verification status cannot be preserved as we do not have the
// creator's signature. Setting all creators to unverified.
for creator in &mut deserialized_creators {
creator.verified = false;
}
creators = Some(deserialized_creators);
}
"seller_fee_basis_points" => {
seller_fee_basis_points = value
.parse::<u16>()
.map_err(|_| ProgramError::InvalidAccountData)?;
}
"collection" => {
collection = Some(
serde_json::from_str(value).map_err(|_| ProgramError::InvalidAccountData)?,
);
}
"uses" => {
uses = Some(
serde_json::from_str(value).map_err(|_| ProgramError::InvalidAccountData)?,
);
}
_ => {} // Ignore other fields
}
}

Ok(DataV2 {
name: token_metadata.name.clone(),
symbol: token_metadata.symbol.clone(),
uri: token_metadata.uri.clone(),
seller_fee_basis_points,
creators,
collection,
uses,
})
}
Loading