diff --git a/Cargo.toml b/Cargo.toml index 8db55cb..b627664 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ chrono = { version = "0.4.11", features = ["serde"] } solana-client = "1.18.12" solana-program = "1.18.12" serde-enum-str = "0.4.0" +bincode = "1.3.3" +base64 = "0.22.1" [dev-dependencies] mockito = "1.4.0" diff --git a/examples/get_priority_fee_estimate.rs b/examples/get_priority_fee_estimate.rs index 61b80b4..7c661db 100644 --- a/examples/get_priority_fee_estimate.rs +++ b/examples/get_priority_fee_estimate.rs @@ -18,6 +18,7 @@ async fn main() -> Result<(), HeliusError> { transaction_encoding: None, lookback_slots: None, recommended: None, + include_vote: None, }), }; diff --git a/src/error.rs b/src/error.rs index 3dcbaaa..0b65d37 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,9 @@ use reqwest::{Error as ReqwestError, StatusCode}; use serde_json::Error as SerdeJsonError; +use solana_client::client_error::ClientError; +use solana_sdk::{ + message::CompileError, sanitize::SanitizeError, signature::SignerError, transaction::TransactionError, +}; use thiserror::Error; /// Represents all possible errors returned by the `Helius` client @@ -14,6 +18,18 @@ pub enum HeliusError { #[error("Bad request to {path}: {text}")] BadRequest { path: String, text: String }, + /// Represents errors from the Solana client + /// + /// This captures errors from the Solana client library + #[error("Solana client error: {0}")] + ClientError(#[from] ClientError), + + /// Represents compile errors from the Solana SDK + /// + /// This captures all compile errors thrown by the Solana SDK + #[error("Compile error: {0}")] + CompileError(#[from] CompileError), + /// Represents errors that occur internally with Helius and our servers /// /// If the server encounters an unexpected condition that prevents it from fulfilling the request, this error is returned. @@ -60,6 +76,24 @@ pub enum HeliusError { #[error("Serialization / Deserialization error: {0}")] SerdeJson(SerdeJsonError), + /// Represents errors from the Solana SDK for signing operations + /// + /// This captures errors from the signing operations in the Solana SDK + #[error("Signer error: {0}")] + SignerError(#[from] SignerError), + + /// Indicates that the transaction confirmation timed out + /// + /// For polling a transaction's confirmation status + #[error("Transaction confirmation timed out with error code {code}: {text}")] + Timeout { code: StatusCode, text: String }, + + /// Represents transaction errors from the Solana SDK + /// + /// This captures errors that occur when processing transactions + #[error("Transaction error: {0}")] + TransactionError(#[from] TransactionError), + /// Indicates the request lacked valid authentication credentials /// /// This error is returned in response to a missing, invalid, or expired API key @@ -98,5 +132,11 @@ impl From for HeliusError { } } +impl From for HeliusError { + fn from(err: SanitizeError) -> Self { + HeliusError::InvalidInput(err.to_string()) + } +} + /// A handy type alias for handling results across the Helius SDK pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs index 4e34a78..05eaa10 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod enhanced_transactions; pub mod error; pub mod factory; pub mod mint_api; +pub mod optimized_transaction; pub mod request_handler; pub mod rpc_client; pub mod types; diff --git a/src/optimized_transaction.rs b/src/optimized_transaction.rs new file mode 100644 index 0000000..b384993 --- /dev/null +++ b/src/optimized_transaction.rs @@ -0,0 +1,332 @@ +use crate::error::{HeliusError, Result}; +use crate::types::{ + GetPriorityFeeEstimateOptions, GetPriorityFeeEstimateRequest, GetPriorityFeeEstimateResponse, + SmartTransactionConfig, +}; +use crate::Helius; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use bincode::{serialize, ErrorKind}; +use reqwest::StatusCode; +use solana_client::rpc_config::{RpcSendTransactionConfig, RpcSimulateTransactionConfig}; +use solana_client::rpc_response::{Response, RpcSimulateTransactionResult}; +use solana_sdk::{ + address_lookup_table::AddressLookupTableAccount, + commitment_config::CommitmentConfig, + compute_budget::ComputeBudgetInstruction, + hash::Hash, + instruction::Instruction, + message::{v0, VersionedMessage}, + pubkey::Pubkey, + signature::{Keypair, Signature, Signer}, + transaction::{Transaction, VersionedTransaction}, +}; +use std::time::{Duration, Instant}; +use tokio::time::sleep; + +impl Helius { + /// Simulates a transaction to get the total compute units consumed + /// + /// # Arguments + /// * `instructions` - The transaction instructions + /// * `payer` - The public key of the payer + /// * `lookup_tables` - The address lookup tables + /// * `from_keypair` - The keypair signing the transaction (needed to simulate the transaction) + /// + /// # Returns + /// The compute units consumed, or None if unsuccessful + pub async fn get_compute_units( + &self, + instructions: Vec, + payer: Pubkey, + lookup_tables: Vec, + from_keypair: &Keypair, + ) -> Result> { + // Set the compute budget limit + let test_instructions: Vec = vec![ComputeBudgetInstruction::set_compute_unit_limit(1_400_000)] + .into_iter() + .chain(instructions) + .collect::>(); + + // Fetch the latest blockhash + let recent_blockhash: Hash = self.connection().get_latest_blockhash()?; + + // Create a v0::Message + let v0_message: v0::Message = + v0::Message::try_compile(&payer, &test_instructions, &lookup_tables, recent_blockhash)?; + let versioned_message: VersionedMessage = VersionedMessage::V0(v0_message); + + // Create a signed VersionedTransaction + let transaction: VersionedTransaction = VersionedTransaction::try_new(versioned_message, &[from_keypair]) + .map_err(|e| HeliusError::InvalidInput(format!("Signing error: {:?}", e)))?; + + // Simulate the transaction + let config: RpcSimulateTransactionConfig = RpcSimulateTransactionConfig { + sig_verify: true, + ..Default::default() + }; + let result: Response = self + .connection() + .simulate_transaction_with_config(&transaction, config)?; + + // Return the units consumed or None if not available + Ok(result.value.units_consumed) + } + + /// Poll a transaction to check whether it has been confirmed + /// + /// * `txt-sig` - The transaction signature to check + /// + /// # Returns + /// The confirmed transaction signature or an error if the confirmation times out + pub async fn poll_transaction_confirmation(&self, txt_sig: Signature) -> Result { + // 15 second timeout + let timeout: Duration = Duration::from_secs(15); + // 5 second retry interval + let interval: Duration = Duration::from_secs(5); + let start: Instant = Instant::now(); + + let commitment_config: CommitmentConfig = CommitmentConfig::confirmed(); + + loop { + if start.elapsed() >= timeout { + return Err(HeliusError::Timeout { + code: StatusCode::REQUEST_TIMEOUT, + text: format!("Transaction {}'s confirmation timed out", txt_sig), + }); + } + + match self + .connection() + .get_signature_status_with_commitment(&txt_sig, commitment_config) + { + Ok(Some(Ok(()))) => return Ok(txt_sig), + Ok(Some(Err(err))) => return Err(HeliusError::TransactionError(err)), + Ok(None) => { + sleep(interval).await; + } + Err(err) => return Err(HeliusError::ClientError(err)), + } + } + } + + /// Builds and sends an optimized transaction, and handles its confirmation status + /// + /// # Arguments + /// * `config` - The configuration for the smart transaction, which includes the transaction's instructions, and the user's keypair. If provided, it also + /// includes whether preflight checks should be skipped, how many times to retry the transaction, and any address lookup tables to be included in the transaction + /// + /// # Returns + /// The transaction signature, if successful + pub async fn send_smart_transaction(&self, config: SmartTransactionConfig<'_>) -> Result { + let pubkey: Pubkey = config.from_keypair.pubkey(); + let mut recent_blockhash: Hash = self.connection().get_latest_blockhash()?; + let mut final_instructions: Vec = vec![]; + + // Determine if we need to use a versioned transaction + let is_versioned: bool = config.lookup_tables.is_some(); + let mut legacy_transaction: Option = None; + let mut versioned_transaction: Option = None; + + // Build the initial transaction based on whether lookup tables are present + if is_versioned { + // If lookup tables are present, we build a versioned transaction + let lookup_tables: &[AddressLookupTableAccount] = config.lookup_tables.as_deref().unwrap_or_default(); + let mut instructions: Vec = vec![ComputeBudgetInstruction::set_compute_unit_price(1)]; + instructions.extend(config.instructions.clone()); + + let v0_message: v0::Message = + v0::Message::try_compile(&pubkey, &instructions, lookup_tables, recent_blockhash)?; + let versioned_message: VersionedMessage = VersionedMessage::V0(v0_message); + + // Sign the versioned transaction + let signers: Vec<&dyn Signer> = vec![config.from_keypair]; + let signatures: Vec = signers + .iter() + .map(|signer| signer.try_sign_message(versioned_message.serialize().as_slice())) + .collect::, _>>()?; + + versioned_transaction = Some(VersionedTransaction { + signatures, + message: versioned_message, + }); + } else { + // If no lookup tables are present, we build a regular transaction + let mut tx: Transaction = Transaction::new_with_payer(&config.instructions, Some(&pubkey)); + tx.try_sign(&[config.from_keypair], recent_blockhash)?; + legacy_transaction = Some(tx); + } + + let priority_fee: u64 = if let Some(tx) = &legacy_transaction { + // Serialize the transaction + let serialized_tx: Vec = + serialize(&tx).map_err(|e: Box| HeliusError::InvalidInput(e.to_string()))?; + let transaction_base64: String = STANDARD.encode(&serialized_tx); + + // Get the priority fee estimate based on the serialized transaction + let priority_fee_request: GetPriorityFeeEstimateRequest = GetPriorityFeeEstimateRequest { + transaction: Some(transaction_base64), + account_keys: None, + options: Some(GetPriorityFeeEstimateOptions { + recommended: Some(true), + ..Default::default() + }), + }; + + let priority_fee_estimate: GetPriorityFeeEstimateResponse = + self.rpc().get_priority_fee_estimate(priority_fee_request).await?; + + let priority_fee_f64: f64 = + priority_fee_estimate + .priority_fee_estimate + .ok_or(HeliusError::InvalidInput( + "Priority fee estimate not available".to_string(), + ))?; + + priority_fee_f64 as u64 + } else if let Some(tx) = &versioned_transaction { + // Serialize the transaction + let serialized_tx: Vec = + serialize(&tx).map_err(|e: Box| HeliusError::InvalidInput(e.to_string()))?; + let transaction_base64: String = STANDARD.encode(&serialized_tx); + + // Get the priority fee estimate based on the serialized transaction + let priority_fee_request: GetPriorityFeeEstimateRequest = GetPriorityFeeEstimateRequest { + transaction: Some(transaction_base64), + account_keys: None, + options: Some(GetPriorityFeeEstimateOptions { + recommended: Some(true), + ..Default::default() + }), + }; + + let priority_fee_estimate: GetPriorityFeeEstimateResponse = + self.rpc().get_priority_fee_estimate(priority_fee_request).await?; + + let priority_fee_f64: f64 = + priority_fee_estimate + .priority_fee_estimate + .ok_or(HeliusError::InvalidInput( + "Priority fee estimate not available".to_string(), + ))?; + priority_fee_f64 as u64 + } else { + return Err(HeliusError::InvalidInput("No transaction available".to_string())); + }; + + // Add the compute unit price instruction with the estimated fee + let compute_budget_ix: Instruction = ComputeBudgetInstruction::set_compute_unit_price(priority_fee); + final_instructions.push(compute_budget_ix); + final_instructions.extend(config.instructions.clone()); + + // Get the optimal compute units + if let Some(units) = self + .get_compute_units( + final_instructions.clone(), + pubkey, + config.lookup_tables.clone().unwrap_or_default(), + &config.from_keypair, + ) + .await? + { + // Add some margin to the compute units to ensure the transaction does not fail + let compute_units_ix: Instruction = + ComputeBudgetInstruction::set_compute_unit_limit((units as f64 * 1.1).ceil() as u32); + final_instructions.insert(0, compute_units_ix); + } + + // Rebuild the transaction with the final instructions + if is_versioned { + let lookup_tables: &[AddressLookupTableAccount] = config.lookup_tables.as_deref().unwrap(); + let v0_message: v0::Message = + v0::Message::try_compile(&pubkey, &final_instructions, lookup_tables, recent_blockhash)?; + let versioned_message: VersionedMessage = VersionedMessage::V0(v0_message); + let signers: Vec<&dyn Signer> = vec![config.from_keypair]; + let signatures: Vec = signers + .iter() + .map(|signer| signer.try_sign_message(versioned_message.serialize().as_slice())) + .collect::, _>>()?; + + versioned_transaction = Some(VersionedTransaction { + signatures, + message: versioned_message, + }); + } else { + let mut tx = Transaction::new_with_payer(&final_instructions, Some(&pubkey)); + tx.try_sign(&[config.from_keypair], recent_blockhash)?; + legacy_transaction = Some(tx); + } + + // Re-fetch interval of 60 seconds + let blockhash_refetch_interval: Duration = Duration::from_secs(60); + let mut last_blockhash_refetch: Instant = Instant::now(); + + let send_transaction_config: RpcSendTransactionConfig = RpcSendTransactionConfig { + skip_preflight: config.skip_preflight_checks.unwrap_or(true), + ..Default::default() + }; + + // Common logic for sending transactions + let send_result = |transaction: &Transaction| { + self.connection() + .send_transaction_with_config(transaction, send_transaction_config) + }; + let send_versioned_result = |transaction: &VersionedTransaction| { + self.connection() + .send_transaction_with_config(transaction, send_transaction_config) + }; + + // Send the transaction with retries and preflight checks + let mut retry_count: usize = 0; + let max_retries: usize = config.max_retries.unwrap_or(6); + + while retry_count <= max_retries { + if last_blockhash_refetch.elapsed() >= blockhash_refetch_interval { + recent_blockhash = self.connection().get_latest_blockhash()?; + if is_versioned { + let signers: Vec<&dyn Signer> = vec![config.from_keypair]; + let signed_message: Vec = signers + .iter() + .map(|signer| { + signer.try_sign_message( + versioned_transaction.as_ref().unwrap().message.serialize().as_slice(), + ) + }) + .collect::, _>>()?; + versioned_transaction.as_mut().unwrap().signatures = signed_message; + } else { + legacy_transaction + .as_mut() + .unwrap() + .try_sign(&[config.from_keypair], recent_blockhash)?; + } + + // Update the last refetch time + last_blockhash_refetch = Instant::now(); + } + + let result = if is_versioned { + send_versioned_result(versioned_transaction.as_ref().unwrap()) + } else { + send_result(legacy_transaction.as_ref().unwrap()) + }; + + match result { + Ok(signature) => return self.poll_transaction_confirmation(signature).await, + Err(error) => { + retry_count += 1; + if retry_count > max_retries { + return Err(HeliusError::ClientError(error)); + } + continue; + } + } + } + + Err(HeliusError::Timeout { + code: StatusCode::REQUEST_TIMEOUT, + text: "Reached an unexpected point in send_smart_transaction".to_string(), + }) + } +} diff --git a/src/types/types.rs b/src/types/types.rs index 8b8eea9..8ecb7a6 100644 --- a/src/types/types.rs +++ b/src/types/types.rs @@ -8,6 +8,8 @@ use crate::types::{DisplayOptions, GetAssetOptions}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use solana_sdk::{address_lookup_table::AddressLookupTableAccount, instruction::Instruction, signature::Keypair}; + /// Defines the available clusters supported by Helius #[derive(Debug, Clone, PartialEq)] pub enum Cluster { @@ -778,6 +780,7 @@ pub struct GetPriorityFeeEstimateOptions { pub transaction_encoding: Option, pub lookback_slots: Option, pub recommended: Option, + pub include_vote: Option, } #[derive(Serialize, Deserialize, Debug, Default)] @@ -939,3 +942,23 @@ pub struct EditWebhookRequest { #[serde(default)] pub encoding: AccountWebhookEncoding, } + +pub struct SmartTransactionConfig<'a> { + pub instructions: Vec, + pub from_keypair: &'a Keypair, + pub skip_preflight_checks: Option, + pub max_retries: Option, + pub lookup_tables: Option>, +} + +impl<'a> SmartTransactionConfig<'a> { + pub fn new(instructions: Vec, from_keypair: &'a Keypair) -> Self { + Self { + instructions, + from_keypair, + skip_preflight_checks: None, + max_retries: None, + lookup_tables: None, + } + } +} diff --git a/tests/rpc/test_get_priority_fee_estimate.rs b/tests/rpc/test_get_priority_fee_estimate.rs index d5d629c..1654cdb 100644 --- a/tests/rpc/test_get_priority_fee_estimate.rs +++ b/tests/rpc/test_get_priority_fee_estimate.rs @@ -63,6 +63,7 @@ async fn test_get_nft_editions_success() { transaction_encoding: None, lookback_slots: None, recommended: None, + include_vote: None, }), }; @@ -117,6 +118,7 @@ async fn test_get_nft_editions_failure() { transaction_encoding: None, lookback_slots: None, recommended: None, + include_vote: None, }), };