diff --git a/Cargo.toml b/Cargo.toml index 42037cc..cdbd15c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helius" -version = "0.2.2" +version = "0.2.3" edition = "2021" description = "An asynchronous Helius Rust SDK for building the future of Solana" keywords = ["helius", "solana", "asynchronous-sdk", "das", "cryptocurrency"] @@ -20,17 +20,17 @@ futures-util = "0.3.30" mpl-token-metadata = { version = "5.0.0-beta.0" } phf = { version = "0.11.2", features = ["macros"] } rand = "0.8.5" -reqwest = { version = "0.12.8", features = ["json", "native-tls"] } +reqwest = { version = "0.11.22", features = ["json", "native-tls"] } semver = "1.0.23" serde = "1.0.198" serde-enum-str = "0.4.0" serde_json = "1.0.116" -solana-account-decoder = "2.0" -solana-client = "2.0" -solana-program = "2.0" -solana-rpc-client-api = "2.0" -solana-sdk = "2.0" -solana-transaction-status = "2.0" +solana-account-decoder = "2.1.4" +solana-client = "2.1.4" +solana-program = "2.1.4" +solana-rpc-client-api = "2.1.4" +solana-sdk = "2.1.4" +solana-transaction-status = "2.1.4" thiserror = "1.0.58" tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread", "net", "time"] } tokio-stream = "0.1.15" @@ -51,9 +51,3 @@ rustls = [ "reqwest/rustls-tls", "tokio-tungstenite/rustls-tls-webpki-roots" ] - -[patch.crates-io] -# https://github.com/solana-labs/solana/issues/26688#issuecomment-2136066879 -# For curve25519-dalek use the same revision Solana uses -# https://github.com/solana-labs/solana/blob/27eff8408b7223bb3c4ab70523f8a8dca3ca6645/Cargo.toml#L475-L563 -curve25519-dalek = { git = "https://github.com/solana-labs/curve25519-dalek.git", rev = "b500cdc2a920cd5bff9e2dd974d7b97349d61464" } diff --git a/examples/async_config_based_creation.rs b/examples/async_config_based_creation.rs new file mode 100644 index 0000000..e23b543 --- /dev/null +++ b/examples/async_config_based_creation.rs @@ -0,0 +1,23 @@ +use helius::config::Config; +use helius::error::Result; +use helius::types::Cluster; +use helius::Helius; + +/// Demonstrates creating a Helius client with async Solana capabilities using the config-based approach +#[tokio::main] +async fn main() -> Result<()> { + let api_key: &str = "your_api_key"; + let cluster: Cluster = Cluster::MainnetBeta; + + let config: Config = Config::new(api_key, cluster)?; + let async_client: Helius = config.create_client_with_async()?; + + if let Ok(async_conn) = async_client.async_connection() { + println!( + "Async client - Get Block Height: {:?}", + async_conn.get_block_height().await + ); + } + + Ok(()) +} diff --git a/examples/enhanced_websocket_transactions.rs b/examples/enhanced_websocket_transactions.rs index 5115c62..da287ab 100644 --- a/examples/enhanced_websocket_transactions.rs +++ b/examples/enhanced_websocket_transactions.rs @@ -9,7 +9,8 @@ async fn main() -> Result<()> { let api_key: &str = "your_api_key"; let cluster: Cluster = Cluster::MainnetBeta; - let helius: Helius = Helius::new_with_ws(api_key, cluster).await.unwrap(); + // Uses custom ping-pong timeouts to ping every 15s and timeout after 45s of no pong + let helius: Helius = Helius::new_with_ws_with_timeouts(api_key, cluster, Some(15), Some(45)).await?; let key: pubkey::Pubkey = pubkey!("BtsmiEEvnSuUnKxqXj2PZRYpPJAc7C34mGz8gtJ1DAaH"); diff --git a/examples/send_smart_transaction_with_tip.rs b/examples/send_smart_transaction_with_tip.rs index 9e3dc74..a0f5f7f 100644 --- a/examples/send_smart_transaction_with_tip.rs +++ b/examples/send_smart_transaction_with_tip.rs @@ -32,6 +32,7 @@ async fn main() { signers: vec![Arc::new(from_keypair)], lookup_tables: None, fee_payer: None, + priority_fee_cap: None, }; let config: SmartTransactionConfig = SmartTransactionConfig { diff --git a/src/client.rs b/src/client.rs index 8d4ce9b..9aa567e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -95,14 +95,23 @@ impl Helius { /// # Arguments /// * `api_key` - The API key required for authenticating requests made /// * `cluster` - The Solana cluster (Devnet or MainnetBeta) that defines the given network environment + /// * `ping_interval_secs` - Optional duration in seconds between ping messages (defaults to 10 seconds if None) + /// * `pong_timeout_secs` - Optional duration in seconds to wait for a pong response before considering the connection dead + /// /// # Returns /// An instance of `Helius` if successful. A `HeliusError` is returned if an error occurs during configuration or initialization of the HTTP, RPC, or WS client - pub async fn new_with_ws(api_key: &str, cluster: Cluster) -> Result { + pub async fn new_with_ws_with_timeouts( + api_key: &str, + cluster: Cluster, + ping_interval_secs: Option, + pong_timeout_secs: Option, + ) -> Result { let config: Arc = Arc::new(Config::new(api_key, cluster)?); let client: Client = Client::builder().build().map_err(HeliusError::ReqwestError)?; let rpc_client: Arc = Arc::new(RpcClient::new(Arc::new(client.clone()), config.clone())?); let wss: String = format!("{}{}", ENHANCED_WEBSOCKET_URL, api_key); - let ws_client: Arc = Arc::new(EnhancedWebsocket::new(&wss).await?); + let ws_client: Arc = + Arc::new(EnhancedWebsocket::new(&wss, ping_interval_secs, pong_timeout_secs).await?); Ok(Helius { config, @@ -113,6 +122,20 @@ impl Helius { }) } + /// Creates a new instance of `Helius` with an enhanced websocket client using default timeout settings. + /// This is a convenience method that uses default values of 10 seconds for ping interval and 3 failed pings + /// before considering the connection dead. + /// + /// # Arguments + /// * `api_key` - The API key required for authenticating requests made + /// * `cluster` - The Solana cluster (Devnet or MainnetBeta) that defines the given network environment + /// + /// # Returns + /// An instance of `Helius` if successful. A `HeliusError` is returned if an error occurs during configuration or initialization of the HTTP, RPC, or WS client + pub async fn new_with_ws(api_key: &str, cluster: Cluster) -> Result { + Self::new_with_ws_with_timeouts(api_key, cluster, None, None).await + } + /// Provides a thread-safe way to access RPC functionalities /// /// # Returns diff --git a/src/config.rs b/src/config.rs index 3630d66..1700980 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,12 @@ use crate::error::{HeliusError, Result}; +use crate::rpc_client::RpcClient; use crate::types::{Cluster, HeliusEndpoints, MintApiAuthority}; +use crate::websocket::{EnhancedWebsocket, ENHANCED_WEBSOCKET_URL}; +use crate::Helius; +use reqwest::Client; +use solana_client::nonblocking::rpc_client::RpcClient as AsyncSolanaRpcClient; +use std::sync::Arc; +use url::{ParseError, Url}; /// Configuration settings for the Helius client /// @@ -41,6 +48,115 @@ impl Config { }) } + pub fn rpc_client_with_reqwest_client(&self, client: Client) -> Result { + RpcClient::new(Arc::new(client), Arc::new(self.clone())) + } + + /// Creates a basic Helius client from this configuration + /// + /// # Returns + /// A `Result` containing a Helius client with basic RPC capabilities + pub fn create_client(self) -> Result { + let client: Client = Client::builder().build().map_err(HeliusError::ReqwestError)?; + let rpc_client: Arc = Arc::new(self.rpc_client_with_reqwest_client(client.clone())?); + + Ok(Helius { + config: Arc::new(self), + client, + rpc_client, + async_rpc_client: None, + ws_client: None, + }) + } + + /// Creates a Helius client with async Solana capabilities + /// + /// # Returns + /// A `Result` containing a Helius client with both RPC and async Solana capabilities + pub fn create_client_with_async(self) -> Result { + let client: Client = Client::builder().build().map_err(HeliusError::ReqwestError)?; + let mut rpc_url: Url = Url::parse(&self.endpoints.rpc) + .map_err(|e: ParseError| HeliusError::InvalidInput(format!("Invalid RPC URL: {}", e)))?; + + rpc_url.query_pairs_mut().append_pair("api-key", &self.api_key); + + let async_solana_client: Arc = Arc::new(AsyncSolanaRpcClient::new(rpc_url.to_string())); + let rpc_client: Arc = Arc::new(self.rpc_client_with_reqwest_client(client.clone())?); + + Ok(Helius { + config: Arc::new(self), + client, + rpc_client, + async_rpc_client: Some(async_solana_client), + ws_client: None, + }) + } + + /// Creates a Helius client with websocket support + /// + /// # Arguments + /// * `ping_interval_secs` - Optional duration in seconds between ping messages + /// * `pong_timeout_secs` - Optional duration in seconds to wait for pong response + /// + /// # Returns + /// A `Result` containing a Helius client with websocket support + pub async fn create_client_with_ws( + self, + ping_interval_secs: Option, + pong_timeout_secs: Option, + ) -> Result { + let client: Client = Client::builder().build().map_err(HeliusError::ReqwestError)?; + let rpc_client: Arc = Arc::new(self.rpc_client_with_reqwest_client(client.clone())?); + + let wss: String = format!("{}{}", ENHANCED_WEBSOCKET_URL, self.api_key); + let ws_client: Arc = + Arc::new(EnhancedWebsocket::new(&wss, ping_interval_secs, pong_timeout_secs).await?); + + Ok(Helius { + config: Arc::new(self), + client, + rpc_client, + async_rpc_client: None, + ws_client: Some(ws_client), + }) + } + + /// Creates a full-featured Helius client with both async and websocket support + /// + /// # Arguments + /// * `ping_interval_secs` - Optional duration in seconds between ping messages + /// * `pong_timeout_secs` - Optional duration in seconds to wait for pong response + /// + /// # Returns + /// A `Result` containing a fully-featured Helius client + pub async fn create_full_client( + self, + ping_interval_secs: Option, + pong_timeout_secs: Option, + ) -> Result { + let client: Client = Client::builder().build().map_err(HeliusError::ReqwestError)?; + let rpc_client: Arc = Arc::new(self.rpc_client_with_reqwest_client(client.clone())?); + + // Setup async client + let mut rpc_url: Url = Url::parse(&self.endpoints.rpc) + .map_err(|e: ParseError| HeliusError::InvalidInput(format!("Invalid RPC URL: {}", e)))?; + rpc_url.query_pairs_mut().append_pair("api-key", &self.api_key); + let async_solana_client = Arc::new(AsyncSolanaRpcClient::new(rpc_url.to_string())); + + // Setup websocket + let wss: String = format!("{}{}", ENHANCED_WEBSOCKET_URL, self.api_key); + let ws_client: Arc = + Arc::new(EnhancedWebsocket::new(&wss, ping_interval_secs, pong_timeout_secs).await?); + + Ok(Helius { + config: Arc::new(self), + client, + rpc_client, + async_rpc_client: Some(async_solana_client), + ws_client: Some(ws_client), + }) + } + pub fn mint_api_authority(&self) -> MintApiAuthority { MintApiAuthority::from_cluster(&self.cluster) } diff --git a/src/optimized_transaction.rs b/src/optimized_transaction.rs index 108885a..7cad1f4 100644 --- a/src/optimized_transaction.rs +++ b/src/optimized_transaction.rs @@ -244,9 +244,15 @@ impl Helius { "Priority fee estimate not available".to_string(), ))? as u64; + let priority_fee: u64 = if let Some(provided_fee) = config.priority_fee_cap { + // Take the minimum between the estimate and the user-provided cap + std::cmp::min(priority_fee_recommendation, provided_fee) + } else { + priority_fee_recommendation + }; + // Add the compute unit price instruction with the estimated fee - let compute_budget_ix: Instruction = - ComputeBudgetInstruction::set_compute_unit_price(priority_fee_recommendation); + let compute_budget_ix: Instruction = ComputeBudgetInstruction::set_compute_unit_price(priority_fee); let mut updated_instructions: Vec = config.instructions.clone(); updated_instructions.push(compute_budget_ix.clone()); final_instructions.push(compute_budget_ix); @@ -474,6 +480,7 @@ impl Helius { signers, lookup_tables: create_config.lookup_tables, fee_payer: Some(fee_payer), + priority_fee_cap: create_config.priority_fee_cap, }; let smart_transaction_config: SmartTransactionConfig = SmartTransactionConfig { diff --git a/src/types/types.rs b/src/types/types.rs index 3b0f97c..f627021 100644 --- a/src/types/types.rs +++ b/src/types/types.rs @@ -956,6 +956,7 @@ pub struct CreateSmartTransactionConfig { pub signers: Vec>, pub lookup_tables: Option>, pub fee_payer: Option>, + pub priority_fee_cap: Option, } impl CreateSmartTransactionConfig { @@ -965,6 +966,7 @@ impl CreateSmartTransactionConfig { signers, lookup_tables: None, fee_payer: None, + priority_fee_cap: None, } } } @@ -1016,6 +1018,7 @@ pub struct CreateSmartTransactionSeedConfig { pub signer_seeds: Vec<[u8; 32]>, pub fee_payer_seed: Option<[u8; 32]>, pub lookup_tables: Option>, + pub priority_fee_cap: Option, } impl CreateSmartTransactionSeedConfig { @@ -1025,6 +1028,7 @@ impl CreateSmartTransactionSeedConfig { signer_seeds, fee_payer_seed: None, lookup_tables: None, + priority_fee_cap: None, } } diff --git a/src/websocket.rs b/src/websocket.rs index fc6c43c..13d6284 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -30,7 +30,8 @@ use tokio_tungstenite::{ }; pub const ENHANCED_WEBSOCKET_URL: &str = "wss://atlas-mainnet.helius-rpc.com/?api-key="; -const DEFAULT_PING_DURATION_SECONDS: u64 = 10; +pub const DEFAULT_PING_DURATION_SECONDS: u64 = 10; +pub const DEFAULT_MAX_FAILED_PINGS: usize = 3; // pub type Result = Result; @@ -40,7 +41,7 @@ type SubscribeRequestMsg = (String, Value, oneshot::Sender type SubscribeResult<'a, T> = Result<(BoxStream<'a, T>, UnsubscribeFn)>; type RequestMsg = (String, Value, oneshot::Sender>); -/// A client for subscribing to transaction or account updates from a Helius (geyser) enhanced websocket server. +/// A client for subscribing to transaction or account updates from a Helius (Geyser) enhanced websocket server. /// /// Forked from Solana's [`PubsubClient`]. pub struct EnhancedWebsocket { @@ -52,13 +53,26 @@ pub struct EnhancedWebsocket { impl EnhancedWebsocket { /// Expects enhanced websocket endpoint: wss://atlas-mainnet.helius-rpc.com?api-key= - pub async fn new(url: &str) -> Result { + pub async fn new(url: &str, ping_interval_secs: Option, pong_timeout_secs: Option) -> Result { let (ws, _response) = connect_async(url).await.map_err(HeliusError::Tungstenite)?; let (subscribe_sender, subscribe_receiver) = mpsc::unbounded_channel(); let (_request_sender, request_receiver) = mpsc::unbounded_channel(); let (shutdown_sender, shutdown_receiver) = oneshot::channel(); + let ping_interval = ping_interval_secs + .filter(|interval: &u64| *interval != 0) + .unwrap_or(DEFAULT_PING_DURATION_SECONDS); + let max_failed_pings = pong_timeout_secs + .map(|timeout| (timeout as f64 / ping_interval as f64).ceil() as usize) + .map_or(DEFAULT_MAX_FAILED_PINGS, |max_failed_pings| { + if max_failed_pings != 0 { + max_failed_pings + } else { + usize::MAX + } + }); + Ok(Self { subscribe_sender, shutdown_sender, @@ -68,7 +82,8 @@ impl EnhancedWebsocket { subscribe_receiver, request_receiver, shutdown_receiver, - DEFAULT_PING_DURATION_SECONDS, + ping_interval, + max_failed_pings, )), }) } @@ -189,8 +204,10 @@ impl EnhancedWebsocket { mut request_receiver: mpsc::UnboundedReceiver, mut shutdown_receiver: oneshot::Receiver<()>, ping_duration_seconds: u64, + max_failed_pings: usize, ) -> Result<()> { let mut request_id: u64 = 0; + let mut unmatched_pings: usize = 0; let mut requests_subscribe = BTreeMap::new(); let mut requests_unsubscribe = BTreeMap::>::new(); @@ -209,7 +226,23 @@ impl EnhancedWebsocket { }, // Send `Message::Ping` each 10s if no any other communication () = sleep(Duration::from_secs(ping_duration_seconds)) => { + // Check if we've exceeded our failed ping threshold + if unmatched_pings >= max_failed_pings { + let frame = CloseFrame { + code: CloseCode::Abnormal, + reason: format!("No pong received after {} pings", max_failed_pings).into() + }; + + ws.send(Message::Close(Some(frame))).await?; + ws.flush().await?; + + return Err(HeliusError::WebsocketClosed( + format!("Connection timeout: no pong received after {} pings", max_failed_pings) + )); + } + ws.send(Message::Ping(Vec::new())).await?; + unmatched_pings += 1; }, // Read message for subscribe Some((operation, params, response_sender)) = subscribe_receiver.recv() => { @@ -242,6 +275,9 @@ impl EnhancedWebsocket { None => break, }; + // Reset unmatched_pings on any received frame + unmatched_pings = 0; + // Get text from the message let text = match msg { Message::Text(text) => text, @@ -250,7 +286,9 @@ impl EnhancedWebsocket { ws.send(Message::Pong(data)).await?; continue }, - Message::Pong(_data) => continue, + Message::Pong(_data) => { + continue; + }, Message::Close(_frame) => break, Message::Frame(_frame) => continue, }; diff --git a/tests/test_config.rs b/tests/test_config.rs index 75568aa..83f99e3 100644 --- a/tests/test_config.rs +++ b/tests/test_config.rs @@ -1,6 +1,7 @@ use helius::config::Config; use helius::error::{HeliusError, Result}; use helius::types::Cluster; +use helius::Helius; #[test] fn test_config_new_with_empty_api_key() { @@ -18,3 +19,25 @@ fn test_config_new_with_valid_api_key() { assert_eq!(config.endpoints.api, "https://api-devnet.helius-rpc.com/"); assert_eq!(config.endpoints.rpc, "https://devnet.helius-rpc.com/"); } + +#[test] +fn test_create_basic_client() { + let config: Config = Config::new("valid-api-key", Cluster::Devnet).unwrap(); + let result: Result = config.create_client(); + assert!(result.is_ok()); + + let client: Helius = result.unwrap(); + assert!(client.async_rpc_client.is_none()); + assert!(client.ws_client.is_none()); +} + +#[test] +fn test_create_async_client() { + let config: Config = Config::new("valid-api-key", Cluster::Devnet).unwrap(); + let result: Result = config.create_client_with_async(); + assert!(result.is_ok()); + + let client: Helius = result.unwrap(); + assert!(client.async_rpc_client.is_some()); + assert!(client.ws_client.is_none()); +}