From 5d81cadd09401b11a499fc4a734fbb93705c97d9 Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Mon, 8 Sep 2025 15:10:36 +0200 Subject: [PATCH 1/2] Sync to token-2022 CLI helper --- clients/cli/Cargo.toml | 1 + clients/cli/src/cli.rs | 8 + clients/cli/src/main.rs | 1 + clients/cli/src/sync_metadata_to_token2022.rs | 143 +++++++++++++++++ .../tests/test_sync_metadata_to_token2022.rs | 144 ++++++++++++++++++ 5 files changed, 297 insertions(+) create mode 100644 clients/cli/src/sync_metadata_to_token2022.rs create mode 100644 clients/cli/tests/test_sync_metadata_to_token2022.rs diff --git a/clients/cli/Cargo.toml b/clients/cli/Cargo.toml index c1e8117..ed96166 100644 --- a/clients/cli/Cargo.toml +++ b/clients/cli/Cargo.toml @@ -40,6 +40,7 @@ tokio = { workspace = true } [dev-dependencies] bytemuck = { workspace = true } +mpl-token-metadata = { workspace = true } serde_json = { workspace = true } serial_test = { workspace = true } solana-keypair = { workspace = true } diff --git a/clients/cli/src/cli.rs b/clients/cli/src/cli.rs index af27ea5..e6687e6 100644 --- a/clients/cli/src/cli.rs +++ b/clients/cli/src/cli.rs @@ -9,6 +9,9 @@ use { sync_metadata_to_spl_token::{ command_sync_metadata_to_spl_token, SyncMetadataToSplTokenArgs, }, + sync_metadata_to_token2022::{ + command_sync_metadata_to_token2022, SyncMetadataToToken2022Args, + }, unwrap::{command_unwrap, UnwrapArgs}, wrap::{command_wrap, WrapArgs}, CommandResult, @@ -107,6 +110,8 @@ pub enum Command { /// Sync metadata from unwrapped mint to wrapped SPL Token mint's `Metaplex` /// metadata account SyncMetadataToSplToken(SyncMetadataToSplTokenArgs), + /// Sync metadata from unwrapped mint to wrapped Token-2022 mint + SyncMetadataToToken2022(SyncMetadataToToken2022Args), } impl Command { @@ -126,6 +131,9 @@ impl Command { Command::SyncMetadataToSplToken(args) => { command_sync_metadata_to_spl_token(config, args, matches, wallet_manager).await } + Command::SyncMetadataToToken2022(args) => { + command_sync_metadata_to_token2022(config, args, matches, wallet_manager).await + } } } } diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index def8b6e..e340dee 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -7,6 +7,7 @@ mod create_mint; mod find_pdas; mod output; mod sync_metadata_to_spl_token; +mod sync_metadata_to_token2022; mod unwrap; mod wrap; diff --git a/clients/cli/src/sync_metadata_to_token2022.rs b/clients/cli/src/sync_metadata_to_token2022.rs new file mode 100644 index 0000000..90afaf8 --- /dev/null +++ b/clients/cli/src/sync_metadata_to_token2022.rs @@ -0,0 +1,143 @@ +use { + crate::{ + common::{parse_pubkey, process_transaction}, + config::Config, + output::{format_output, println_display}, + CommandResult, + }, + clap::{ArgMatches, Args}, + serde_derive::{Deserialize, Serialize}, + serde_with::{serde_as, DisplayFromStr}, + solana_cli_output::{display::writeln_name_value, QuietDisplay, VerboseDisplay}, + solana_pubkey::Pubkey, + solana_remote_wallet::remote_wallet::RemoteWalletManager, + solana_signature::Signature, + solana_signer::Signer, + solana_transaction::Transaction, + spl_token_wrap::{ + get_wrapped_mint_address, get_wrapped_mint_authority, + instruction::sync_metadata_to_token_2022, + }, + std::{ + fmt::{Display, Formatter}, + rc::Rc, + }, +}; + +#[derive(Clone, Debug, Args)] +pub struct SyncMetadataToToken2022Args { + /// The address of the unwrapped mint whose metadata will be synced from + #[clap(value_parser = parse_pubkey)] + pub unwrapped_mint: Pubkey, + + /// Optional source metadata account when the unwrapped mint's metadata + /// pointer points to an external account or third-party program + #[clap(long, value_parser = parse_pubkey)] + pub source_metadata: Option, + + /// Optional owner program for the source metadata account, when owned by a + /// third-party program + #[clap(long, value_parser = parse_pubkey)] + pub owner_program: Option, +} + +#[serde_as] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncMetadataToToken2022Output { + #[serde_as(as = "DisplayFromStr")] + pub unwrapped_mint: Pubkey, + + #[serde_as(as = "DisplayFromStr")] + pub wrapped_mint: Pubkey, + + #[serde_as(as = "DisplayFromStr")] + pub wrapped_mint_authority: Pubkey, + + #[serde_as(as = "Option")] + pub source_metadata: Option, + + #[serde_as(as = "Option")] + pub owner_program: Option, + + pub signatures: Vec, +} + +impl Display for SyncMetadataToToken2022Output { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + writeln_name_value(f, "Unwrapped mint:", &self.unwrapped_mint.to_string())?; + writeln_name_value(f, "Wrapped mint:", &self.wrapped_mint.to_string())?; + writeln_name_value( + f, + "Wrapped mint authority:", + &self.wrapped_mint_authority.to_string(), + )?; + if let Some(src) = self.source_metadata { + writeln_name_value(f, "Source metadata:", &src.to_string())?; + } + if let Some(owner) = self.owner_program { + writeln_name_value(f, "Owner program:", &owner.to_string())?; + } + + writeln!(f, "Signers:")?; + for signature in &self.signatures { + writeln!(f, " {signature}")?; + } + + Ok(()) + } +} + +impl QuietDisplay for SyncMetadataToToken2022Output { + fn write_str(&self, _: &mut dyn std::fmt::Write) -> std::fmt::Result { + Ok(()) + } +} +impl VerboseDisplay for SyncMetadataToToken2022Output {} + +pub async fn command_sync_metadata_to_token2022( + config: &Config, + args: SyncMetadataToToken2022Args, + _matches: &ArgMatches, + _wallet_manager: &mut Option>, +) -> CommandResult { + let payer = config.fee_payer()?; + + let wrapped_mint = get_wrapped_mint_address(&args.unwrapped_mint, &spl_token_2022::id()); + let wrapped_mint_authority = get_wrapped_mint_authority(&wrapped_mint); + + println_display( + config, + format!( + "Syncing metadata to Token-2022 mint {} from {}", + wrapped_mint, args.unwrapped_mint + ), + ); + + let instruction = sync_metadata_to_token_2022( + &spl_token_wrap::id(), + &wrapped_mint, + &wrapped_mint_authority, + &args.unwrapped_mint, + args.source_metadata.as_ref(), + args.owner_program.as_ref(), + ); + + let blockhash = config.rpc_client.get_latest_blockhash().await?; + + let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey())); + transaction.partial_sign(&[payer.clone()], blockhash); + + process_transaction(config, transaction.clone()).await?; + + let output = SyncMetadataToToken2022Output { + unwrapped_mint: args.unwrapped_mint, + wrapped_mint, + wrapped_mint_authority, + source_metadata: args.source_metadata, + owner_program: args.owner_program, + signatures: transaction.signatures, + }; + + Ok(format_output(config, output)) +} diff --git a/clients/cli/tests/test_sync_metadata_to_token2022.rs b/clients/cli/tests/test_sync_metadata_to_token2022.rs new file mode 100644 index 0000000..35dd658 --- /dev/null +++ b/clients/cli/tests/test_sync_metadata_to_token2022.rs @@ -0,0 +1,144 @@ +use { + crate::helpers::{ + create_unwrapped_mint, execute_create_mint, setup_test_env, TOKEN_WRAP_CLI_BIN, + }, + mpl_token_metadata::{ + accounts::Metadata as MetaplexMetadata, + instructions::{CreateMetadataAccountV3, CreateMetadataAccountV3InstructionArgs}, + types::DataV2, + utils::clean, + }, + serde_json::Value, + serial_test::serial, + solana_sdk_ids::system_program, + solana_signer::Signer, + solana_system_interface::instruction::transfer, + solana_transaction::Transaction, + spl_token_2022::{ + extension::{BaseStateWithExtensions, PodStateWithExtensions}, + pod::PodMint, + }, + spl_token_metadata_interface::state::TokenMetadata, + spl_token_wrap::get_wrapped_mint_address, + std::process::Command, +}; + +mod helpers; + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_sync_metadata_from_spl_token_to_token2022() { + let env = setup_test_env().await; + + // 1. Create a standard SPL Token mint + let unwrapped_mint = create_unwrapped_mint(&env, &spl_token::id()).await; + + // 2. Create Metaplex metadata for the SPL Token mint + let (metaplex_pda, _) = MetaplexMetadata::find_pda(&unwrapped_mint); + let name = "Test Token".to_string(); + let symbol = "TEST".to_string(); + let uri = "http://test.com".to_string(); + + let create_meta_ix = CreateMetadataAccountV3 { + metadata: metaplex_pda, + mint: unwrapped_mint, + mint_authority: env.payer.pubkey(), + payer: env.payer.pubkey(), + update_authority: (env.payer.pubkey(), true), + system_program: system_program::id(), + rent: None, + } + .instruction(CreateMetadataAccountV3InstructionArgs { + data: DataV2 { + name: name.clone(), + symbol: symbol.clone(), + uri: uri.clone(), + seller_fee_basis_points: 0, + creators: None, + collection: None, + uses: None, + }, + is_mutable: true, + collection_details: None, + }); + + let meta_tx = Transaction::new_signed_with_payer( + &[create_meta_ix], + Some(&env.payer.pubkey()), + &[&env.payer], + env.rpc_client.get_latest_blockhash().await.unwrap(), + ); + env.rpc_client + .send_and_confirm_transaction(&meta_tx) + .await + .unwrap(); + + // 3. Create the corresponding wrapped Token-2022 mint for the SPL Token mint + execute_create_mint(&env, &unwrapped_mint, &spl_token_2022::id()).await; + + // 4. Fund the wrapped mint account for the extra space needed for the metadata + // extension + let wrapped_mint_address = get_wrapped_mint_address(&unwrapped_mint, &spl_token_2022::id()); + let fund_tx = Transaction::new_signed_with_payer( + &[transfer( + &env.payer.pubkey(), + &wrapped_mint_address, + 1_000_000_000, + )], + Some(&env.payer.pubkey()), + &[&env.payer], + env.rpc_client.get_latest_blockhash().await.unwrap(), + ); + env.rpc_client + .send_and_confirm_transaction(&fund_tx) + .await + .unwrap(); + + // 5. Execute the sync-metadata-to-token2022 command + let output = Command::new(TOKEN_WRAP_CLI_BIN) + .args([ + "sync-metadata-to-token2022", + "-C", + &env.config_file_path, + &unwrapped_mint.to_string(), + "--source-metadata", + &metaplex_pda.to_string(), + "--output", + "json", + ]) + .output() + .unwrap(); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + panic!( + "sync-metadata-to-token2022 command failed:\nSTDOUT:\n{}\nSTDERR:\n{}", + stdout, stderr + ); + } + assert!(output.status.success()); + + let json: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + json["wrappedMint"].as_str().unwrap(), + &wrapped_mint_address.to_string() + ); + + // 6. Verify the metadata was written correctly to the wrapped mint + let wrapped_mint_account_after = env + .rpc_client + .get_account(&wrapped_mint_address) + .await + .unwrap(); + let wrapped_mint_state = + PodStateWithExtensions::::unpack(&wrapped_mint_account_after.data).unwrap(); + let token_metadata = wrapped_mint_state + .get_variable_len_extension::() + .unwrap(); + + assert_eq!(clean(token_metadata.name), name); + assert_eq!(clean(token_metadata.symbol), symbol); + assert_eq!(clean(token_metadata.uri), uri); + assert_eq!(token_metadata.mint, wrapped_mint_address); +} From 04f83d005c95475fee56b049d0f34f5037f425f2 Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Wed, 10 Sep 2025 11:21:45 +0200 Subject: [PATCH 2/2] change CLI api --- clients/cli/Cargo.toml | 1 - clients/cli/src/sync_metadata_to_token2022.rs | 27 ++++++++++++++----- .../tests/test_sync_metadata_to_token2022.rs | 3 +-- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/clients/cli/Cargo.toml b/clients/cli/Cargo.toml index ed96166..c1e8117 100644 --- a/clients/cli/Cargo.toml +++ b/clients/cli/Cargo.toml @@ -40,7 +40,6 @@ tokio = { workspace = true } [dev-dependencies] bytemuck = { workspace = true } -mpl-token-metadata = { workspace = true } serde_json = { workspace = true } serial_test = { workspace = true } solana-keypair = { workspace = true } diff --git a/clients/cli/src/sync_metadata_to_token2022.rs b/clients/cli/src/sync_metadata_to_token2022.rs index 90afaf8..351bd66 100644 --- a/clients/cli/src/sync_metadata_to_token2022.rs +++ b/clients/cli/src/sync_metadata_to_token2022.rs @@ -6,6 +6,7 @@ use { CommandResult, }, clap::{ArgMatches, Args}, + mpl_token_metadata::accounts::Metadata as MetaplexMetadata, serde_derive::{Deserialize, Serialize}, serde_with::{serde_as, DisplayFromStr}, solana_cli_output::{display::writeln_name_value, QuietDisplay, VerboseDisplay}, @@ -30,15 +31,20 @@ pub struct SyncMetadataToToken2022Args { #[clap(value_parser = parse_pubkey)] pub unwrapped_mint: Pubkey, + /// Specify that the source metadata is from a `Metaplex` Token Metadata + /// account. The CLI will derive the PDA automatically. + #[clap(long)] + pub metaplex: bool, + /// Optional source metadata account when the unwrapped mint's metadata /// pointer points to an external account or third-party program - #[clap(long, value_parser = parse_pubkey)] - pub source_metadata: Option, + #[clap(long, value_parser = parse_pubkey, conflicts_with = "metaplex", requires = "program-id")] + pub metadata_account: Option, /// Optional owner program for the source metadata account, when owned by a /// third-party program #[clap(long, value_parser = parse_pubkey)] - pub owner_program: Option, + pub program_id: Option, } #[serde_as] @@ -106,6 +112,13 @@ pub async fn command_sync_metadata_to_token2022( let wrapped_mint = get_wrapped_mint_address(&args.unwrapped_mint, &spl_token_2022::id()); let wrapped_mint_authority = get_wrapped_mint_authority(&wrapped_mint); + let source_metadata = if args.metaplex { + let (metaplex_pda, _) = MetaplexMetadata::find_pda(&args.unwrapped_mint); + Some(metaplex_pda) + } else { + args.metadata_account + }; + println_display( config, format!( @@ -119,8 +132,8 @@ pub async fn command_sync_metadata_to_token2022( &wrapped_mint, &wrapped_mint_authority, &args.unwrapped_mint, - args.source_metadata.as_ref(), - args.owner_program.as_ref(), + source_metadata.as_ref(), + args.program_id.as_ref(), ); let blockhash = config.rpc_client.get_latest_blockhash().await?; @@ -134,8 +147,8 @@ pub async fn command_sync_metadata_to_token2022( unwrapped_mint: args.unwrapped_mint, wrapped_mint, wrapped_mint_authority, - source_metadata: args.source_metadata, - owner_program: args.owner_program, + source_metadata, + owner_program: args.program_id, signatures: transaction.signatures, }; diff --git a/clients/cli/tests/test_sync_metadata_to_token2022.rs b/clients/cli/tests/test_sync_metadata_to_token2022.rs index 35dd658..5080516 100644 --- a/clients/cli/tests/test_sync_metadata_to_token2022.rs +++ b/clients/cli/tests/test_sync_metadata_to_token2022.rs @@ -101,8 +101,7 @@ async fn test_sync_metadata_from_spl_token_to_token2022() { "-C", &env.config_file_path, &unwrapped_mint.to_string(), - "--source-metadata", - &metaplex_pda.to_string(), + "--metaplex", "--output", "json", ])