diff --git a/Cargo.lock b/Cargo.lock index f0b79e49..4dbd4d57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1573,7 +1573,7 @@ dependencies = [ [[package]] name = "metaboss" -version = "0.3.1" +version = "0.3.3" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 3b90ba38..cf78412e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "metaboss" -version = "0.3.1" +version = "0.3.3" edition = "2021" description="The Metaplex NFT-standard Swiss Army Knife tool." repository="https://github.com/samuelvanderwaal/metaboss" diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 04938894..13f7b789 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,14 @@ +v0.3.3 + +* Added exponential backoff retries to network requests: 250 ms, 500 ms, 1000 ms then fails. +* Added support for snapshot mints and holders commands for v2 candy machine ids. +* Added `derive` subcommand for deriving PDAs. + +v0.3.2 + +* Check first creator is verified in snapshot mints and snapshot holders commands. + + v0.3.1 * Add `primary_sale_happened` flag to mint commands diff --git a/src/decode.rs b/src/decode.rs index 713b03c4..1fbffc8c 100644 --- a/src/decode.rs +++ b/src/decode.rs @@ -3,6 +3,7 @@ use indicatif::ParallelProgressIterator; use log::{debug, error, info}; use metaplex_token_metadata::state::{Key, Metadata}; use rayon::prelude::*; +use retry::{delay::Exponential, retry}; use serde::Serialize; use serde_json::{json, Value}; use solana_client::rpc_client::RpcClient; @@ -147,10 +148,13 @@ pub fn decode(client: &RpcClient, mint_account: &String) -> Result data, Err(err) => { - return Err(DecodeError::ClientError(err.kind)); + return Err(DecodeError::NetworkError(err.to_string())); } }; diff --git a/src/derive.rs b/src/derive.rs new file mode 100644 index 00000000..58c0f5b9 --- /dev/null +++ b/src/derive.rs @@ -0,0 +1,141 @@ +use metaplex_token_metadata::id; +use solana_sdk::pubkey::Pubkey; +use std::{convert::AsRef, str::FromStr}; + +pub fn get_generic_pda(str_seeds: String, program_id: String) { + let str_seeds = str_seeds + .split(",") + .map(|s| s.into()) + .collect::>(); + + let seeds: Vec> = str_seeds + .into_iter() + .map(|seed| pubkey_or_bytes(seed)) + .collect(); + + let seeds: Vec<&[u8]> = seeds.iter().map(|seed| seed.as_slice()).collect(); + + let program_id = + Pubkey::from_str(&program_id).expect("Failed to parse pubkey from program_id!"); + println!("{}", derive_generic_pda(seeds, program_id)); +} + +fn pubkey_or_bytes(seed: String) -> Vec { + let res = Pubkey::from_str(&seed); + let value: Vec = match res { + Ok(pubkey) => pubkey.as_ref().to_vec(), + Err(_) => seed.as_bytes().to_owned(), + }; + + value +} + +pub fn get_metadata_pda(mint_account: String) { + let pubkey = + Pubkey::from_str(&mint_account).expect("Failed to parse pubkey from mint account!"); + println!("{}", derive_metadata_pda(&pubkey)); +} + +pub fn get_edition_pda(mint_account: String) { + let pubkey = + Pubkey::from_str(&mint_account).expect("Failed to parse pubkey from mint account!"); + println!("{}", derive_edition_pda(&pubkey)); +} + +pub fn get_cmv2_pda(candy_machine_id: String) { + let pubkey = + Pubkey::from_str(&candy_machine_id).expect("Failed to parse pubkey from candy_machine_id!"); + println!("{}", derive_cmv2_pda(&pubkey)); +} + +fn derive_generic_pda(seeds: Vec<&[u8]>, program_id: Pubkey) -> Pubkey { + let (pda, _) = Pubkey::find_program_address(&seeds, &program_id); + pda +} + +fn derive_metadata_pda(pubkey: &Pubkey) -> Pubkey { + let metaplex_pubkey = id(); + + let seeds = &[ + "metadata".as_bytes(), + metaplex_pubkey.as_ref(), + pubkey.as_ref(), + ]; + + let (pda, _) = Pubkey::find_program_address(seeds, &metaplex_pubkey); + pda +} + +fn derive_edition_pda(pubkey: &Pubkey) -> Pubkey { + let metaplex_pubkey = id(); + + let seeds = &[ + "metadata".as_bytes(), + metaplex_pubkey.as_ref(), + pubkey.as_ref(), + "edition".as_bytes(), + ]; + + let (pda, _) = Pubkey::find_program_address(seeds, &metaplex_pubkey); + pda +} + +pub fn derive_cmv2_pda(pubkey: &Pubkey) -> Pubkey { + let cmv2_pubkey = Pubkey::from_str("cndy3Z4yapfJBmL3ShUp5exZKqR3z33thTzeNMm2gRZ") + .expect("Failed to parse pubkey from candy machine program id!"); + + let seeds = &["candy_machine".as_bytes(), pubkey.as_ref()]; + + let (pda, _) = Pubkey::find_program_address(seeds, &cmv2_pubkey); + pda +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_derive_generic_pda() { + let metadata_program_pubkey = + Pubkey::from_str("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s").unwrap(); + let mint_pubkey = Pubkey::from_str("H9UJFx7HknQ9GUz7RBqqV9SRnht6XaVDh2cZS3Huogpf").unwrap(); + + let seeds = vec![ + "metadata".as_bytes(), + metadata_program_pubkey.as_ref(), + mint_pubkey.as_ref(), + ]; + + let expected_pda = + Pubkey::from_str("99pKPWsqi7bZaXKMvmwkxWV4nJjb5BS5SgKSNhW26ZNq").unwrap(); + let program_pubkey = + Pubkey::from_str("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s").unwrap(); + + assert_eq!(derive_generic_pda(seeds, program_pubkey), expected_pda); + } + + #[test] + fn test_derive_metadata_pda() { + let mint_pubkey = Pubkey::from_str("H9UJFx7HknQ9GUz7RBqqV9SRnht6XaVDh2cZS3Huogpf").unwrap(); + let expected_pda = + Pubkey::from_str("99pKPWsqi7bZaXKMvmwkxWV4nJjb5BS5SgKSNhW26ZNq").unwrap(); + assert_eq!(derive_metadata_pda(&mint_pubkey), expected_pda); + } + + #[test] + fn test_derive_edition_pda() { + let mint_pubkey = Pubkey::from_str("H9UJFx7HknQ9GUz7RBqqV9SRnht6XaVDh2cZS3Huogpf").unwrap(); + let expected_pda = + Pubkey::from_str("2vNgLPdTtfZYMNBR14vL5WXp6jYAvumfHauEHNc1BQim").unwrap(); + assert_eq!(derive_edition_pda(&mint_pubkey), expected_pda); + } + + #[test] + fn test_derive_cmv2_pda() { + let candy_machine_pubkey = + Pubkey::from_str("3qt9aBBmTSMxyzFEcwzZnFeV4tCZzPkTYVqPP7Bw5zUh").unwrap(); + let expected_pda = + Pubkey::from_str("8J9W44AfgWFMSwE4iYyZMNCWV9mKqovS5YHiVoKuuA2b").unwrap(); + assert_eq!(derive_cmv2_pda(&candy_machine_pubkey), expected_pda); + } +} diff --git a/src/errors.rs b/src/errors.rs index 9ccd9b64..9cec1ba7 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -10,6 +10,9 @@ pub enum DecodeError { #[error("failed to get account data")] ClientError(ClientErrorKind), + #[error("network request failed after three attempts")] + NetworkError(String), + #[error("failed to parse string into Pubkey")] PubkeyParseFailed(String), diff --git a/src/lib.rs b/src/lib.rs index 6bf41de7..d1619083 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod constants; pub mod data; pub mod decode; +pub mod derive; pub mod errors; pub mod limiter; pub mod mint; diff --git a/src/main.rs b/src/main.rs index 97154d1d..5fbcac80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,7 @@ fn main() -> Result<()> { let client = RpcClient::new_with_timeout_and_commitment(rpc, timeout, commitment); match options.cmd { Command::Decode { decode_subcommands } => process_decode(&client, decode_subcommands)?, + Command::Derive { derive_subcommands } => process_derive(derive_subcommands), Command::Mint { mint_subcommands } => process_mint(&client, mint_subcommands)?, Command::Update { update_subcommands } => process_update(&client, update_subcommands)?, Command::Set { set_subcommands } => process_set(&client, set_subcommands)?, diff --git a/src/mint.rs b/src/mint.rs index 45dcfd9b..c0846ac7 100644 --- a/src/mint.rs +++ b/src/mint.rs @@ -6,7 +6,7 @@ use metaplex_token_metadata::instruction::{ }; use rayon::prelude::*; use reqwest; -use retry::{delay::Fixed, retry}; +use retry::{delay::Exponential, retry}; use serde_json::Value; use solana_client::rpc_client::RpcClient; use solana_sdk::{ @@ -203,7 +203,7 @@ pub fn mint_one>( primary_sale_happened, )?; info!("Tx id: {:?}\nMint account: {:?}", &tx_id, &mint_account); - let message = format!("Tx id: {:?}\nMint account: {:?}!", &tx_id, &mint_account,); + let message = format!("Tx id: {:?}\nMint account: {:?}", &tx_id, &mint_account,); println!("{}", message); Ok(()) @@ -337,9 +337,10 @@ pub fn mint( ); // Send tx with retries. - let res = retry(Fixed::from_millis(100), || { - client.send_and_confirm_transaction(&tx) - }); + let res = retry( + Exponential::from_millis_with_factor(250, 2.0).take(3), + || client.send_and_confirm_transaction(&tx), + ); let sig = res?; Ok((sig, mint.pubkey())) diff --git a/src/opt.rs b/src/opt.rs index 2cc5cc8f..ad679d2b 100644 --- a/src/opt.rs +++ b/src/opt.rs @@ -27,6 +27,11 @@ pub enum Command { #[structopt(subcommand)] decode_subcommands: DecodeSubcommands, }, + /// Derive PDAs for various account types + Derive { + #[structopt(subcommand)] + derive_subcommands: DeriveSubcommands, + }, /// Mint new NFTs from JSON files #[structopt(name = "mint")] Mint { @@ -78,6 +83,27 @@ pub enum DecodeSubcommands { }, } +#[derive(Debug, StructOpt)] +pub enum DeriveSubcommands { + /// Derive generic PDA from seeds and program id + #[structopt(name = "pda")] + Pda { + /// Seeds to derive PDA from + seeds: String, + /// Program id to derive PDA from + program_id: String, + }, + /// Derive Metadata PDA + #[structopt(name = "metadata")] + Metadata { mint_account: String }, + /// Derive Edition PDA + #[structopt(name = "edition")] + Edition { mint_account: String }, + /// Derive CMV2 PDA + #[structopt(name = "cmv2-creator")] + CMV2Creator { candy_machine_id: String }, +} + #[derive(Debug, StructOpt)] pub enum MintSubcommands { /// Mint a single NFT from a JSON file @@ -224,6 +250,10 @@ pub enum SnapshotSubcommands { #[structopt(short, long)] candy_machine_id: Option, + /// Candy machine v2 id + #[structopt(long = "v2")] + v2: bool, + /// Path to directory to save output files. #[structopt(short, long, default_value = ".")] output: String, @@ -250,6 +280,10 @@ pub enum SnapshotSubcommands { #[structopt(short, long)] update_authority: Option, + /// Candy machine v2 id + #[structopt(long = "v2")] + v2: bool, + /// Path to directory to save output file #[structopt(short, long, default_value = ".")] output: String, diff --git a/src/process_subcommands.rs b/src/process_subcommands.rs index aa597651..e1d12462 100644 --- a/src/process_subcommands.rs +++ b/src/process_subcommands.rs @@ -2,6 +2,7 @@ use anyhow::Result; use solana_client::rpc_client::RpcClient; use crate::decode::decode_metadata; +use crate::derive::{get_cmv2_pda, get_edition_pda, get_generic_pda, get_metadata_pda}; use crate::mint::{mint_list, mint_one}; use crate::opt::*; use crate::sign::{sign_all, sign_one}; @@ -19,6 +20,15 @@ pub fn process_decode(client: &RpcClient, commands: DecodeSubcommands) -> Result Ok(()) } +pub fn process_derive(commands: DeriveSubcommands) { + match commands { + DeriveSubcommands::Pda { seeds, program_id } => get_generic_pda(seeds, program_id), + DeriveSubcommands::Metadata { mint_account } => get_metadata_pda(mint_account), + DeriveSubcommands::Edition { mint_account } => get_edition_pda(mint_account), + DeriveSubcommands::CMV2Creator { candy_machine_id } => get_cmv2_pda(candy_machine_id), + } +} + pub fn process_mint(client: &RpcClient, commands: MintSubcommands) -> Result<()> { match commands { MintSubcommands::One { @@ -95,8 +105,9 @@ pub fn process_snapshot(client: &RpcClient, commands: SnapshotSubcommands) -> Re SnapshotSubcommands::Holders { update_authority, candy_machine_id, + v2, output, - } => snapshot_holders(&client, &update_authority, &candy_machine_id, &output), + } => snapshot_holders(&client, &update_authority, &candy_machine_id, v2, &output), SnapshotSubcommands::CMAccounts { update_authority, output, @@ -104,8 +115,9 @@ pub fn process_snapshot(client: &RpcClient, commands: SnapshotSubcommands) -> Re SnapshotSubcommands::Mints { candy_machine_id, update_authority, + v2, output, - } => snapshot_mints(&client, candy_machine_id, update_authority, output), + } => snapshot_mints(&client, candy_machine_id, update_authority, v2, output), } } diff --git a/src/sign.rs b/src/sign.rs index 63a051c5..55331524 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -5,6 +5,7 @@ use metaplex_token_metadata::{ instruction::sign_metadata, state::Metadata, ID as METAPLEX_PROGRAM_ID, }; use rayon::prelude::*; +use retry::{delay::Exponential, retry}; use solana_client::rpc_client::RpcClient; use solana_program::borsh::try_from_slice_unchecked; use solana_sdk::{ @@ -34,6 +35,7 @@ pub fn sign_one(client: &RpcClient, keypair: String, account: String) -> Result< metadata_pubkey, &creator.pubkey() ); + let sig = sign(client, &creator, metadata_pubkey)?; info!("Tx sig: {}", sig); println!("Tx sig: {}", sig); @@ -78,7 +80,14 @@ pub fn sign(client: &RpcClient, creator: &Keypair, metadata_pubkey: Pubkey) -> R &[creator], recent_blockhash, ); - let sig = client.send_and_confirm_transaction(&tx)?; + + // Send tx with retries. + let res = retry( + Exponential::from_millis_with_factor(250, 2.0).take(3), + || client.send_and_confirm_transaction(&tx), + ); + let sig = res?; + Ok(sig) } diff --git a/src/snapshot.rs b/src/snapshot.rs index 1071b7dd..237eb5a0 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -4,6 +4,7 @@ use log::{error, info}; use metaplex_token_metadata::state::Metadata; use metaplex_token_metadata::ID as TOKEN_METADATA_PROGRAM_ID; use rayon::prelude::*; +use retry::{delay::Exponential, retry}; use serde::Serialize; use solana_account_decoder::{ parse_account_data::{parse_account_data, AccountAdditionalData, ParsedAccount}, @@ -28,6 +29,7 @@ use std::{ }; use crate::constants::*; +use crate::derive::derive_cmv2_pda; use crate::parse::is_only_one_option; use crate::spinner::*; @@ -61,6 +63,7 @@ pub fn snapshot_mints( client: &RpcClient, candy_machine_id: Option, update_authority: Option, + v2: bool, output: String, ) -> Result<()> { if !is_only_one_option(&candy_machine_id, &update_authority) { @@ -73,7 +76,15 @@ pub fn snapshot_mints( let accounts = if let Some(ref update_authority) = update_authority { get_mints_by_update_authority(client, &update_authority)? } else if let Some(ref candy_machine_id) = candy_machine_id { - get_cm_creator_accounts(client, &candy_machine_id)? + // Support v2 cm ids + if v2 { + let cm_pubkey = Pubkey::from_str(&candy_machine_id) + .expect("Failed to parse pubkey from candy_machine_id!"); + let cmv2_id = derive_cmv2_pda(&cm_pubkey); + get_cm_creator_accounts(client, &cmv2_id.to_string())? + } else { + get_cm_creator_accounts(client, &candy_machine_id)? + } } else { return Err(anyhow!( "Please specify either a candy machine id or an update authority, but not both." @@ -111,13 +122,22 @@ pub fn snapshot_holders( client: &RpcClient, update_authority: &Option, candy_machine_id: &Option, + v2: bool, output: &String, ) -> Result<()> { let spinner = create_spinner("Getting accounts..."); let accounts = if let Some(update_authority) = update_authority { get_mints_by_update_authority(client, update_authority)? } else if let Some(candy_machine_id) = candy_machine_id { - get_cm_creator_accounts(client, candy_machine_id)? + // Support v2 cm ids + if v2 { + let cm_pubkey = Pubkey::from_str(&candy_machine_id) + .expect("Failed to parse pubkey from candy_machine_id!"); + let cmv2_id = derive_cmv2_pda(&cm_pubkey); + get_cm_creator_accounts(client, &cmv2_id.to_string())? + } else { + get_cm_creator_accounts(client, &candy_machine_id)? + } } else { return Err(anyhow!( "Must specify either --update-authority or --candy-machine-id" @@ -143,8 +163,10 @@ pub fn snapshot_holders( } }; - let token_accounts = match get_holder_token_accounts(client, metadata.mint.to_string()) - { + let token_accounts = match retry( + Exponential::from_millis_with_factor(250, 2.0).take(3), + || get_holder_token_accounts(client, metadata.mint.to_string()), + ) { Ok(token_accounts) => token_accounts, Err(_) => { error!("Account {} has no token accounts", metadata_pubkey); diff --git a/src/update_metadata.rs b/src/update_metadata.rs index 93f61de9..0b0ed81a 100644 --- a/src/update_metadata.rs +++ b/src/update_metadata.rs @@ -4,14 +4,19 @@ use indicatif::ParallelProgressIterator; use log::{error, info}; use metaplex_token_metadata::{instruction::update_metadata_accounts, state::Data}; use rayon::prelude::*; -use retry::{delay::Fixed, retry}; +use retry::{delay::Exponential, retry}; use solana_client::rpc_client::RpcClient; use solana_sdk::{ pubkey::Pubkey, signer::{keypair::Keypair, Signer}, transaction::Transaction, }; -use std::{fs::File, path::Path, str::FromStr}; +use std::{ + fs::File, + path::Path, + str::FromStr, + sync::{Arc, Mutex}, +}; use crate::constants::*; use crate::data::{NFTData, UpdateNFTData, UpdateUriData}; @@ -46,9 +51,12 @@ pub fn update_data_all(client: &RpcClient, keypair: &String, data_dir: &String) let paths: Vec<_> = paths.into_iter().map(Result::unwrap).collect(); let errors: Vec<_> = errors.into_iter().map(Result::unwrap_err).collect(); + let failed_mints: Arc>> = Arc::new(Mutex::new(Vec::new())); + info!("Updating..."); println!("Updating..."); paths.par_iter().progress().for_each(|path| { + let failed_mints = failed_mints.clone(); let f = match File::open(path) { Ok(f) => f, Err(e) => { @@ -83,12 +91,15 @@ pub fn update_data_all(client: &RpcClient, keypair: &String, data_dir: &String) Ok(_) => (), Err(e) => { error!("Failed to update data: {:?} error: {}", path, e); + failed_mints + .lock() + .unwrap() + .push(update_nft_data.mint_account); return; } } }); - // TODO: handle errors in a better way and log instead of print. if !errors.is_empty() { error!("Failed to read some of the files with the following errors:"); for error in errors { @@ -96,6 +107,13 @@ pub fn update_data_all(client: &RpcClient, keypair: &String, data_dir: &String) } } + if !failed_mints.lock().unwrap().is_empty() { + error!("Failed to update the following mints:"); + for mint in failed_mints.lock().unwrap().iter() { + error!("{}", mint); + } + } + Ok(()) } @@ -128,9 +146,10 @@ pub fn update_data( ); // Send tx with retries. - let res = retry(Fixed::from_millis(100), || { - client.send_and_confirm_transaction(&tx) - }); + let res = retry( + Exponential::from_millis_with_factor(250, 2.0).take(3), + || client.send_and_confirm_transaction(&tx), + ); let sig = res?; info!("Tx sig: {:?}", sig);