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