diff --git a/Cargo.toml b/Cargo.toml index 046fdca..0aa6024 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "0.7.1" +version = "0.8.0" authors = [ "Cavey Cool ", "Tracy " @@ -18,3 +18,7 @@ authors = [ edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/GenesysGo/shadow-drive-rust" + +[workspace.dependencies] +shadow-drive-sdk = { path = "sdk", version = "0.8.0"} +shadow-rpc-auth = { path = "auth", version = "0.8.0"} \ No newline at end of file diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 033be8d..7b76d1d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,19 +1,26 @@ [package] name = "shadow-drive-cli" -description = "The Rust CLI for GenesysGo's Shadow Drive" +description = "The Rust CLI for GenesysGo's Shadow Drive, NFT Standard Program, and Minter Program" version = { workspace = true } authors = { workspace = true } edition = { workspace = true } license = { workspace = true } repository = { workspace = true } +[[bin]] +name = "shdw" +path = "src/main.rs" +test = false +bench = false + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -shadow-drive-sdk = { path = "../sdk", version = "0.7.1" } -shadow-rpc-auth = { path = "../auth", version = "0.7.1" } -shadow-nft-standard = { git = "https://github.com/genesysgo/shadow-nft-standard", branch = "main"} -shadowy-super-minter = { git = "https://github.com/genesysgo/shadow-nft-standard", branch = "main"} +shadow-drive-sdk = { workspace = true } +shadow-rpc-auth = { version = "0.7.1" } +shadow-nft-common = "0.1.1" +shadow-nft-standard = "0.1.1" +shadowy-super-minter = "0.1.1" tokio = { version = "^1", features = ["full"] } anyhow = "1.0.65" byte-unit = "4.0.14" diff --git a/cli/src/command/nft/collection/init.rs b/cli/src/command/nft/collection/init.rs index a4dd123..83d78ad 100644 --- a/cli/src/command/nft/collection/init.rs +++ b/cli/src/command/nft/collection/init.rs @@ -52,7 +52,7 @@ pub(super) async fn process(signer: &impl Signer, rpc_url: &str) -> anyhow::Resu let collection = Collection::get_pda(creator_group, &name); // Construct the instruction to create a minter - let for_minter = Confirm::new("Is this collection for a shadowy super minter? (no for 1/1s). You cannot change this later").prompt()?; + let for_minter = Confirm::new("Is this collection for a shadowy super minter? (no for 1/1s)? You cannot change this later.").prompt()?; let args = CreateCollectionArgs { name, symbol, @@ -84,8 +84,21 @@ pub(super) async fn process(signer: &impl Signer, rpc_url: &str) -> anyhow::Resu client.get_latest_blockhash().await?, ); - if let Err(e) = client.send_and_confirm_transaction(&create_group_tx).await { - return Err(anyhow::Error::msg(e)); + match Confirm::new(&format!( + "Send and confirm transaction (signing with {})?", + signer.pubkey() + )) + .prompt() + { + Ok(true) => {} + _ => return Err(anyhow::Error::msg("Discarded Request")), + } + + match client.send_and_confirm_transaction(&create_group_tx).await { + Ok(sig) => { + println!("Successful: https://explorer.solana.com/tx/{sig}") + } + Err(e) => return Err(anyhow::Error::msg(format!("{e:#?}"))), }; println!(""); diff --git a/cli/src/command/nft/collection/mod.rs b/cli/src/command/nft/collection/mod.rs index 88e2a47..73919d1 100644 --- a/cli/src/command/nft/collection/mod.rs +++ b/cli/src/command/nft/collection/mod.rs @@ -3,11 +3,18 @@ use shadow_drive_sdk::{Pubkey, Signer}; mod get; mod init; +mod withdraw; #[derive(Debug, Parser)] pub enum CollectionCommand { + /// Initialize a collection Init, + + /// Retrieve and print an onchain Collection account Get { collection: Pubkey }, + + /// Withdraw mint fees from an onchain Collection account + Withdraw { collection: Pubkey }, } impl CollectionCommand { pub async fn process(&self, signer: &impl Signer, rpc_url: &str) -> anyhow::Result<()> { @@ -15,6 +22,9 @@ impl CollectionCommand { CollectionCommand::Init => init::process(signer, rpc_url).await, CollectionCommand::Get { collection } => get::process(collection, rpc_url).await, + CollectionCommand::Withdraw { collection } => { + withdraw::process(signer, *collection, rpc_url).await + } } } } diff --git a/cli/src/command/nft/collection/withdraw.rs b/cli/src/command/nft/collection/withdraw.rs new file mode 100644 index 0000000..4c04a4e --- /dev/null +++ b/cli/src/command/nft/collection/withdraw.rs @@ -0,0 +1,80 @@ +use inquire::Confirm; +use shadow_drive_sdk::{Pubkey, Signer}; +use shadow_nft_standard::{ + accounts::Withdraw as WithdrawAccounts, + common::{collection::Collection, creator_group::CreatorGroup}, + instruction::Withdraw as WithdrawInstruction, +}; +use shadowy_super_minter::state::file_type::{AccountDeserialize, InstructionData, ToAccountMetas}; +use solana_client::rpc_client::RpcClient; +use solana_sdk::{ + instruction::{AccountMeta, Instruction}, + transaction::Transaction, +}; + +pub(crate) async fn process( + signer: &impl Signer, + collection: Pubkey, + rpc_url: &str, +) -> Result<(), anyhow::Error> { + let client = RpcClient::new(rpc_url); + + // Get onchain data + let onchain_collection = + Collection::try_deserialize(&mut client.get_account_data(&collection)?.as_slice())?; + let onchain_creator_group = CreatorGroup::try_deserialize( + &mut client + .get_account_data(&onchain_collection.creator_group_key)? + .as_slice(), + )?; + let creator_group = onchain_collection.creator_group_key; + + // Build tx + let mut accounts = WithdrawAccounts { + payer_creator: signer.pubkey(), + collection, + creator_group, + } + .to_account_metas(None); + for creator in onchain_creator_group.creators { + if creator != signer.pubkey() { + accounts.push(AccountMeta::new(creator, false)) + } + } + let withdraw_tx = Transaction::new_signed_with_payer( + &[Instruction::new_with_bytes( + shadow_nft_standard::ID, + WithdrawInstruction {}.data().as_ref(), + WithdrawAccounts { + payer_creator: signer.pubkey(), + collection, + creator_group, + } + .to_account_metas(None), + )], + Some(&signer.pubkey()), + &[signer], + client.get_latest_blockhash()?, + ); + + // Confirm with user + match Confirm::new(&format!( + "Send and confirm transaction (signing with {})?", + signer.pubkey() + )) + .prompt() + { + Ok(true) => {} + _ => return Err(anyhow::Error::msg("Discarded Request")), + } + + // Sign and send + match client.send_and_confirm_transaction(&withdraw_tx) { + Ok(sig) => { + println!("Successful: https://explorer.solana.com/tx/{sig}") + } + Err(e) => return Err(anyhow::Error::msg(format!("{e:#?}"))), + }; + + Ok(()) +} diff --git a/cli/src/command/nft/creator_group/init.rs b/cli/src/command/nft/creator_group/init.rs index b36fc17..a33cd89 100644 --- a/cli/src/command/nft/creator_group/init.rs +++ b/cli/src/command/nft/creator_group/init.rs @@ -34,7 +34,7 @@ pub(crate) async fn process( }; // Ask user what they would like to name their group - let name = Text::new("What would you like to n ame your group").prompt()?; + let name = Text::new("What would you like to name your group").prompt()?; // Ask for other members if not single_member let other_members = Rc::new(RefCell::new(vec![])); @@ -112,10 +112,13 @@ pub(crate) async fn process( ) }; - // TODO: add name here when we change contract - // Confirm input with user - match Confirm::new(&format!("Confirm Input (signing with {})", signer.pubkey())).prompt() { + match Confirm::new(&format!( + "Send and confirm transaction (signing with {})?", + signer.pubkey() + )) + .prompt() + { Ok(true) => {} _ => return Err(anyhow::Error::msg("Discarded Request")), } @@ -147,8 +150,11 @@ pub(crate) async fn process( ); println!("Sending create group tx. May take a while to confirm."); - if let Err(e) = client.send_and_confirm_transaction(&create_group_tx).await { - return Err(anyhow::Error::msg(e)); + match client.send_and_confirm_transaction(&create_group_tx).await { + Ok(sig) => { + println!("Successful: https://explorer.solana.com/tx/{sig}") + } + Err(e) => return Err(anyhow::Error::msg(format!("{e:#?}"))), }; println!("Initialized {creator_group}"); diff --git a/cli/src/command/nft/creator_group/mod.rs b/cli/src/command/nft/creator_group/mod.rs index c218155..85eef78 100644 --- a/cli/src/command/nft/creator_group/mod.rs +++ b/cli/src/command/nft/creator_group/mod.rs @@ -6,7 +6,10 @@ pub(crate) mod init; #[derive(Debug, Parser)] pub enum CreatorGroupCommand { + /// Initialize a creator group Init, + + /// Retrieve and print an onchain CreatorGroup account Get { creator_group: Pubkey }, } diff --git a/cli/src/command/nft/minter/init.rs b/cli/src/command/nft/minter/init.rs index 8619efd..0c493c6 100644 --- a/cli/src/command/nft/minter/init.rs +++ b/cli/src/command/nft/minter/init.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use futures::StreamExt; -use indicatif::ProgressBar; +use indicatif::{ProgressBar, ProgressStyle}; use inquire::validator::Validation; use inquire::{Confirm, Text}; use itertools::Itertools; @@ -28,8 +28,7 @@ use solana_sdk::system_program; use solana_sdk::transaction::Transaction; use crate::command::nft::utils::{ - swap_sol_for_shdw_tx, - validate_json_compliance, SHDW_MINT_PUBKEY, + swap_sol_for_shdw_tx, validate_json_compliance, SHDW_MINT_PUBKEY, }; use crate::utils::shadow_client_factory; @@ -103,6 +102,7 @@ pub(super) async fn process( Ok(Validation::Invalid("Path does not exist".into())) } }) + .with_autocomplete(FilePathCompleter::default()) .prompt()?; let Ok(config_file_contents) = std::fs::read_to_string(config_file_path) else { @@ -110,7 +110,7 @@ pub(super) async fn process( }; let Ok( MinterInitArgs { creator_group, collection, reveal_hash_all_ones_if_none, items_available, mint_price_lamports, start_time_solana_cluster_time, end_time_solana_cluster_time, sdrive_account, name_prefix, metadata_dir } - ) + ) = serde_json::from_str(&config_file_contents) else { return Err(anyhow::Error::msg("Failed to deserialize json. Do you have all fields filled in and is it formatted properly?")) }; @@ -170,8 +170,8 @@ pub(super) async fn process( .list_objects(&account) .await .map_err(|_| anyhow::Error::msg("Failed to get files in storage account"))?; - let all_files_exist = (0..items_available) - .all(|i| existing_files.contains(&format!("{i}.json"))); + let all_files_exist = + (0..items_available).all(|i| existing_files.contains(&format!("{i}.json"))); if !all_files_exist { // Check if there is enough storage @@ -376,14 +376,21 @@ pub(super) async fn process( client.get_latest_blockhash().await?, ); - match Confirm::new(&format!("Confirm Input (signing with {})", signer.pubkey())).prompt() { + match Confirm::new(&format!( + "Send and confirm transaction (signing with {})?", + signer.pubkey() + )) + .prompt() + { Ok(true) => {} _ => return Err(anyhow::Error::msg("Discarded Request")), } - if let Err(e) = client.send_and_confirm_transaction(&create_minter_tx).await { - println!("{e:#?}"); - return Err(anyhow::Error::msg(e)); + match client.send_and_confirm_transaction(&create_minter_tx).await { + Ok(sig) => { + println!("Successful: https://explorer.solana.com/tx/{sig}") + } + Err(e) => return Err(anyhow::Error::msg(format!("{e:#?}"))), }; println!("Initialized Minter for {collection_name}"); @@ -409,6 +416,14 @@ fn validate_metadata_dir( return Err(anyhow::Error::msg("failed to read directory entries")) }; + let pb = ProgressBar::new(items_available as u64) + .with_style( + ProgressStyle::with_template( + "[{elapsed_precise}] {prefix} {bar:30.cyan/blue} {pos:>7}/{len:7}", + ) + .unwrap(), + ) + .with_prefix("Validating JSON"); for i in 0..items_available as usize { // Expected filename let expected_filename = Path::new(&format!("{i}")).with_extension("json"); @@ -428,17 +443,19 @@ fn validate_metadata_dir( ))); } - // Check for companion files + // Check for companion files (minor TODO: This makes loop O(N^2)...) counts[i] += all_files_in_dir .iter() .filter(|f| f.file_stem() == Some(OsStr::new(&format!("{i}")))) .count(); + + pb.inc(1); } // Ensure every json file has a companion media file for (i, count) in counts.into_iter().enumerate() { if count == 1 { - println!("Warning: File {i}.json does not have a companion media file, e.g. {i}.png") + println!("Warning: {i}.json does not have expected companion file, e.g. {i}.png. Ignore if using some other convention.") } } @@ -451,3 +468,119 @@ fn safe_amount(additional_storage: u64, rate_per_gib: u64) -> u64 { ((additional_storage as u128) * (rate_per_gib as u128) / (BYTES_PER_GIB)) as u64 } const BYTES_PER_GIB: u128 = 1 << 30; + +#[derive(Clone, Default)] +pub struct FilePathCompleter { + input: String, + paths: Vec, + lcp: String, +} + +impl FilePathCompleter { + fn update_input(&mut self, input: &str) -> Result<(), inquire::CustomUserError> { + if input == self.input { + return Ok(()); + } + + self.input = input.to_owned(); + self.paths.clear(); + + let input_path = std::path::PathBuf::from(input); + + let fallback_parent = input_path + .parent() + .map(|p| { + if p.to_string_lossy() == "" { + std::path::PathBuf::from(".") + } else { + p.to_owned() + } + }) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + + let scan_dir = if input.ends_with('/') { + input_path + } else { + fallback_parent.clone() + }; + + let entries = match std::fs::read_dir(scan_dir) { + Ok(read_dir) => Ok(read_dir), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + std::fs::read_dir(fallback_parent) + } + Err(err) => Err(err), + }? + .collect::, _>>()?; + + let mut idx = 0; + let limit = 15; + + while idx < entries.len() && self.paths.len() < limit { + let entry = entries.get(idx).unwrap(); + + let path = entry.path(); + let path_str = if path.is_dir() { + format!("{}/", path.to_string_lossy()) + } else { + path.to_string_lossy().to_string() + }; + + if path_str.starts_with(&self.input) && path_str.len() != self.input.len() { + self.paths.push(path_str); + } + + idx = idx.saturating_add(1); + } + + self.lcp = self.longest_common_prefix(); + + Ok(()) + } + + fn longest_common_prefix(&self) -> String { + let mut ret: String = String::new(); + + let mut sorted = self.paths.clone(); + sorted.sort(); + if sorted.is_empty() { + return ret; + } + + let mut first_word = sorted.first().unwrap().chars(); + let mut last_word = sorted.last().unwrap().chars(); + + loop { + match (first_word.next(), last_word.next()) { + (Some(c1), Some(c2)) if c1 == c2 => { + ret.push(c1); + } + _ => return ret, + } + } + } +} + +impl inquire::Autocomplete for FilePathCompleter { + fn get_suggestions(&mut self, input: &str) -> Result, inquire::CustomUserError> { + self.update_input(input)?; + + Ok(self.paths.clone()) + } + + fn get_completion( + &mut self, + input: &str, + highlighted_suggestion: Option, + ) -> Result { + self.update_input(input)?; + + Ok(match highlighted_suggestion { + Some(suggestion) => inquire::autocompletion::Replacement::Some(suggestion), + None => match self.lcp.is_empty() { + true => inquire::autocompletion::Replacement::None, + false => inquire::autocompletion::Replacement::Some(self.lcp.clone()), + }, + }) + } +} diff --git a/cli/src/command/nft/minter/init_old.rs b/cli/src/command/nft/minter/init_old.rs index a66a1c4..5a15de1 100644 --- a/cli/src/command/nft/minter/init_old.rs +++ b/cli/src/command/nft/minter/init_old.rs @@ -644,9 +644,11 @@ pub(super) async fn process( client.get_latest_blockhash().await?, ); - if let Err(e) = client.send_and_confirm_transaction(&create_minter_tx).await { - println!("{e:#?}"); - return Err(anyhow::Error::msg(e)); + match client.send_and_confirm_transaction(&create_minter_tx).await { + Ok(sig) => { + println!("Successful: https://explorer.solana.com/tx/{sig}") + } + Err(e) => return Err(anyhow::Error::msg(format!("{e:#?}"))), }; println!("Initialized Minter for {collection_name}"); diff --git a/cli/src/command/nft/minter/mint.rs b/cli/src/command/nft/minter/mint.rs new file mode 100644 index 0000000..0b5813e --- /dev/null +++ b/cli/src/command/nft/minter/mint.rs @@ -0,0 +1,107 @@ +use inquire::Confirm; +use shadow_drive_sdk::{Keypair, Pubkey, Signer}; +use shadow_nft_common::get_payer_pda; +use shadow_nft_standard::common::collection::Collection; +use shadow_nft_standard::common::creator_group::CreatorGroup; +use shadow_nft_standard::common::{token_2022, Metadata}; +use shadowy_super_minter::accounts::Mint as MintAccounts; +use shadowy_super_minter::instruction::Mint as MintInstruction; +use shadowy_super_minter::state::file_type::{AccountDeserialize, InstructionData}; +use shadowy_super_minter::state::file_type::{Id, ToAccountMetas}; +use shadowy_super_minter::state::ShadowySuperMinter; +use solana_client::rpc_client::RpcClient; +use solana_sdk::instruction::Instruction; +use solana_sdk::transaction::Transaction; +use solana_sdk::{system_program, sysvar}; + +const COMPUTE: Pubkey = Pubkey::new_from_array([ + 3, 6, 70, 111, 229, 33, 23, 50, 255, 236, 173, 186, 114, 195, 155, 231, 188, 140, 229, 187, + 197, 247, 18, 107, 44, 67, 155, 58, 64, 0, 0, 0, +]); + +pub(super) async fn process( + signer: &impl Signer, + shadowy_super_minter: Pubkey, + rpc_url: &str, +) -> anyhow::Result<()> { + // Get onchain data + let client = RpcClient::new(rpc_url); + let onchain_shadowy_super_minter = ShadowySuperMinter::try_deserialize( + &mut client.get_account_data(&shadowy_super_minter)?.as_slice(), + )?; + let collection = onchain_shadowy_super_minter.collection; + let onchain_collection = + Collection::try_deserialize(&mut client.get_account_data(&collection)?.as_slice())?; + let creator_group = onchain_shadowy_super_minter.creator_group; + let onchain_creator_group = + CreatorGroup::try_deserialize(&mut client.get_account_data(&creator_group)?.as_slice())?; + + // Build mint tx + let mint_keypair = Keypair::new(); // never used after this + let mint_tx = Transaction::new_signed_with_payer( + &[ + Instruction::new_with_borsh::<[u8; 5]>( + COMPUTE, + &[0x02, 0x00, 0x06, 0x1A, 0x80], + vec![], + ), + Instruction::new_with_bytes( + shadowy_super_minter::ID, + MintInstruction {}.data().as_ref(), + MintAccounts { + shadowy_super_minter, + minter: signer.pubkey(), + minter_ata: + spl_associated_token_account::get_associated_token_address_with_program_id( + &signer.pubkey(), + &mint_keypair.pubkey(), + &token_2022::Token2022::id(), + ), + payer_pda: get_payer_pda(&mint_keypair.pubkey()), + mint: mint_keypair.pubkey(), + collection, + metadata: Metadata::derive_pda(&mint_keypair.pubkey()), + creator_group, + shadow_nft_standard: shadow_nft_standard::ID, + token_program: token_2022::Token2022::id(), + associated_token_program: spl_associated_token_account::ID, + system_program: system_program::ID, + recent_slothashes: sysvar::slot_hashes::ID, + } + .to_account_metas(None), + ), + ], + Some(&signer.pubkey()), + &[signer as &dyn Signer, &mint_keypair as &dyn Signer], + client.get_latest_blockhash()?, + ); + + // Confirm with user + println!("Minting an NFT from:"); + println!(" minter {shadowy_super_minter}"); + #[rustfmt::skip] + println!(" collection {} ({collection})", &onchain_collection.name); + #[rustfmt::skip] + println!(" creator_group {} ({})", &onchain_creator_group.name, creator_group); + match Confirm::new(&format!( + "Send and confirm transaction (signing with {})?", + signer.pubkey() + )) + .prompt() + { + Ok(true) => {} + _ => return Err(anyhow::Error::msg("Discarded Request")), + } + + // Sign and send + match client.send_and_confirm_transaction(&mint_tx) { + Ok(sig) => { + println!("Successful: https://explorer.solana.com/tx/{sig}") + } + Err(e) => return Err(anyhow::Error::msg(format!("{e:#?}"))), + }; + + println!("minted"); + + Ok(()) +} diff --git a/cli/src/command/nft/minter/mod.rs b/cli/src/command/nft/minter/mod.rs index 1a0d6cd..ec86ea8 100644 --- a/cli/src/command/nft/minter/mod.rs +++ b/cli/src/command/nft/minter/mod.rs @@ -4,11 +4,18 @@ use shadow_drive_sdk::{Pubkey, Signer}; mod get; mod init; +mod mint; #[derive(Debug, Parser)] pub enum MinterCommand { + /// Initializes a minter given a creator_group and collection Init, + + /// Gets a minter from the chain and prints its state Get { minter: Pubkey }, + + /// Mints an nft from the provided minter + Mint { minter: Pubkey }, } impl MinterCommand { @@ -22,6 +29,8 @@ impl MinterCommand { MinterCommand::Init => init::process(signer, client_signer, rpc_url).await, MinterCommand::Get { minter } => get::process(minter, rpc_url).await, + + MinterCommand::Mint { minter } => mint::process(signer, *minter, rpc_url).await, } } } diff --git a/cli/src/command/nft/utils.rs b/cli/src/command/nft/utils.rs index 47fa0ed..fd798b1 100644 --- a/cli/src/command/nft/utils.rs +++ b/cli/src/command/nft/utils.rs @@ -4,38 +4,30 @@ use serde_json::Value; use solana_sdk::{pubkey::Pubkey, transaction::VersionedTransaction}; use std::str::FromStr; -/// This function ensures the contents of a JSON file are compliant with the Metaplex Standard +/// This function ensures the contents of a JSON file are compliant with the Off-Chain Shadow Standard /// which we define as a JSON with the non-null values for the following fields: /// /// 1) `name`: Name of the asset. /// 2) `symbol`: Symbol of the asset. /// 3) `description`: Description of the asset. /// 4) `image`: URI pointing to the asset's logo. -/// 5) `animation_url`: URI pointing to the asset's animation. -/// 6) `external_url`: URI pointing to an external URL defining the asset — e.g. the game's main site. -/// 7) `attributes`: Array of attributes defining the characteristics of the asset. -/// a) `trait_type`: The type of attribute. -/// b) `value`: The value for that attribute. +/// 5) `external_url`: URI pointing to an external URL defining the asset — e.g. the game's main site. /// -/// This is taken from https://docs.metaplex.com/programs/token-metadata/token-standard and reformatted. +/// The function simply checks whether these fields are non-null. Although we do not check for it, +/// we recommend the following fields are included if relevant: /// -/// The function simply checks whether the fields are non-null -pub(crate) fn validate_json_compliance(json: &Value) -> bool { +/// 6) `animation_url` (optional): URI pointing to the asset's animation. +/// 7) `attributes` (optional): Array of attributes defining the characteristics of the asset. +/// a) `trait_type`: The type of attribute. +/// b) `value`: The value for that attribute. +pub fn validate_json_compliance(json: &Value) -> bool { let has_name = json.get("name").is_some(); let has_symbol = json.get("symbol").is_some(); let has_description = json.get("description").is_some(); let has_image = json.get("image").is_some(); - let has_animation_url = json.get("animation_url").is_some(); let has_external_url = json.get("external_url").is_some(); - let has_attributes = json.get("attributes").is_some(); - - has_name - & has_symbol - & has_description - & has_image - & has_animation_url - & has_external_url - & has_attributes + + has_name & has_symbol & has_description & has_image & has_external_url } pub(crate) async fn swap_sol_for_shdw_tx( diff --git a/py/Cargo.toml b/py/Cargo.toml index 0b5b44c..04809f1 100644 --- a/py/Cargo.toml +++ b/py/Cargo.toml @@ -17,6 +17,6 @@ concat-arrays = "0.1.2" ed25519-dalek = "1.0.1" pyo3 = { version = "0.17.3", features = ["extension-module"] } reqwest = "0.11.14" -shadow-drive-sdk = { path = "../sdk/", version = "0.7.1" } +shadow-drive-sdk = { workspace = true } tokio = { version = "1.14.1", features = ["full"] } tokio-scoped = "0.2.0"