Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(optimized_transactions): Add send_smart_transaction Functionality #29

Merged
merged 10 commits into from
May 26, 2024
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions examples/get_priority_fee_estimate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ async fn main() -> Result<(), HeliusError> {
transaction_encoding: None,
lookback_slots: None,
recommended: None,
include_vote: None,
}),
};

Expand Down
40 changes: 40 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -98,5 +132,11 @@ impl From<SerdeJsonError> for HeliusError {
}
}

impl From<SanitizeError> 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<T> = std::result::Result<T, HeliusError>;
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
218 changes: 218 additions & 0 deletions src/optimized_transaction.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
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;
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::{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
///
/// # Returns
/// The compute units consumed, or None if unsuccessful
pub async fn get_compute_units(
&self,
instructions: Vec<Instruction>,
payer: Pubkey,
lookup_tables: Vec<AddressLookupTableAccount>,
) -> Result<Option<u64>> {
// Set the compute budget limit
let test_instructions: Vec<Instruction> = vec![ComputeBudgetInstruction::set_compute_unit_limit(1_400_000)]
.into_iter()
.chain(instructions)
.collect::<Vec<_>>();

// 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 an unsigned VersionedTransaction
let transaction: VersionedTransaction = VersionedTransaction {
signatures: vec![],
message: versioned_message,
};

// Simulate the transaction
let result: Response<RpcSimulateTransactionResult> = self.connection().simulate_transaction(&transaction)?;

// 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<Signature> {
// 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, the user's keypair, whether preflight checks
/// should be skipped, and how many times to retry the transaction, if provided
///
/// # Returns
/// The transaction signature, if successful
pub async fn send_smart_transaction(&self, config: SmartTransactionConfig<'_>) -> Result<Signature> {
let pubkey: Pubkey = config.from_keypair.pubkey();
let mut recent_blockhash: Hash = self.connection().get_latest_blockhash()?;

// Build the initial transaction and estimate the priority fee
let mut transaction: Transaction = Transaction::new_with_payer(&config.instructions, Some(&pubkey));
transaction.try_sign(&[config.from_keypair], recent_blockhash)?;

// Serialize the transaction
let serialized_transaction: Vec<u8> =
serialize(&transaction).map_err(|e: Box<ErrorKind>| HeliusError::InvalidInput(e.to_string()))?;

// Convert the serialized transaction to a Base64 string
let transaction_base64: String = STANDARD.encode(&serialized_transaction);

// Get the priority fee estimate based on the serialized transaction
let priority_fee_request: GetPriorityFeeEstimateRequest = GetPriorityFeeEstimateRequest {
0xIchigo marked this conversation as resolved.
Show resolved Hide resolved
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 = priority_fee_estimate
.priority_fee_estimate
.ok_or(HeliusError::InvalidInput(
"Priority fee estimate not available".to_string(),
))?;

// Directly cast as u64
let priority_fee: u64 = priority_fee_f64 as u64;

// Add the compute unit price instruction with the estimated fee
let compute_budget_ix: Instruction = ComputeBudgetInstruction::set_compute_unit_price(priority_fee);
let mut final_instructions: Vec<Instruction> = vec![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, vec![])
0xIchigo marked this conversation as resolved.
Show resolved Hide resolved
.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);
0xIchigo marked this conversation as resolved.
Show resolved Hide resolved
final_instructions.insert(0, compute_units_ix);
}

// Build the optimized transaction
let mut optimized_transaction: Transaction = Transaction::new_with_payer(&final_instructions, Some(&pubkey));
optimized_transaction.try_sign(&[config.from_keypair], recent_blockhash)?;

// Re-fetch the blockhash every 4 retries, or roughly once every minute
let blockhash_validity_threshold: usize = 4;
0xIchigo marked this conversation as resolved.
Show resolved Hide resolved

let mut retry_count: usize = 0;
let txt_sig: Signature;

let skip_preflight_checks: bool = config.skip_preflight_checks.unwrap_or(true);
let send_transaction_config: RpcSendTransactionConfig = RpcSendTransactionConfig {
skip_preflight: skip_preflight_checks,
..Default::default()
};
let max_retries: usize = config.max_retries.unwrap_or(6);

// Send the transaction with configurable retries and preflight checks
while retry_count <= max_retries {
if retry_count > 0 && retry_count % blockhash_validity_threshold == 0 {
recent_blockhash = self.connection().get_latest_blockhash()?;
optimized_transaction.try_sign(&[config.from_keypair], recent_blockhash)?;
}

match self
.connection()
.send_transaction_with_config(&optimized_transaction, send_transaction_config)
{
Ok(signature) => {
txt_sig = signature;
return self.poll_transaction_confirmation(txt_sig).await;
}
Err(error) => {
retry_count += 1;

if retry_count > max_retries {
0xIchigo marked this conversation as resolved.
Show resolved Hide resolved
return Err(HeliusError::ClientError(error));
}

continue;
}
}
}

Err(HeliusError::Timeout {
code: StatusCode::REQUEST_TIMEOUT,
text: "Reached an unexpected point in send_smart_transaction".to_string(),
})
}
}
22 changes: 22 additions & 0 deletions src/types/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ use crate::types::{DisplayOptions, GetAssetOptions};
use serde::{Deserialize, Serialize};
use serde_json::Value;

use solana_sdk::instruction::Instruction;
use solana_sdk::signature::Keypair;

/// Defines the available clusters supported by Helius
#[derive(Debug, Clone, PartialEq)]
pub enum Cluster {
Expand Down Expand Up @@ -778,6 +781,7 @@ pub struct GetPriorityFeeEstimateOptions {
pub transaction_encoding: Option<UiTransactionEncoding>,
pub lookback_slots: Option<u8>,
pub recommended: Option<bool>,
pub include_vote: Option<bool>,
}

#[derive(Serialize, Deserialize, Debug, Default)]
Expand Down Expand Up @@ -939,3 +943,21 @@ pub struct EditWebhookRequest {
#[serde(default)]
pub encoding: AccountWebhookEncoding,
}

pub struct SmartTransactionConfig<'a> {
pub instructions: Vec<Instruction>,
pub from_keypair: &'a Keypair,
pub skip_preflight_checks: Option<bool>,
pub max_retries: Option<usize>,
}

impl<'a> SmartTransactionConfig<'a> {
pub fn new(instructions: Vec<Instruction>, from_keypair: &'a Keypair) -> Self {
Self {
instructions,
from_keypair,
skip_preflight_checks: None,
max_retries: None,
}
}
}
2 changes: 2 additions & 0 deletions tests/rpc/test_get_priority_fee_estimate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ async fn test_get_nft_editions_success() {
transaction_encoding: None,
lookback_slots: None,
recommended: None,
include_vote: None,
}),
};

Expand Down Expand Up @@ -117,6 +118,7 @@ async fn test_get_nft_editions_failure() {
transaction_encoding: None,
lookback_slots: None,
recommended: None,
include_vote: None,
}),
};

Expand Down
Loading