openzeppelin_relayer/services/provider/solana/
mod.rs

1//! Solana Provider Module
2//!
3//! This module provides an abstraction layer over the Solana RPC client,
4//! offering common operations such as retrieving account balance, fetching
5//! the latest blockhash, sending transactions, confirming transactions, and
6//! querying the minimum balance for rent exemption.
7//!
8//! The provider uses the non-blocking `RpcClient` for asynchronous operations
9//! and integrates detailed error handling through the `ProviderError` type.
10//!
11use async_trait::async_trait;
12use eyre::Result;
13#[cfg(test)]
14use mockall::automock;
15use mpl_token_metadata::accounts::Metadata;
16use reqwest::Url;
17use serde::Serialize;
18use solana_client::{
19    client_error::{ClientError, ClientErrorKind},
20    nonblocking::rpc_client::RpcClient,
21    rpc_request::RpcRequest,
22    rpc_response::{RpcPrioritizationFee, RpcSimulateTransactionResult},
23};
24use solana_commitment_config::CommitmentConfig;
25use solana_sdk::{
26    account::Account,
27    hash::Hash,
28    message::Message,
29    program_pack::Pack,
30    pubkey::Pubkey,
31    signature::Signature,
32    transaction::{Transaction, VersionedTransaction},
33};
34use spl_token_interface::state::Mint;
35use std::{str::FromStr, sync::Arc, time::Duration};
36use thiserror::Error;
37
38use once_cell::sync::Lazy;
39
40use crate::{
41    models::{RpcConfig, SolanaTransactionStatus},
42    services::{
43        client_cache::SyncClientCache,
44        provider::{retry_rpc_call, should_mark_provider_failed_by_status_code},
45    },
46};
47
48use super::ProviderError;
49use super::{
50    rpc_selector::{RpcSelector, RpcSelectorError},
51    ProviderConfig, RetryConfig,
52};
53
54use crate::utils::validate_safe_url;
55
56#[derive(Clone, Debug, Eq, PartialEq, Hash)]
57struct SolanaRpcClientKey {
58    url: String,
59    timeout_ms: u128,
60    commitment: CommitmentConfig,
61}
62
63static SOLANA_RPC_CLIENT_CACHE: Lazy<SyncClientCache<SolanaRpcClientKey, RpcClient>> =
64    Lazy::new(SyncClientCache::new);
65
66/// Utility function to match error patterns by normalizing both strings.
67/// Removes spaces and converts to lowercase for flexible matching.
68///
69/// This allows matching patterns like "invalid instruction data" against errors
70/// containing "invalidinstructiondata", "invalid instruction data", etc.
71fn matches_error_pattern(error_msg: &str, pattern: &str) -> bool {
72    let normalized_msg = error_msg.to_lowercase().replace(' ', "");
73    let normalized_pattern = pattern.to_lowercase().replace(' ', "");
74    normalized_msg.contains(&normalized_pattern)
75}
76
77/// Errors that can occur when interacting with the Solana provider.
78///
79/// Use `is_transient()` to determine if an error should be retried.
80#[derive(Error, Debug, Serialize)]
81pub enum SolanaProviderError {
82    /// Network/IO error (transient - connection issues, timeouts)
83    #[error("Network error: {0}")]
84    NetworkError(String),
85
86    /// RPC protocol error (transient - RPC-level issues like node lag, sync pending)
87    #[error("RPC error: {0}")]
88    RpcError(String),
89
90    /// HTTP request error with status code (transient/permanent based on status code)
91    #[error("Request error (HTTP {status_code}): {error}")]
92    RequestError { error: String, status_code: u16 },
93
94    /// Invalid address format (permanent)
95    #[error("Invalid address: {0}")]
96    InvalidAddress(String),
97
98    /// RPC selector error (transient - can retry with different node)
99    #[error("RPC selector error: {0}")]
100    SelectorError(RpcSelectorError),
101
102    /// Network configuration error (permanent - missing data, unsupported operations)
103    #[error("Network configuration error: {0}")]
104    NetworkConfiguration(String),
105
106    /// Insufficient funds for transaction (permanent)
107    #[error("Insufficient funds for transaction: {0}")]
108    InsufficientFunds(String),
109
110    /// Blockhash not found or expired (transient - can rebuild with fresh blockhash)
111    #[error("Blockhash not found or expired: {0}")]
112    BlockhashNotFound(String),
113
114    /// Invalid transaction structure or execution (permanent)
115    #[error("Invalid transaction: {0}")]
116    InvalidTransaction(String),
117
118    /// Transaction already processed (permanent - duplicate)
119    #[error("Transaction already processed: {0}")]
120    AlreadyProcessed(String),
121}
122
123impl SolanaProviderError {
124    /// Determines if this error is transient (can retry) or permanent (should fail).
125    ///
126    /// With comprehensive error code classification in `from_rpc_response_error()`,
127    /// errors are properly categorized at the source, so we can simply match on variants.
128    ///
129    /// **Transient (can retry):**
130    /// - `NetworkError`: IO/connection errors, timeouts, network unavailable
131    /// - `RpcError`: RPC protocol issues, node lag, sync pending (-32004, -32005, -32014, -32016)
132    /// - `BlockhashNotFound`: Can rebuild transaction with fresh blockhash (-32008)
133    /// - `SelectorError`: Can retry with different RPC node
134    /// - `RequestError`: HTTP errors with retriable status codes (5xx, 408, 425, 429)
135    ///
136    /// **Permanent (fail immediately):**
137    /// - `InsufficientFunds`: Not enough balance for transaction
138    /// - `InvalidTransaction`: Malformed transaction, invalid signatures, version mismatch (-32002, -32003, -32013, -32015, -32602)
139    /// - `AlreadyProcessed`: Duplicate transaction already on-chain (-32009)
140    /// - `InvalidAddress`: Invalid public key format
141    /// - `NetworkConfiguration`: Missing data, unsupported operations (-32007, -32010)
142    /// - `RequestError`: HTTP errors with non-retriable status codes (4xx except 408, 425, 429)
143    pub fn is_transient(&self) -> bool {
144        match self {
145            // Transient errors - safe to retry
146            SolanaProviderError::NetworkError(_) => true,
147            SolanaProviderError::RpcError(_) => true,
148            SolanaProviderError::BlockhashNotFound(_) => true,
149            SolanaProviderError::SelectorError(_) => true,
150
151            // RequestError - check status code to determine if retriable
152            SolanaProviderError::RequestError { status_code, .. } => match *status_code {
153                // Non-retriable 5xx: persistent server-side issues
154                501 | 505 => false, // Not Implemented, HTTP Version Not Supported
155
156                // Retriable 5xx: temporary server-side issues
157                500 | 502..=504 | 506..=599 => true,
158
159                // Retriable 4xx: timeout or rate-limit related
160                408 | 425 | 429 => true,
161
162                // Non-retriable 4xx: client errors
163                400..=499 => false,
164
165                // Other status codes: not retriable
166                _ => false,
167            },
168
169            // Permanent errors - fail immediately
170            SolanaProviderError::InsufficientFunds(_) => false,
171            SolanaProviderError::InvalidTransaction(_) => false,
172            SolanaProviderError::AlreadyProcessed(_) => false,
173            SolanaProviderError::InvalidAddress(_) => false,
174            SolanaProviderError::NetworkConfiguration(_) => false,
175        }
176    }
177
178    /// Classifies a Solana RPC client error into the appropriate error variant.
179    ///
180    /// Uses structured error types from the Solana SDK for precise classification,
181    /// including JSON-RPC error codes for enhanced accuracy.
182    pub fn from_rpc_error(error: ClientError) -> Self {
183        match error.kind() {
184            // Network/IO errors - connection issues, timeouts (transient)
185            ClientErrorKind::Io(_) => SolanaProviderError::NetworkError(error.to_string()),
186
187            // Reqwest errors - extract status code if available
188            ClientErrorKind::Reqwest(reqwest_err) => {
189                if let Some(status) = reqwest_err.status() {
190                    SolanaProviderError::RequestError {
191                        error: error.to_string(),
192                        status_code: status.as_u16(),
193                    }
194                } else {
195                    // No status code available (e.g., connection error, timeout)
196                    SolanaProviderError::NetworkError(error.to_string())
197                }
198            }
199
200            // RPC errors - classify based on error code and message
201            ClientErrorKind::RpcError(rpc_err) => {
202                let rpc_err_str = format!("{rpc_err}");
203                Self::from_rpc_response_error(&rpc_err_str, &error)
204            }
205
206            // Transaction errors - classify based on specific error type
207            ClientErrorKind::TransactionError(tx_error) => {
208                Self::from_transaction_error(tx_error, &error)
209            }
210
211            // Custom errors from Solana client - reuse pattern matching logic
212            ClientErrorKind::Custom(msg) => {
213                // Delegate to from_rpc_response_error for consistent classification
214                Self::from_rpc_response_error(msg, &error)
215            }
216
217            // All other error types
218            _ => SolanaProviderError::RpcError(error.to_string()),
219        }
220    }
221
222    /// Classifies RPC response errors using error codes and messages.
223    ///
224    /// Solana JSON-RPC 2.0 error codes (see https://www.quicknode.com/docs/solana/error-references):
225    ///
226    /// **Transient errors (can retry):**
227    /// - `-32004`: Block not available for slot - temporary, retry recommended
228    /// - `-32005`: Node is unhealthy/behind - temporary node lag
229    /// - `-32008`: Blockhash not found - can rebuild transaction with fresh blockhash
230    /// - `-32014`: Block status not yet available - pending sync, retry later
231    /// - `-32016`: Minimum context slot not reached - future slot, retry later
232    ///
233    /// **Permanent errors (fail immediately):**
234    /// - `-32002`: Transaction simulation failed - check message for specific cause
235    /// - `-32003`: Signature verification failure - invalid signatures
236    /// - `-32007`: Slot skipped/missing (snapshot jump) - data unavailable
237    /// - `-32009`: Already processed - duplicate transaction
238    /// - `-32010`: Key excluded from secondary indexes - RPC method unavailable
239    /// - `-32013`: Transaction signature length mismatch - malformed transaction
240    /// - `-32015`: Transaction version not supported - client version mismatch
241    /// - `-32602`: Invalid params - malformed request parameters
242    fn from_rpc_response_error(rpc_err: &str, full_error: &ClientError) -> Self {
243        let error_str = rpc_err;
244
245        // Check for specific error codes in the error string
246        if error_str.contains("-32002") {
247            // Transaction simulation failed - check message for specific issues
248            if matches_error_pattern(error_str, "blockhash not found") {
249                SolanaProviderError::BlockhashNotFound(full_error.to_string())
250            } else if matches_error_pattern(error_str, "insufficient funds") {
251                SolanaProviderError::InsufficientFunds(full_error.to_string())
252            } else {
253                // Most simulation failures are permanent (invalid instruction data, etc.)
254                SolanaProviderError::InvalidTransaction(full_error.to_string())
255            }
256        } else if error_str.contains("-32003") {
257            // Signature verification failure - permanent
258            SolanaProviderError::InvalidTransaction(full_error.to_string())
259        } else if error_str.contains("-32004") {
260            // Block not available - transient, retry recommended
261            SolanaProviderError::RpcError(full_error.to_string())
262        } else if error_str.contains("-32005") {
263            // Node is behind - transient
264            SolanaProviderError::RpcError(full_error.to_string())
265        } else if error_str.contains("-32007") {
266            // Slot skipped/missing due to snapshot jump - permanent
267            SolanaProviderError::NetworkConfiguration(full_error.to_string())
268        } else if error_str.contains("-32008") {
269            // Blockhash not found - transient (can rebuild transaction)
270            SolanaProviderError::BlockhashNotFound(full_error.to_string())
271        } else if error_str.contains("-32009") {
272            // Already processed - permanent
273            SolanaProviderError::AlreadyProcessed(full_error.to_string())
274        } else if error_str.contains("-32010") {
275            // Key excluded from secondary indexes - permanent
276            SolanaProviderError::NetworkConfiguration(full_error.to_string())
277        } else if error_str.contains("-32013") {
278            // Transaction signature length mismatch - permanent
279            SolanaProviderError::InvalidTransaction(full_error.to_string())
280        } else if error_str.contains("-32014") {
281            // Block status not yet available - transient, retry later
282            SolanaProviderError::RpcError(full_error.to_string())
283        } else if error_str.contains("-32015") {
284            // Transaction version not supported - permanent
285            SolanaProviderError::InvalidTransaction(full_error.to_string())
286        } else if error_str.contains("-32016") {
287            // Minimum context slot not reached - transient, retry later
288            SolanaProviderError::RpcError(full_error.to_string())
289        } else if error_str.contains("-32602") {
290            // Invalid params - permanent
291            SolanaProviderError::InvalidTransaction(full_error.to_string())
292        } else {
293            // For other codes, fall back to string matching
294            if matches_error_pattern(error_str, "insufficient funds") {
295                SolanaProviderError::InsufficientFunds(full_error.to_string())
296            } else if matches_error_pattern(error_str, "blockhash not found") {
297                SolanaProviderError::BlockhashNotFound(full_error.to_string())
298            } else if matches_error_pattern(error_str, "already processed") {
299                SolanaProviderError::AlreadyProcessed(full_error.to_string())
300            } else {
301                // Default to transient RPC error for unknown codes
302                SolanaProviderError::RpcError(full_error.to_string())
303            }
304        }
305    }
306
307    /// Classifies a Solana TransactionError into the appropriate error variant.
308    fn from_transaction_error(
309        tx_error: &solana_sdk::transaction::TransactionError,
310        full_error: &ClientError,
311    ) -> Self {
312        use solana_sdk::transaction::TransactionError as TxErr;
313
314        match tx_error {
315            // Insufficient funds - permanent
316            TxErr::InsufficientFundsForFee | TxErr::InsufficientFundsForRent { .. } => {
317                SolanaProviderError::InsufficientFunds(full_error.to_string())
318            }
319
320            // Blockhash not found - transient (can rebuild transaction with fresh blockhash)
321            TxErr::BlockhashNotFound => {
322                SolanaProviderError::BlockhashNotFound(full_error.to_string())
323            }
324
325            // Already processed - permanent
326            TxErr::AlreadyProcessed => {
327                SolanaProviderError::AlreadyProcessed(full_error.to_string())
328            }
329
330            // Invalid transaction structure/signatures - permanent
331            TxErr::SignatureFailure
332            | TxErr::MissingSignatureForFee
333            | TxErr::InvalidAccountForFee
334            | TxErr::AccountNotFound
335            | TxErr::InvalidAccountIndex
336            | TxErr::InvalidProgramForExecution
337            | TxErr::ProgramAccountNotFound
338            | TxErr::InstructionError(_, _)
339            | TxErr::CallChainTooDeep
340            | TxErr::InvalidWritableAccount
341            | TxErr::InvalidRentPayingAccount
342            | TxErr::WouldExceedMaxBlockCostLimit
343            | TxErr::WouldExceedMaxAccountCostLimit
344            | TxErr::WouldExceedMaxVoteCostLimit
345            | TxErr::WouldExceedAccountDataBlockLimit
346            | TxErr::TooManyAccountLocks
347            | TxErr::AddressLookupTableNotFound
348            | TxErr::InvalidAddressLookupTableOwner
349            | TxErr::InvalidAddressLookupTableData
350            | TxErr::InvalidAddressLookupTableIndex
351            | TxErr::MaxLoadedAccountsDataSizeExceeded
352            | TxErr::InvalidLoadedAccountsDataSizeLimit
353            | TxErr::ResanitizationNeeded
354            | TxErr::ProgramExecutionTemporarilyRestricted { .. }
355            | TxErr::AccountBorrowOutstanding => {
356                SolanaProviderError::InvalidTransaction(full_error.to_string())
357            }
358
359            // Transient errors that might succeed on retry
360            TxErr::AccountInUse | TxErr::AccountLoadedTwice | TxErr::ClusterMaintenance => {
361                SolanaProviderError::RpcError(full_error.to_string())
362            }
363
364            // Treat unknown errors as generic RPC errors (transient by default)
365            _ => SolanaProviderError::RpcError(full_error.to_string()),
366        }
367    }
368}
369
370/// A trait that abstracts common Solana provider operations.
371#[async_trait]
372#[cfg_attr(test, automock)]
373#[allow(dead_code)]
374pub trait SolanaProviderTrait: Send + Sync {
375    fn get_configs(&self) -> Vec<RpcConfig>;
376    /// Retrieves the balance (in lamports) for the given address.
377    async fn get_balance(&self, address: &str) -> Result<u64, SolanaProviderError>;
378
379    /// Retrieves the latest blockhash as a 32-byte array.
380    async fn get_latest_blockhash(&self) -> Result<Hash, SolanaProviderError>;
381
382    // Retrieves the latest blockhash with the specified commitment.
383    async fn get_latest_blockhash_with_commitment(
384        &self,
385        commitment: CommitmentConfig,
386    ) -> Result<(Hash, u64), SolanaProviderError>;
387
388    /// Sends a transaction to the Solana network.
389    async fn send_transaction(
390        &self,
391        transaction: &Transaction,
392    ) -> Result<Signature, SolanaProviderError>;
393
394    /// Sends a transaction to the Solana network.
395    async fn send_versioned_transaction(
396        &self,
397        transaction: &VersionedTransaction,
398    ) -> Result<Signature, SolanaProviderError>;
399
400    /// Confirms a transaction given its signature.
401    async fn confirm_transaction(&self, signature: &Signature)
402        -> Result<bool, SolanaProviderError>;
403
404    /// Retrieves the minimum balance required for rent exemption for the specified data size.
405    async fn get_minimum_balance_for_rent_exemption(
406        &self,
407        data_size: usize,
408    ) -> Result<u64, SolanaProviderError>;
409
410    /// Simulates a transaction and returns the simulation result.
411    async fn simulate_transaction(
412        &self,
413        transaction: &Transaction,
414    ) -> Result<RpcSimulateTransactionResult, SolanaProviderError>;
415
416    /// Retrieve an account given its string representation.
417    async fn get_account_from_str(&self, account: &str) -> Result<Account, SolanaProviderError>;
418
419    /// Retrieve an account given its Pubkey.
420    async fn get_account_from_pubkey(
421        &self,
422        pubkey: &Pubkey,
423    ) -> Result<Account, SolanaProviderError>;
424
425    /// Retrieve token metadata from the provided pubkey.
426    async fn get_token_metadata_from_pubkey(
427        &self,
428        pubkey: &str,
429    ) -> Result<TokenMetadata, SolanaProviderError>;
430
431    /// Check if a blockhash is valid.
432    async fn is_blockhash_valid(
433        &self,
434        hash: &Hash,
435        commitment: CommitmentConfig,
436    ) -> Result<bool, SolanaProviderError>;
437
438    /// get fee for message
439    async fn get_fee_for_message(&self, message: &Message) -> Result<u64, SolanaProviderError>;
440
441    /// get recent prioritization fees
442    async fn get_recent_prioritization_fees(
443        &self,
444        addresses: &[Pubkey],
445    ) -> Result<Vec<RpcPrioritizationFee>, SolanaProviderError>;
446
447    /// calculate total fee
448    async fn calculate_total_fee(&self, message: &Message) -> Result<u64, SolanaProviderError>;
449
450    /// get transaction status
451    async fn get_transaction_status(
452        &self,
453        signature: &Signature,
454    ) -> Result<SolanaTransactionStatus, SolanaProviderError>;
455
456    /// Send a raw JSON-RPC request to the Solana node
457    async fn raw_request_dyn(
458        &self,
459        method: &str,
460        params: serde_json::Value,
461    ) -> Result<serde_json::Value, SolanaProviderError>;
462}
463
464#[derive(Debug)]
465pub struct SolanaProvider {
466    // RPC selector for handling multiple client connections
467    selector: RpcSelector,
468    // Default timeout in seconds
469    timeout_seconds: Duration,
470    // Default commitment level
471    commitment: CommitmentConfig,
472    // Retry configuration for network requests
473    retry_config: RetryConfig,
474}
475
476impl From<String> for SolanaProviderError {
477    fn from(s: String) -> Self {
478        SolanaProviderError::RpcError(s)
479    }
480}
481
482/// Determines if a Solana provider error should mark the provider as failed.
483///
484/// This function identifies errors that indicate the RPC provider itself is having issues
485/// and should be marked as failed to trigger failover to another provider.
486///
487/// Uses the shared `should_mark_provider_failed_by_status_code` function for HTTP status code logic.
488fn should_mark_solana_provider_failed(error: &SolanaProviderError) -> bool {
489    match error {
490        SolanaProviderError::RequestError { status_code, .. } => {
491            should_mark_provider_failed_by_status_code(*status_code)
492        }
493        _ => false,
494    }
495}
496
497#[derive(Error, Debug, PartialEq)]
498pub struct TokenMetadata {
499    pub decimals: u8,
500    pub symbol: String,
501    pub mint: String,
502}
503
504impl std::fmt::Display for TokenMetadata {
505    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
506        write!(
507            f,
508            "TokenMetadata {{ decimals: {}, symbol: {}, mint: {} }}",
509            self.decimals, self.symbol, self.mint
510        )
511    }
512}
513
514#[allow(dead_code)]
515impl SolanaProvider {
516    pub fn new(config: ProviderConfig) -> Result<Self, ProviderError> {
517        Self::new_with_commitment_and_health(
518            config.rpc_configs,
519            config.timeout_seconds,
520            CommitmentConfig::confirmed(),
521            config.failure_threshold,
522            config.pause_duration_secs,
523            config.failure_expiration_secs,
524        )
525    }
526
527    /// Creates a new SolanaProvider with RPC configurations and optional settings.
528    ///
529    /// # Arguments
530    ///
531    /// * `configs` - A vector of RPC configurations
532    /// * `timeout` - Optional custom timeout
533    /// * `commitment` - Optional custom commitment level
534    /// * `failure_threshold` - Number of consecutive failures before pausing a provider
535    /// * `pause_duration_secs` - Duration in seconds to pause a provider after reaching failure threshold
536    /// * `failure_expiration_secs` - Duration in seconds after which failures are considered stale
537    ///
538    /// # Returns
539    ///
540    /// A Result containing the provider or an error
541    pub fn new_with_commitment_and_health(
542        configs: Vec<RpcConfig>,
543        timeout_seconds: u64,
544        commitment: CommitmentConfig,
545        failure_threshold: u32,
546        pause_duration_secs: u64,
547        failure_expiration_secs: u64,
548    ) -> Result<Self, ProviderError> {
549        if configs.is_empty() {
550            return Err(ProviderError::NetworkConfiguration(
551                "At least one RPC configuration must be provided".to_string(),
552            ));
553        }
554
555        RpcConfig::validate_list(&configs)
556            .map_err(|e| ProviderError::NetworkConfiguration(format!("Invalid URL: {e}")))?;
557
558        // Now create the selector with validated configs
559        let selector = RpcSelector::new(
560            configs,
561            failure_threshold,
562            pause_duration_secs,
563            failure_expiration_secs,
564        )
565        .map_err(|e| {
566            ProviderError::NetworkConfiguration(format!("Failed to create RPC selector: {e}"))
567        })?;
568
569        let retry_config = RetryConfig::from_env();
570
571        Ok(Self {
572            selector,
573            timeout_seconds: Duration::from_secs(timeout_seconds),
574            commitment,
575            retry_config,
576        })
577    }
578
579    /// Gets the current RPC configurations.
580    ///
581    /// # Returns
582    /// * `Vec<RpcConfig>` - The current configurations
583    pub fn get_configs(&self) -> Vec<RpcConfig> {
584        self.selector.get_configs()
585    }
586
587    /// Retrieves an RPC client instance using the configured selector.
588    ///
589    /// # Returns
590    ///
591    /// A Result containing either:
592    /// - A configured RPC client connected to a selected endpoint
593    /// - A SolanaProviderError describing what went wrong
594    ///
595    fn get_client(&self) -> Result<RpcClient, SolanaProviderError> {
596        self.selector
597            .get_client(
598                |url| {
599                    Ok(RpcClient::new_with_timeout_and_commitment(
600                        url.to_string(),
601                        self.timeout_seconds,
602                        self.commitment,
603                    ))
604                },
605                &std::collections::HashSet::new(),
606            )
607            .map_err(SolanaProviderError::SelectorError)
608    }
609
610    /// Initialize a provider for a given URL
611    ///
612    /// # SSRF Mitigation Note
613    /// Unlike EVM and Stellar providers, HTTP redirect policy cannot be disabled here.
614    /// The Solana SDK's `RpcClient::new_with_timeout_and_commitment` doesn't expose
615    /// HTTP client configuration. To disable redirects, we would need to use
616    /// `solana-rpc-client::HttpSender::new_with_client()` with a custom reqwest::Client,
617    /// which requires adding `solana-rpc-client` as a direct dependency.
618    /// The URL security validation provides the primary SSRF defense for Solana.
619    fn initialize_provider(&self, url: &str) -> Result<Arc<RpcClient>, SolanaProviderError> {
620        let allowed_hosts = crate::config::ServerConfig::get_rpc_allowed_hosts();
621        let block_private_ips = crate::config::ServerConfig::get_rpc_block_private_ips();
622        validate_safe_url(url, &allowed_hosts, block_private_ips).map_err(|e| {
623            SolanaProviderError::NetworkConfiguration(format!(
624                "RPC URL security validation failed: {e}"
625            ))
626        })?;
627
628        let timeout = self.timeout_seconds;
629        let commitment = self.commitment;
630        let cache_key = SolanaRpcClientKey {
631            url: url.to_string(),
632            timeout_ms: timeout.as_millis(),
633            commitment,
634        };
635        SOLANA_RPC_CLIENT_CACHE.get_or_try_init(cache_key, || {
636            let rpc_url: Url = url.parse().map_err(|e| {
637                SolanaProviderError::NetworkConfiguration(format!("Invalid URL format: {e}"))
638            })?;
639            Ok(RpcClient::new_with_timeout_and_commitment(
640                rpc_url.to_string(),
641                timeout,
642                commitment,
643            ))
644        })
645    }
646
647    /// Retry helper for Solana RPC calls
648    async fn retry_rpc_call<T, F, Fut>(
649        &self,
650        operation_name: &str,
651        operation: F,
652    ) -> Result<T, SolanaProviderError>
653    where
654        F: Fn(Arc<RpcClient>) -> Fut,
655        Fut: std::future::Future<Output = Result<T, SolanaProviderError>>,
656    {
657        let is_retriable = |e: &SolanaProviderError| e.is_transient();
658
659        tracing::debug!(
660            "Starting RPC operation '{}' with timeout: {}s",
661            operation_name,
662            self.timeout_seconds.as_secs()
663        );
664
665        retry_rpc_call(
666            &self.selector,
667            operation_name,
668            is_retriable,
669            should_mark_solana_provider_failed,
670            |url| match self.initialize_provider(url) {
671                Ok(provider) => Ok(provider),
672                Err(e) => Err(e),
673            },
674            operation,
675            Some(self.retry_config.clone()),
676        )
677        .await
678    }
679}
680
681#[async_trait]
682#[allow(dead_code)]
683impl SolanaProviderTrait for SolanaProvider {
684    fn get_configs(&self) -> Vec<RpcConfig> {
685        self.get_configs()
686    }
687
688    /// Retrieves the balance (in lamports) for the given address.
689    /// # Errors
690    ///
691    /// Returns `ProviderError::InvalidAddress` if address parsing fails,
692    /// and `ProviderError::RpcError` if the RPC call fails.
693    async fn get_balance(&self, address: &str) -> Result<u64, SolanaProviderError> {
694        let pubkey = Pubkey::from_str(address)
695            .map_err(|e| SolanaProviderError::InvalidAddress(e.to_string()))?;
696
697        self.retry_rpc_call("get_balance", |client| async move {
698            client
699                .get_balance(&pubkey)
700                .await
701                .map_err(SolanaProviderError::from_rpc_error)
702        })
703        .await
704    }
705
706    /// Check if a blockhash is valid
707    async fn is_blockhash_valid(
708        &self,
709        hash: &Hash,
710        commitment: CommitmentConfig,
711    ) -> Result<bool, SolanaProviderError> {
712        self.retry_rpc_call("is_blockhash_valid", |client| async move {
713            client
714                .is_blockhash_valid(hash, commitment)
715                .await
716                .map_err(SolanaProviderError::from_rpc_error)
717        })
718        .await
719    }
720
721    /// Gets the latest blockhash.
722    async fn get_latest_blockhash(&self) -> Result<Hash, SolanaProviderError> {
723        self.retry_rpc_call("get_latest_blockhash", |client| async move {
724            client
725                .get_latest_blockhash()
726                .await
727                .map_err(SolanaProviderError::from_rpc_error)
728        })
729        .await
730    }
731
732    async fn get_latest_blockhash_with_commitment(
733        &self,
734        commitment: CommitmentConfig,
735    ) -> Result<(Hash, u64), SolanaProviderError> {
736        self.retry_rpc_call(
737            "get_latest_blockhash_with_commitment",
738            |client| async move {
739                client
740                    .get_latest_blockhash_with_commitment(commitment)
741                    .await
742                    .map_err(SolanaProviderError::from_rpc_error)
743            },
744        )
745        .await
746    }
747
748    /// Sends a transaction to the network.
749    async fn send_transaction(
750        &self,
751        transaction: &Transaction,
752    ) -> Result<Signature, SolanaProviderError> {
753        self.retry_rpc_call("send_transaction", |client| async move {
754            client
755                .send_transaction(transaction)
756                .await
757                .map_err(SolanaProviderError::from_rpc_error)
758        })
759        .await
760    }
761
762    /// Sends a transaction to the network.
763    async fn send_versioned_transaction(
764        &self,
765        transaction: &VersionedTransaction,
766    ) -> Result<Signature, SolanaProviderError> {
767        self.retry_rpc_call("send_transaction", |client| async move {
768            client
769                .send_transaction(transaction)
770                .await
771                .map_err(SolanaProviderError::from_rpc_error)
772        })
773        .await
774    }
775
776    /// Confirms the given transaction signature.
777    async fn confirm_transaction(
778        &self,
779        signature: &Signature,
780    ) -> Result<bool, SolanaProviderError> {
781        self.retry_rpc_call("confirm_transaction", |client| async move {
782            client
783                .confirm_transaction(signature)
784                .await
785                .map_err(SolanaProviderError::from_rpc_error)
786        })
787        .await
788    }
789
790    /// Retrieves the minimum balance for rent exemption for the given data size.
791    async fn get_minimum_balance_for_rent_exemption(
792        &self,
793        data_size: usize,
794    ) -> Result<u64, SolanaProviderError> {
795        self.retry_rpc_call(
796            "get_minimum_balance_for_rent_exemption",
797            |client| async move {
798                client
799                    .get_minimum_balance_for_rent_exemption(data_size)
800                    .await
801                    .map_err(SolanaProviderError::from_rpc_error)
802            },
803        )
804        .await
805    }
806
807    /// Simulate transaction.
808    async fn simulate_transaction(
809        &self,
810        transaction: &Transaction,
811    ) -> Result<RpcSimulateTransactionResult, SolanaProviderError> {
812        self.retry_rpc_call("simulate_transaction", |client| async move {
813            client
814                .simulate_transaction(transaction)
815                .await
816                .map_err(SolanaProviderError::from_rpc_error)
817                .map(|response| response.value)
818        })
819        .await
820    }
821
822    /// Retrieves account data for the given account string.
823    async fn get_account_from_str(&self, account: &str) -> Result<Account, SolanaProviderError> {
824        let address = Pubkey::from_str(account).map_err(|e| {
825            SolanaProviderError::InvalidAddress(format!("Invalid pubkey {account}: {e}"))
826        })?;
827        self.retry_rpc_call("get_account", |client| async move {
828            client
829                .get_account(&address)
830                .await
831                .map_err(SolanaProviderError::from_rpc_error)
832        })
833        .await
834    }
835
836    /// Retrieves account data for the given pubkey.
837    async fn get_account_from_pubkey(
838        &self,
839        pubkey: &Pubkey,
840    ) -> Result<Account, SolanaProviderError> {
841        self.retry_rpc_call("get_account_from_pubkey", |client| async move {
842            client
843                .get_account(pubkey)
844                .await
845                .map_err(SolanaProviderError::from_rpc_error)
846        })
847        .await
848    }
849
850    /// Retrieves token metadata from a provided mint address.
851    async fn get_token_metadata_from_pubkey(
852        &self,
853        pubkey: &str,
854    ) -> Result<TokenMetadata, SolanaProviderError> {
855        // Parse and validate pubkey once
856        let mint_pubkey = Pubkey::from_str(pubkey).map_err(|e| {
857            SolanaProviderError::InvalidAddress(format!("Invalid pubkey {pubkey}: {e}"))
858        })?;
859
860        // Retrieve account using already-parsed pubkey (avoids re-parsing)
861        let account = self.get_account_from_pubkey(&mint_pubkey).await?;
862
863        // Unpack the mint info from the account's data
864        let decimals = Mint::unpack(&account.data)
865            .map_err(|e| {
866                SolanaProviderError::InvalidTransaction(format!(
867                    "Failed to unpack mint info for {pubkey}: {e}"
868                ))
869            })?
870            .decimals;
871
872        // Derive the PDA for the token metadata
873        // Convert bytes directly between Pubkey types (no string conversion needed)
874        let mint_pubkey_program =
875            solana_program::pubkey::Pubkey::new_from_array(mint_pubkey.to_bytes());
876        let metadata_pda_program = Metadata::find_pda(&mint_pubkey_program).0;
877
878        // Convert bytes directly (no string conversion)
879        let metadata_pda = Pubkey::new_from_array(metadata_pda_program.to_bytes());
880
881        let symbol = match self.get_account_from_pubkey(&metadata_pda).await {
882            Ok(metadata_account) => match Metadata::from_bytes(&metadata_account.data) {
883                Ok(metadata) => metadata.symbol.trim_end_matches('\u{0}').to_string(),
884                Err(_) => String::new(),
885            },
886            Err(_) => String::new(), // Return empty symbol if metadata doesn't exist
887        };
888
889        Ok(TokenMetadata {
890            decimals,
891            symbol,
892            mint: pubkey.to_string(),
893        })
894    }
895
896    /// Get the fee for a message
897    async fn get_fee_for_message(&self, message: &Message) -> Result<u64, SolanaProviderError> {
898        self.retry_rpc_call("get_fee_for_message", |client| async move {
899            client
900                .get_fee_for_message(message)
901                .await
902                .map_err(SolanaProviderError::from_rpc_error)
903        })
904        .await
905    }
906
907    async fn get_recent_prioritization_fees(
908        &self,
909        addresses: &[Pubkey],
910    ) -> Result<Vec<RpcPrioritizationFee>, SolanaProviderError> {
911        self.retry_rpc_call("get_recent_prioritization_fees", |client| async move {
912            client
913                .get_recent_prioritization_fees(addresses)
914                .await
915                .map_err(SolanaProviderError::from_rpc_error)
916        })
917        .await
918    }
919
920    async fn calculate_total_fee(&self, message: &Message) -> Result<u64, SolanaProviderError> {
921        let base_fee = self.get_fee_for_message(message).await?;
922        let priority_fees = self.get_recent_prioritization_fees(&[]).await?;
923
924        let max_priority_fee = priority_fees
925            .iter()
926            .map(|fee| fee.prioritization_fee)
927            .max()
928            .unwrap_or(0);
929
930        Ok(base_fee + max_priority_fee)
931    }
932
933    async fn get_transaction_status(
934        &self,
935        signature: &Signature,
936    ) -> Result<SolanaTransactionStatus, SolanaProviderError> {
937        let result = self
938            .retry_rpc_call("get_transaction_status", |client| async move {
939                client
940                    .get_signature_statuses_with_history(&[*signature])
941                    .await
942                    .map_err(SolanaProviderError::from_rpc_error)
943            })
944            .await?;
945
946        let status = result.value.first();
947
948        match status {
949            Some(Some(v)) => {
950                if v.err.is_some() {
951                    Ok(SolanaTransactionStatus::Failed)
952                } else if v.satisfies_commitment(CommitmentConfig::finalized()) {
953                    Ok(SolanaTransactionStatus::Finalized)
954                } else if v.satisfies_commitment(CommitmentConfig::confirmed()) {
955                    Ok(SolanaTransactionStatus::Confirmed)
956                } else {
957                    Ok(SolanaTransactionStatus::Processed)
958                }
959            }
960            Some(None) => Err(SolanaProviderError::RpcError(
961                "Transaction confirmation status not available".to_string(),
962            )),
963            None => Err(SolanaProviderError::RpcError(
964                "Transaction confirmation status not available".to_string(),
965            )),
966        }
967    }
968
969    /// Send a raw JSON-RPC request to the Solana node
970    async fn raw_request_dyn(
971        &self,
972        method: &str,
973        params: serde_json::Value,
974    ) -> Result<serde_json::Value, SolanaProviderError> {
975        let params_owned = params.clone();
976        let method_static: &'static str = Box::leak(method.to_string().into_boxed_str());
977        self.retry_rpc_call("raw_request_dyn", move |client| {
978            let params_for_call = params_owned.clone();
979            async move {
980                client
981                    .send(
982                        RpcRequest::Custom {
983                            method: method_static,
984                        },
985                        params_for_call,
986                    )
987                    .await
988                    .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
989            }
990        })
991        .await
992    }
993}
994
995#[cfg(test)]
996mod tests {
997    use super::*;
998    use lazy_static::lazy_static;
999    use solana_sdk::{
1000        hash::Hash,
1001        message::Message,
1002        signer::{keypair::Keypair, Signer},
1003        transaction::Transaction,
1004    };
1005    use std::sync::Mutex;
1006
1007    lazy_static! {
1008        static ref EVM_TEST_ENV_MUTEX: Mutex<()> = Mutex::new(());
1009    }
1010
1011    struct EvmTestEnvGuard {
1012        _mutex_guard: std::sync::MutexGuard<'static, ()>,
1013    }
1014
1015    impl EvmTestEnvGuard {
1016        fn new(mutex_guard: std::sync::MutexGuard<'static, ()>) -> Self {
1017            std::env::set_var(
1018                "API_KEY",
1019                "test_api_key_for_evm_provider_new_this_is_long_enough_32_chars",
1020            );
1021            std::env::set_var("REDIS_URL", "redis://test-dummy-url-for-evm-provider");
1022
1023            Self {
1024                _mutex_guard: mutex_guard,
1025            }
1026        }
1027    }
1028
1029    impl Drop for EvmTestEnvGuard {
1030        fn drop(&mut self) {
1031            std::env::remove_var("API_KEY");
1032            std::env::remove_var("REDIS_URL");
1033        }
1034    }
1035
1036    // Helper function to set up the test environment
1037    fn setup_test_env() -> EvmTestEnvGuard {
1038        let guard = EVM_TEST_ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
1039        EvmTestEnvGuard::new(guard)
1040    }
1041
1042    fn get_funded_keypair() -> Keypair {
1043        // address HCKHoE2jyk1qfAwpHQghvYH3cEfT8euCygBzF9AV6bhY
1044        Keypair::try_from(
1045            [
1046                120, 248, 160, 20, 225, 60, 226, 195, 68, 137, 176, 87, 21, 129, 0, 76, 144, 129,
1047                122, 250, 80, 4, 247, 50, 248, 82, 146, 77, 139, 156, 40, 41, 240, 161, 15, 81,
1048                198, 198, 86, 167, 90, 148, 131, 13, 184, 222, 251, 71, 229, 212, 169, 2, 72, 202,
1049                150, 184, 176, 148, 75, 160, 255, 233, 73, 31,
1050            ]
1051            .as_slice(),
1052        )
1053        .unwrap()
1054    }
1055
1056    // Helper function to obtain a recent blockhash from the provider.
1057    async fn get_recent_blockhash(provider: &SolanaProvider) -> Hash {
1058        provider
1059            .get_latest_blockhash()
1060            .await
1061            .expect("Failed to get blockhash")
1062    }
1063
1064    fn create_test_rpc_config() -> RpcConfig {
1065        RpcConfig {
1066            url: "https://api.devnet.solana.com".to_string(),
1067            weight: 1,
1068            ..Default::default()
1069        }
1070    }
1071
1072    fn create_test_provider_config(configs: Vec<RpcConfig>, timeout: u64) -> ProviderConfig {
1073        ProviderConfig::new(configs, timeout, 3, 60, 60)
1074    }
1075
1076    #[tokio::test]
1077    async fn test_new_with_valid_config() {
1078        let _env_guard = setup_test_env();
1079        let configs = vec![create_test_rpc_config()];
1080        let timeout = 30;
1081
1082        let result = SolanaProvider::new(create_test_provider_config(configs, timeout));
1083
1084        assert!(result.is_ok());
1085        let provider = result.unwrap();
1086        assert_eq!(provider.timeout_seconds, Duration::from_secs(timeout));
1087        assert_eq!(provider.commitment, CommitmentConfig::confirmed());
1088    }
1089
1090    #[tokio::test]
1091    async fn test_new_with_commitment_valid_config() {
1092        let _env_guard = setup_test_env();
1093
1094        let configs = vec![create_test_rpc_config()];
1095        let timeout = 30;
1096        let commitment = CommitmentConfig::finalized();
1097
1098        let result =
1099            SolanaProvider::new_with_commitment_and_health(configs, timeout, commitment, 3, 60, 60);
1100
1101        assert!(result.is_ok());
1102        let provider = result.unwrap();
1103        assert_eq!(provider.timeout_seconds, Duration::from_secs(timeout));
1104        assert_eq!(provider.commitment, commitment);
1105    }
1106
1107    #[tokio::test]
1108    async fn test_new_with_empty_configs() {
1109        let _env_guard = setup_test_env();
1110        let configs: Vec<RpcConfig> = vec![];
1111        let timeout = 30;
1112
1113        let result = SolanaProvider::new(create_test_provider_config(configs, timeout));
1114
1115        assert!(result.is_err());
1116        assert!(matches!(
1117            result,
1118            Err(ProviderError::NetworkConfiguration(_))
1119        ));
1120    }
1121
1122    #[tokio::test]
1123    async fn test_new_with_commitment_empty_configs() {
1124        let _env_guard = setup_test_env();
1125        let configs: Vec<RpcConfig> = vec![];
1126        let timeout = 30;
1127        let commitment = CommitmentConfig::finalized();
1128
1129        let result =
1130            SolanaProvider::new_with_commitment_and_health(configs, timeout, commitment, 3, 60, 60);
1131
1132        assert!(result.is_err());
1133        assert!(matches!(
1134            result,
1135            Err(ProviderError::NetworkConfiguration(_))
1136        ));
1137    }
1138
1139    #[tokio::test]
1140    async fn test_new_with_invalid_url() {
1141        let _env_guard = setup_test_env();
1142        let configs = vec![RpcConfig {
1143            url: "invalid-url".to_string(),
1144            weight: 1,
1145            ..Default::default()
1146        }];
1147        let timeout = 30;
1148
1149        let result = SolanaProvider::new(create_test_provider_config(configs, timeout));
1150
1151        assert!(result.is_err());
1152        assert!(matches!(
1153            result,
1154            Err(ProviderError::NetworkConfiguration(_))
1155        ));
1156    }
1157
1158    #[tokio::test]
1159    async fn test_new_with_commitment_invalid_url() {
1160        let _env_guard = setup_test_env();
1161        let configs = vec![RpcConfig {
1162            url: "invalid-url".to_string(),
1163            weight: 1,
1164            ..Default::default()
1165        }];
1166        let timeout = 30;
1167        let commitment = CommitmentConfig::finalized();
1168
1169        let result =
1170            SolanaProvider::new_with_commitment_and_health(configs, timeout, commitment, 3, 60, 60);
1171
1172        assert!(result.is_err());
1173        assert!(matches!(
1174            result,
1175            Err(ProviderError::NetworkConfiguration(_))
1176        ));
1177    }
1178
1179    #[tokio::test]
1180    async fn test_new_with_multiple_configs() {
1181        let _env_guard = setup_test_env();
1182        let configs = vec![
1183            create_test_rpc_config(),
1184            RpcConfig {
1185                url: "https://api.mainnet-beta.solana.com".to_string(),
1186                weight: 1,
1187                ..Default::default()
1188            },
1189        ];
1190        let timeout = 30;
1191
1192        let result = SolanaProvider::new(create_test_provider_config(configs, timeout));
1193
1194        assert!(result.is_ok());
1195    }
1196
1197    #[tokio::test]
1198    async fn test_provider_creation() {
1199        let _env_guard = setup_test_env();
1200        let configs = vec![create_test_rpc_config()];
1201        let timeout = 30;
1202        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout));
1203        assert!(provider.is_ok());
1204    }
1205
1206    #[tokio::test]
1207    async fn test_get_balance() {
1208        let _env_guard = setup_test_env();
1209        let configs = vec![create_test_rpc_config()];
1210        let timeout = 30;
1211        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1212        let keypair = Keypair::new();
1213        let balance = provider.get_balance(&keypair.pubkey().to_string()).await;
1214        assert!(balance.is_ok());
1215        assert_eq!(balance.unwrap(), 0);
1216    }
1217
1218    #[tokio::test]
1219    async fn test_get_balance_funded_account() {
1220        let _env_guard = setup_test_env();
1221        let configs = vec![create_test_rpc_config()];
1222        let timeout = 30;
1223        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1224        let keypair = get_funded_keypair();
1225        let balance = provider.get_balance(&keypair.pubkey().to_string()).await;
1226        assert!(balance.is_ok());
1227        assert_eq!(balance.unwrap(), 1000000000);
1228    }
1229
1230    #[tokio::test]
1231    async fn test_get_latest_blockhash() {
1232        let _env_guard = setup_test_env();
1233        let configs = vec![create_test_rpc_config()];
1234        let timeout = 30;
1235        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1236        let blockhash = provider.get_latest_blockhash().await;
1237        assert!(blockhash.is_ok());
1238    }
1239
1240    #[tokio::test]
1241    async fn test_simulate_transaction() {
1242        let _env_guard = setup_test_env();
1243        let configs = vec![create_test_rpc_config()];
1244        let timeout = 30;
1245        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout))
1246            .expect("Failed to create provider");
1247
1248        let fee_payer = get_funded_keypair();
1249
1250        // Construct a message with no instructions (a no-op transaction).
1251        // Note: An empty instruction set is acceptable for simulation purposes.
1252        let message = Message::new(&[], Some(&fee_payer.pubkey()));
1253
1254        let mut tx = Transaction::new_unsigned(message);
1255
1256        let recent_blockhash = get_recent_blockhash(&provider).await;
1257        tx.try_sign(&[&fee_payer], recent_blockhash)
1258            .expect("Failed to sign transaction");
1259
1260        let simulation_result = provider.simulate_transaction(&tx).await;
1261
1262        assert!(
1263            simulation_result.is_ok(),
1264            "Simulation failed: {simulation_result:?}"
1265        );
1266
1267        let result = simulation_result.unwrap();
1268        // The simulation result may contain logs or an error field.
1269        // For a no-op transaction, we expect no errors and possibly empty logs.
1270        assert!(
1271            result.err.is_none(),
1272            "Simulation encountered an error: {:?}",
1273            result.err
1274        );
1275    }
1276
1277    #[tokio::test]
1278    async fn test_get_token_metadata_from_pubkey() {
1279        let _env_guard = setup_test_env();
1280        let configs = vec![RpcConfig {
1281            url: "https://api.mainnet-beta.solana.com".to_string(),
1282            weight: 1,
1283            ..Default::default()
1284        }];
1285        let timeout = 30;
1286        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1287        let usdc_token_metadata = provider
1288            .get_token_metadata_from_pubkey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1289            .await
1290            .unwrap();
1291
1292        assert_eq!(
1293            usdc_token_metadata,
1294            TokenMetadata {
1295                decimals: 6,
1296                symbol: "USDC".to_string(),
1297                mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
1298            }
1299        );
1300
1301        let usdt_token_metadata = provider
1302            .get_token_metadata_from_pubkey("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB")
1303            .await
1304            .unwrap();
1305
1306        assert_eq!(
1307            usdt_token_metadata,
1308            TokenMetadata {
1309                decimals: 6,
1310                symbol: "USDT".to_string(),
1311                mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(),
1312            }
1313        );
1314    }
1315
1316    #[tokio::test]
1317    async fn test_get_client_success() {
1318        let _env_guard = setup_test_env();
1319        let configs = vec![create_test_rpc_config()];
1320        let timeout = 30;
1321        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1322
1323        let client = provider.get_client();
1324        assert!(client.is_ok());
1325
1326        let client = client.unwrap();
1327        let health_result = client.get_health().await;
1328        assert!(health_result.is_ok());
1329    }
1330
1331    #[tokio::test]
1332    async fn test_get_client_with_custom_commitment() {
1333        let _env_guard = setup_test_env();
1334        let configs = vec![create_test_rpc_config()];
1335        let timeout = 30;
1336        let commitment = CommitmentConfig::finalized();
1337
1338        let provider =
1339            SolanaProvider::new_with_commitment_and_health(configs, timeout, commitment, 3, 60, 60)
1340                .unwrap();
1341
1342        let client = provider.get_client();
1343        assert!(client.is_ok());
1344
1345        let client = client.unwrap();
1346        let health_result = client.get_health().await;
1347        assert!(health_result.is_ok());
1348    }
1349
1350    #[tokio::test]
1351    async fn test_get_client_with_multiple_rpcs() {
1352        let _env_guard = setup_test_env();
1353        let configs = vec![
1354            create_test_rpc_config(),
1355            RpcConfig {
1356                url: "https://api.mainnet-beta.solana.com".to_string(),
1357                weight: 2,
1358                ..Default::default()
1359            },
1360        ];
1361        let timeout = 30;
1362
1363        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1364
1365        let client_result = provider.get_client();
1366        assert!(client_result.is_ok());
1367
1368        // Call multiple times to exercise the selection logic
1369        for _ in 0..5 {
1370            let client = provider.get_client();
1371            assert!(client.is_ok());
1372        }
1373    }
1374
1375    #[test]
1376    fn test_initialize_provider_valid_url() {
1377        let _env_guard = setup_test_env();
1378
1379        let configs = vec![RpcConfig {
1380            url: "https://api.devnet.solana.com".to_string(),
1381            weight: 1,
1382            ..Default::default()
1383        }];
1384        let provider = SolanaProvider::new(create_test_provider_config(configs, 10)).unwrap();
1385        let result = provider.initialize_provider("https://api.devnet.solana.com");
1386        assert!(result.is_ok());
1387        let arc_client = result.unwrap();
1388        // Arc pointer should not be null and should point to RpcClient
1389        let _client: &RpcClient = Arc::as_ref(&arc_client);
1390    }
1391
1392    #[test]
1393    fn test_initialize_provider_invalid_url() {
1394        let _env_guard = setup_test_env();
1395
1396        let configs = vec![RpcConfig {
1397            url: "https://api.devnet.solana.com".to_string(),
1398            weight: 1,
1399            ..Default::default()
1400        }];
1401        let provider = SolanaProvider::new(create_test_provider_config(configs, 10)).unwrap();
1402        let result = provider.initialize_provider("not-a-valid-url");
1403        assert!(result.is_err());
1404        match result {
1405            Err(SolanaProviderError::NetworkConfiguration(msg)) => {
1406                assert!(msg.contains("Invalid URL format"))
1407            }
1408            _ => panic!("Expected NetworkConfiguration error"),
1409        }
1410    }
1411
1412    #[test]
1413    fn test_from_string_for_solana_provider_error() {
1414        let msg = "some rpc error".to_string();
1415        let err: SolanaProviderError = msg.clone().into();
1416        match err {
1417            SolanaProviderError::RpcError(inner) => assert_eq!(inner, msg),
1418            _ => panic!("Expected RpcError variant"),
1419        }
1420    }
1421
1422    #[test]
1423    fn test_matches_error_pattern() {
1424        // Test exact matches
1425        assert!(matches_error_pattern(
1426            "blockhash not found",
1427            "blockhash not found"
1428        ));
1429        assert!(matches_error_pattern(
1430            "insufficient funds",
1431            "insufficient funds"
1432        ));
1433
1434        // Test case insensitive matching
1435        assert!(matches_error_pattern(
1436            "BLOCKHASH NOT FOUND",
1437            "blockhash not found"
1438        ));
1439        assert!(matches_error_pattern(
1440            "blockhash not found",
1441            "BLOCKHASH NOT FOUND"
1442        ));
1443        assert!(matches_error_pattern(
1444            "BlockHash Not Found",
1445            "blockhash not found"
1446        ));
1447
1448        // Test space insensitive matching
1449        assert!(matches_error_pattern(
1450            "blockhashnotfound",
1451            "blockhash not found"
1452        ));
1453        assert!(matches_error_pattern(
1454            "blockhash not found",
1455            "blockhashnotfound"
1456        ));
1457        assert!(matches_error_pattern(
1458            "insufficientfunds",
1459            "insufficient funds"
1460        ));
1461
1462        // Test mixed case and space insensitive
1463        assert!(matches_error_pattern(
1464            "BLOCKHASHNOTFOUND",
1465            "blockhash not found"
1466        ));
1467        assert!(matches_error_pattern(
1468            "blockhash not found",
1469            "BLOCKHASHNOTFOUND"
1470        ));
1471        assert!(matches_error_pattern(
1472            "BlockHashNotFound",
1473            "blockhash not found"
1474        ));
1475        assert!(matches_error_pattern(
1476            "INSUFFICIENTFUNDS",
1477            "insufficient funds"
1478        ));
1479
1480        // Test partial matches within longer strings
1481        assert!(matches_error_pattern(
1482            "transaction failed: blockhash not found",
1483            "blockhash not found"
1484        ));
1485        assert!(matches_error_pattern(
1486            "error: insufficient funds for transaction",
1487            "insufficient funds"
1488        ));
1489        assert!(matches_error_pattern(
1490            "BLOCKHASHNOTFOUND in simulation",
1491            "blockhash not found"
1492        ));
1493
1494        // Test multiple spaces handling
1495        assert!(matches_error_pattern(
1496            "blockhash  not   found",
1497            "blockhash not found"
1498        ));
1499        assert!(matches_error_pattern(
1500            "insufficient   funds",
1501            "insufficient funds"
1502        ));
1503
1504        // Test no matches
1505        assert!(!matches_error_pattern(
1506            "account not found",
1507            "blockhash not found"
1508        ));
1509        assert!(!matches_error_pattern(
1510            "invalid signature",
1511            "insufficient funds"
1512        ));
1513        assert!(!matches_error_pattern(
1514            "timeout error",
1515            "blockhash not found"
1516        ));
1517
1518        // Test empty strings
1519        assert!(matches_error_pattern("", ""));
1520        assert!(matches_error_pattern("blockhash not found", "")); // Empty pattern matches everything
1521        assert!(!matches_error_pattern("", "blockhash not found"));
1522
1523        // Test special characters and numbers
1524        assert!(matches_error_pattern(
1525            "error code -32008: blockhash not found",
1526            "-32008"
1527        ));
1528        assert!(matches_error_pattern("slot 123456 skipped", "slot"));
1529        assert!(matches_error_pattern("RPC_ERROR_503", "rpc_error_503"));
1530    }
1531
1532    #[test]
1533    fn test_solana_provider_error_is_transient() {
1534        // Test transient errors (should return true)
1535        assert!(SolanaProviderError::NetworkError("connection timeout".to_string()).is_transient());
1536        assert!(SolanaProviderError::RpcError("node is behind".to_string()).is_transient());
1537        assert!(
1538            SolanaProviderError::BlockhashNotFound("blockhash expired".to_string()).is_transient()
1539        );
1540        assert!(
1541            SolanaProviderError::SelectorError(RpcSelectorError::AllProvidersFailed).is_transient()
1542        );
1543
1544        // Test permanent errors (should return false)
1545        assert!(
1546            !SolanaProviderError::InsufficientFunds("not enough balance".to_string())
1547                .is_transient()
1548        );
1549        assert!(
1550            !SolanaProviderError::InvalidTransaction("invalid signature".to_string())
1551                .is_transient()
1552        );
1553        assert!(
1554            !SolanaProviderError::AlreadyProcessed("duplicate transaction".to_string())
1555                .is_transient()
1556        );
1557        assert!(
1558            !SolanaProviderError::InvalidAddress("invalid pubkey format".to_string())
1559                .is_transient()
1560        );
1561        assert!(
1562            !SolanaProviderError::NetworkConfiguration("unsupported operation".to_string())
1563                .is_transient()
1564        );
1565    }
1566
1567    #[tokio::test]
1568    async fn test_get_minimum_balance_for_rent_exemption() {
1569        let _env_guard = super::tests::setup_test_env();
1570        let configs = vec![super::tests::create_test_rpc_config()];
1571        let timeout = 30;
1572        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1573
1574        // 0 bytes is always valid, should return a value >= 0
1575        let result = provider.get_minimum_balance_for_rent_exemption(0).await;
1576        assert!(result.is_ok());
1577    }
1578
1579    #[tokio::test]
1580    async fn test_is_blockhash_valid_for_recent_blockhash() {
1581        let _env_guard = super::tests::setup_test_env();
1582        let configs = vec![super::tests::create_test_rpc_config()];
1583        let timeout = 30;
1584        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1585
1586        // Get a recent blockhash (should be valid)
1587        let blockhash = provider.get_latest_blockhash().await.unwrap();
1588        let is_valid = provider
1589            .is_blockhash_valid(&blockhash, CommitmentConfig::confirmed())
1590            .await;
1591        assert!(is_valid.is_ok());
1592    }
1593
1594    #[tokio::test]
1595    async fn test_is_blockhash_valid_for_invalid_blockhash() {
1596        let _env_guard = super::tests::setup_test_env();
1597        let configs = vec![super::tests::create_test_rpc_config()];
1598        let timeout = 30;
1599        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1600
1601        let invalid_blockhash = solana_sdk::hash::Hash::new_from_array([0u8; 32]);
1602        let is_valid = provider
1603            .is_blockhash_valid(&invalid_blockhash, CommitmentConfig::confirmed())
1604            .await;
1605        assert!(is_valid.is_ok());
1606    }
1607
1608    #[tokio::test]
1609    async fn test_get_latest_blockhash_with_commitment() {
1610        let _env_guard = super::tests::setup_test_env();
1611        let configs = vec![super::tests::create_test_rpc_config()];
1612        let timeout = 30;
1613        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1614
1615        let commitment = CommitmentConfig::confirmed();
1616        let result = provider
1617            .get_latest_blockhash_with_commitment(commitment)
1618            .await;
1619        assert!(result.is_ok());
1620        let (blockhash, last_valid_block_height) = result.unwrap();
1621        // Blockhash should not be all zeros and block height should be > 0
1622        assert_ne!(blockhash, solana_sdk::hash::Hash::new_from_array([0u8; 32]));
1623        assert!(last_valid_block_height > 0);
1624    }
1625
1626    #[test]
1627    fn test_from_rpc_response_error_transaction_simulation_failed() {
1628        // Create a simple mock ClientError for testing
1629        let mock_error = create_mock_client_error();
1630
1631        // -32002 with "blockhash not found" should be BlockhashNotFound
1632        let error_str =
1633            r#"{"code": -32002, "message": "Transaction simulation failed: Blockhash not found"}"#;
1634        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1635        assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_)));
1636
1637        // -32002 with "insufficient funds" should be InsufficientFunds
1638        let error_str =
1639            r#"{"code": -32002, "message": "Transaction simulation failed: Insufficient funds"}"#;
1640        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1641        assert!(matches!(result, SolanaProviderError::InsufficientFunds(_)));
1642
1643        // -32002 with other message should be InvalidTransaction
1644        let error_str = r#"{"code": -32002, "message": "Transaction simulation failed: Invalid instruction data"}"#;
1645        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1646        assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1647    }
1648
1649    #[test]
1650    fn test_from_rpc_response_error_signature_verification() {
1651        let mock_error = create_mock_client_error();
1652
1653        // -32003 should be InvalidTransaction
1654        let error_str = r#"{"code": -32003, "message": "Signature verification failure"}"#;
1655        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1656        assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1657    }
1658
1659    #[test]
1660    fn test_from_rpc_response_error_transient_errors() {
1661        let mock_error = create_mock_client_error();
1662
1663        // -32004: Block not available - should be RpcError (transient)
1664        let error_str = r#"{"code": -32004, "message": "Block not available for slot"}"#;
1665        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1666        assert!(matches!(result, SolanaProviderError::RpcError(_)));
1667
1668        // -32005: Node is behind - should be RpcError (transient)
1669        let error_str = r#"{"code": -32005, "message": "Node is behind"}"#;
1670        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1671        assert!(matches!(result, SolanaProviderError::RpcError(_)));
1672
1673        // -32008: Blockhash not found - should be BlockhashNotFound (transient)
1674        let error_str = r#"{"code": -32008, "message": "Blockhash not found"}"#;
1675        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1676        assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_)));
1677
1678        // -32014: Block status not available - should be RpcError (transient)
1679        let error_str = r#"{"code": -32014, "message": "Block status not yet available"}"#;
1680        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1681        assert!(matches!(result, SolanaProviderError::RpcError(_)));
1682
1683        // -32016: Minimum context slot not reached - should be RpcError (transient)
1684        let error_str = r#"{"code": -32016, "message": "Minimum context slot not reached"}"#;
1685        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1686        assert!(matches!(result, SolanaProviderError::RpcError(_)));
1687    }
1688
1689    #[test]
1690    fn test_from_rpc_response_error_permanent_errors() {
1691        let mock_error = create_mock_client_error();
1692
1693        // -32007: Slot skipped - should be NetworkConfiguration (permanent)
1694        let error_str = r#"{"code": -32007, "message": "Slot skipped"}"#;
1695        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1696        assert!(matches!(
1697            result,
1698            SolanaProviderError::NetworkConfiguration(_)
1699        ));
1700
1701        // -32009: Already processed - should be AlreadyProcessed (permanent)
1702        let error_str = r#"{"code": -32009, "message": "Already processed"}"#;
1703        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1704        assert!(matches!(result, SolanaProviderError::AlreadyProcessed(_)));
1705
1706        // -32010: Key excluded from secondary indexes - should be NetworkConfiguration (permanent)
1707        let error_str = r#"{"code": -32010, "message": "Key excluded from secondary indexes"}"#;
1708        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1709        assert!(matches!(
1710            result,
1711            SolanaProviderError::NetworkConfiguration(_)
1712        ));
1713
1714        // -32013: Transaction signature length mismatch - should be InvalidTransaction (permanent)
1715        let error_str = r#"{"code": -32013, "message": "Transaction signature length mismatch"}"#;
1716        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1717        assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1718
1719        // -32015: Transaction version not supported - should be InvalidTransaction (permanent)
1720        let error_str = r#"{"code": -32015, "message": "Transaction version not supported"}"#;
1721        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1722        assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1723
1724        // -32602: Invalid params - should be InvalidTransaction (permanent)
1725        let error_str = r#"{"code": -32602, "message": "Invalid params"}"#;
1726        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1727        assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1728    }
1729
1730    #[test]
1731    fn test_from_rpc_response_error_string_pattern_matching() {
1732        let mock_error = create_mock_client_error();
1733
1734        // Test case-insensitive and space-insensitive pattern matching
1735        let error_str = r#"{"code": -32000, "message": "INSUFFICIENTFUNDS for transaction"}"#;
1736        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1737        assert!(matches!(result, SolanaProviderError::InsufficientFunds(_)));
1738
1739        let error_str = r#"{"code": -32000, "message": "BlockhashNotFound"}"#;
1740        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1741        assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_)));
1742
1743        let error_str = r#"{"code": -32000, "message": "AlreadyProcessed"}"#;
1744        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1745        assert!(matches!(result, SolanaProviderError::AlreadyProcessed(_)));
1746    }
1747
1748    #[test]
1749    fn test_from_rpc_response_error_unknown_code() {
1750        let mock_error = create_mock_client_error();
1751
1752        // Unknown error code should default to RpcError (transient)
1753        let error_str = r#"{"code": -99999, "message": "Unknown error"}"#;
1754        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1755        assert!(matches!(result, SolanaProviderError::RpcError(_)));
1756    }
1757
1758    // Helper function to create a mock ClientError for testing
1759    fn create_mock_client_error() -> ClientError {
1760        use solana_client::rpc_request::RpcRequest;
1761        // Create a simple ClientError using available constructors
1762        ClientError::new_with_request(
1763            ClientErrorKind::RpcError(solana_client::rpc_request::RpcError::RpcRequestError(
1764                "test".to_string(),
1765            )),
1766            RpcRequest::GetHealth,
1767        )
1768    }
1769
1770    #[test]
1771    fn test_from_rpc_error_integration() {
1772        // Test that a typical RPC error string gets classified correctly
1773        let mock_error = create_mock_client_error();
1774
1775        // Test the fallback string matching for "insufficient funds"
1776        let error_str = r#"{"code": -32000, "message": "Account has insufficient funds"}"#;
1777        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1778        assert!(matches!(result, SolanaProviderError::InsufficientFunds(_)));
1779
1780        // Test the fallback string matching for "blockhash not found"
1781        let error_str = r#"{"code": -32000, "message": "Blockhash not found"}"#;
1782        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1783        assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_)));
1784
1785        // Test the fallback string matching for "already processed"
1786        let error_str = r#"{"code": -32000, "message": "Transaction was already processed"}"#;
1787        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1788        assert!(matches!(result, SolanaProviderError::AlreadyProcessed(_)));
1789    }
1790
1791    #[test]
1792    fn test_request_error_is_transient() {
1793        // Test retriable 5xx errors
1794        let error = SolanaProviderError::RequestError {
1795            error: "Server error".to_string(),
1796            status_code: 500,
1797        };
1798        assert!(error.is_transient());
1799
1800        let error = SolanaProviderError::RequestError {
1801            error: "Bad gateway".to_string(),
1802            status_code: 502,
1803        };
1804        assert!(error.is_transient());
1805
1806        let error = SolanaProviderError::RequestError {
1807            error: "Service unavailable".to_string(),
1808            status_code: 503,
1809        };
1810        assert!(error.is_transient());
1811
1812        let error = SolanaProviderError::RequestError {
1813            error: "Gateway timeout".to_string(),
1814            status_code: 504,
1815        };
1816        assert!(error.is_transient());
1817
1818        // Test retriable 4xx errors
1819        let error = SolanaProviderError::RequestError {
1820            error: "Request timeout".to_string(),
1821            status_code: 408,
1822        };
1823        assert!(error.is_transient());
1824
1825        let error = SolanaProviderError::RequestError {
1826            error: "Too early".to_string(),
1827            status_code: 425,
1828        };
1829        assert!(error.is_transient());
1830
1831        let error = SolanaProviderError::RequestError {
1832            error: "Too many requests".to_string(),
1833            status_code: 429,
1834        };
1835        assert!(error.is_transient());
1836
1837        // Test non-retriable 5xx errors
1838        let error = SolanaProviderError::RequestError {
1839            error: "Not implemented".to_string(),
1840            status_code: 501,
1841        };
1842        assert!(!error.is_transient());
1843
1844        let error = SolanaProviderError::RequestError {
1845            error: "HTTP version not supported".to_string(),
1846            status_code: 505,
1847        };
1848        assert!(!error.is_transient());
1849
1850        // Test non-retriable 4xx errors
1851        let error = SolanaProviderError::RequestError {
1852            error: "Bad request".to_string(),
1853            status_code: 400,
1854        };
1855        assert!(!error.is_transient());
1856
1857        let error = SolanaProviderError::RequestError {
1858            error: "Unauthorized".to_string(),
1859            status_code: 401,
1860        };
1861        assert!(!error.is_transient());
1862
1863        let error = SolanaProviderError::RequestError {
1864            error: "Forbidden".to_string(),
1865            status_code: 403,
1866        };
1867        assert!(!error.is_transient());
1868
1869        let error = SolanaProviderError::RequestError {
1870            error: "Not found".to_string(),
1871            status_code: 404,
1872        };
1873        assert!(!error.is_transient());
1874    }
1875
1876    #[test]
1877    fn test_request_error_display() {
1878        let error = SolanaProviderError::RequestError {
1879            error: "Server error".to_string(),
1880            status_code: 500,
1881        };
1882        let error_str = format!("{error}");
1883        assert!(error_str.contains("HTTP 500"));
1884        assert!(error_str.contains("Server error"));
1885    }
1886}