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: {:?}",
1245            simulation_result
1246        );
1247
1248        let result = simulation_result.unwrap();
1249        // The simulation result may contain logs or an error field.
1250        // For a no-op transaction, we expect no errors and possibly empty logs.
1251        assert!(
1252            result.err.is_none(),
1253            "Simulation encountered an error: {:?}",
1254            result.err
1255        );
1256    }
1257
1258    #[tokio::test]
1259    async fn test_get_token_metadata_from_pubkey() {
1260        let _env_guard = setup_test_env();
1261        let configs = vec![RpcConfig {
1262            url: "https://api.mainnet-beta.solana.com".to_string(),
1263            weight: 1,
1264            ..Default::default()
1265        }];
1266        let timeout = 30;
1267        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1268        let usdc_token_metadata = provider
1269            .get_token_metadata_from_pubkey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1270            .await
1271            .unwrap();
1272
1273        assert_eq!(
1274            usdc_token_metadata,
1275            TokenMetadata {
1276                decimals: 6,
1277                symbol: "USDC".to_string(),
1278                mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
1279            }
1280        );
1281
1282        let usdt_token_metadata = provider
1283            .get_token_metadata_from_pubkey("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB")
1284            .await
1285            .unwrap();
1286
1287        assert_eq!(
1288            usdt_token_metadata,
1289            TokenMetadata {
1290                decimals: 6,
1291                symbol: "USDT".to_string(),
1292                mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(),
1293            }
1294        );
1295    }
1296
1297    #[tokio::test]
1298    async fn test_get_client_success() {
1299        let _env_guard = setup_test_env();
1300        let configs = vec![create_test_rpc_config()];
1301        let timeout = 30;
1302        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1303
1304        let client = provider.get_client();
1305        assert!(client.is_ok());
1306
1307        let client = client.unwrap();
1308        let health_result = client.get_health().await;
1309        assert!(health_result.is_ok());
1310    }
1311
1312    #[tokio::test]
1313    async fn test_get_client_with_custom_commitment() {
1314        let _env_guard = setup_test_env();
1315        let configs = vec![create_test_rpc_config()];
1316        let timeout = 30;
1317        let commitment = CommitmentConfig::finalized();
1318
1319        let provider =
1320            SolanaProvider::new_with_commitment_and_health(configs, timeout, commitment, 3, 60, 60)
1321                .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_multiple_rpcs() {
1333        let _env_guard = setup_test_env();
1334        let configs = vec![
1335            create_test_rpc_config(),
1336            RpcConfig {
1337                url: "https://api.mainnet-beta.solana.com".to_string(),
1338                weight: 2,
1339                ..Default::default()
1340            },
1341        ];
1342        let timeout = 30;
1343
1344        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1345
1346        let client_result = provider.get_client();
1347        assert!(client_result.is_ok());
1348
1349        // Call multiple times to exercise the selection logic
1350        for _ in 0..5 {
1351            let client = provider.get_client();
1352            assert!(client.is_ok());
1353        }
1354    }
1355
1356    #[test]
1357    fn test_initialize_provider_valid_url() {
1358        let _env_guard = setup_test_env();
1359
1360        let configs = vec![RpcConfig {
1361            url: "https://api.devnet.solana.com".to_string(),
1362            weight: 1,
1363            ..Default::default()
1364        }];
1365        let provider = SolanaProvider::new(create_test_provider_config(configs, 10)).unwrap();
1366        let result = provider.initialize_provider("https://api.devnet.solana.com");
1367        assert!(result.is_ok());
1368        let arc_client = result.unwrap();
1369        // Arc pointer should not be null and should point to RpcClient
1370        let _client: &RpcClient = Arc::as_ref(&arc_client);
1371    }
1372
1373    #[test]
1374    fn test_initialize_provider_invalid_url() {
1375        let _env_guard = setup_test_env();
1376
1377        let configs = vec![RpcConfig {
1378            url: "https://api.devnet.solana.com".to_string(),
1379            weight: 1,
1380            ..Default::default()
1381        }];
1382        let provider = SolanaProvider::new(create_test_provider_config(configs, 10)).unwrap();
1383        let result = provider.initialize_provider("not-a-valid-url");
1384        assert!(result.is_err());
1385        match result {
1386            Err(SolanaProviderError::NetworkConfiguration(msg)) => {
1387                assert!(msg.contains("Invalid URL format"))
1388            }
1389            _ => panic!("Expected NetworkConfiguration error"),
1390        }
1391    }
1392
1393    #[test]
1394    fn test_from_string_for_solana_provider_error() {
1395        let msg = "some rpc error".to_string();
1396        let err: SolanaProviderError = msg.clone().into();
1397        match err {
1398            SolanaProviderError::RpcError(inner) => assert_eq!(inner, msg),
1399            _ => panic!("Expected RpcError variant"),
1400        }
1401    }
1402
1403    #[test]
1404    fn test_matches_error_pattern() {
1405        // Test exact matches
1406        assert!(matches_error_pattern(
1407            "blockhash not found",
1408            "blockhash not found"
1409        ));
1410        assert!(matches_error_pattern(
1411            "insufficient funds",
1412            "insufficient funds"
1413        ));
1414
1415        // Test case insensitive matching
1416        assert!(matches_error_pattern(
1417            "BLOCKHASH NOT FOUND",
1418            "blockhash not found"
1419        ));
1420        assert!(matches_error_pattern(
1421            "blockhash not found",
1422            "BLOCKHASH NOT FOUND"
1423        ));
1424        assert!(matches_error_pattern(
1425            "BlockHash Not Found",
1426            "blockhash not found"
1427        ));
1428
1429        // Test space insensitive matching
1430        assert!(matches_error_pattern(
1431            "blockhashnotfound",
1432            "blockhash not found"
1433        ));
1434        assert!(matches_error_pattern(
1435            "blockhash not found",
1436            "blockhashnotfound"
1437        ));
1438        assert!(matches_error_pattern(
1439            "insufficientfunds",
1440            "insufficient funds"
1441        ));
1442
1443        // Test mixed case and space insensitive
1444        assert!(matches_error_pattern(
1445            "BLOCKHASHNOTFOUND",
1446            "blockhash not found"
1447        ));
1448        assert!(matches_error_pattern(
1449            "blockhash not found",
1450            "BLOCKHASHNOTFOUND"
1451        ));
1452        assert!(matches_error_pattern(
1453            "BlockHashNotFound",
1454            "blockhash not found"
1455        ));
1456        assert!(matches_error_pattern(
1457            "INSUFFICIENTFUNDS",
1458            "insufficient funds"
1459        ));
1460
1461        // Test partial matches within longer strings
1462        assert!(matches_error_pattern(
1463            "transaction failed: blockhash not found",
1464            "blockhash not found"
1465        ));
1466        assert!(matches_error_pattern(
1467            "error: insufficient funds for transaction",
1468            "insufficient funds"
1469        ));
1470        assert!(matches_error_pattern(
1471            "BLOCKHASHNOTFOUND in simulation",
1472            "blockhash not found"
1473        ));
1474
1475        // Test multiple spaces handling
1476        assert!(matches_error_pattern(
1477            "blockhash  not   found",
1478            "blockhash not found"
1479        ));
1480        assert!(matches_error_pattern(
1481            "insufficient   funds",
1482            "insufficient funds"
1483        ));
1484
1485        // Test no matches
1486        assert!(!matches_error_pattern(
1487            "account not found",
1488            "blockhash not found"
1489        ));
1490        assert!(!matches_error_pattern(
1491            "invalid signature",
1492            "insufficient funds"
1493        ));
1494        assert!(!matches_error_pattern(
1495            "timeout error",
1496            "blockhash not found"
1497        ));
1498
1499        // Test empty strings
1500        assert!(matches_error_pattern("", ""));
1501        assert!(matches_error_pattern("blockhash not found", "")); // Empty pattern matches everything
1502        assert!(!matches_error_pattern("", "blockhash not found"));
1503
1504        // Test special characters and numbers
1505        assert!(matches_error_pattern(
1506            "error code -32008: blockhash not found",
1507            "-32008"
1508        ));
1509        assert!(matches_error_pattern("slot 123456 skipped", "slot"));
1510        assert!(matches_error_pattern("RPC_ERROR_503", "rpc_error_503"));
1511    }
1512
1513    #[test]
1514    fn test_solana_provider_error_is_transient() {
1515        // Test transient errors (should return true)
1516        assert!(SolanaProviderError::NetworkError("connection timeout".to_string()).is_transient());
1517        assert!(SolanaProviderError::RpcError("node is behind".to_string()).is_transient());
1518        assert!(
1519            SolanaProviderError::BlockhashNotFound("blockhash expired".to_string()).is_transient()
1520        );
1521        assert!(
1522            SolanaProviderError::SelectorError(RpcSelectorError::AllProvidersFailed).is_transient()
1523        );
1524
1525        // Test permanent errors (should return false)
1526        assert!(
1527            !SolanaProviderError::InsufficientFunds("not enough balance".to_string())
1528                .is_transient()
1529        );
1530        assert!(
1531            !SolanaProviderError::InvalidTransaction("invalid signature".to_string())
1532                .is_transient()
1533        );
1534        assert!(
1535            !SolanaProviderError::AlreadyProcessed("duplicate transaction".to_string())
1536                .is_transient()
1537        );
1538        assert!(
1539            !SolanaProviderError::InvalidAddress("invalid pubkey format".to_string())
1540                .is_transient()
1541        );
1542        assert!(
1543            !SolanaProviderError::NetworkConfiguration("unsupported operation".to_string())
1544                .is_transient()
1545        );
1546    }
1547
1548    #[tokio::test]
1549    async fn test_get_minimum_balance_for_rent_exemption() {
1550        let _env_guard = super::tests::setup_test_env();
1551        let configs = vec![super::tests::create_test_rpc_config()];
1552        let timeout = 30;
1553        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1554
1555        // 0 bytes is always valid, should return a value >= 0
1556        let result = provider.get_minimum_balance_for_rent_exemption(0).await;
1557        assert!(result.is_ok());
1558    }
1559
1560    #[tokio::test]
1561    async fn test_is_blockhash_valid_for_recent_blockhash() {
1562        let _env_guard = super::tests::setup_test_env();
1563        let configs = vec![super::tests::create_test_rpc_config()];
1564        let timeout = 30;
1565        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1566
1567        // Get a recent blockhash (should be valid)
1568        let blockhash = provider.get_latest_blockhash().await.unwrap();
1569        let is_valid = provider
1570            .is_blockhash_valid(&blockhash, CommitmentConfig::confirmed())
1571            .await;
1572        assert!(is_valid.is_ok());
1573    }
1574
1575    #[tokio::test]
1576    async fn test_is_blockhash_valid_for_invalid_blockhash() {
1577        let _env_guard = super::tests::setup_test_env();
1578        let configs = vec![super::tests::create_test_rpc_config()];
1579        let timeout = 30;
1580        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1581
1582        let invalid_blockhash = solana_sdk::hash::Hash::new_from_array([0u8; 32]);
1583        let is_valid = provider
1584            .is_blockhash_valid(&invalid_blockhash, CommitmentConfig::confirmed())
1585            .await;
1586        assert!(is_valid.is_ok());
1587    }
1588
1589    #[tokio::test]
1590    async fn test_get_latest_blockhash_with_commitment() {
1591        let _env_guard = super::tests::setup_test_env();
1592        let configs = vec![super::tests::create_test_rpc_config()];
1593        let timeout = 30;
1594        let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1595
1596        let commitment = CommitmentConfig::confirmed();
1597        let result = provider
1598            .get_latest_blockhash_with_commitment(commitment)
1599            .await;
1600        assert!(result.is_ok());
1601        let (blockhash, last_valid_block_height) = result.unwrap();
1602        // Blockhash should not be all zeros and block height should be > 0
1603        assert_ne!(blockhash, solana_sdk::hash::Hash::new_from_array([0u8; 32]));
1604        assert!(last_valid_block_height > 0);
1605    }
1606
1607    #[test]
1608    fn test_from_rpc_response_error_transaction_simulation_failed() {
1609        // Create a simple mock ClientError for testing
1610        let mock_error = create_mock_client_error();
1611
1612        // -32002 with "blockhash not found" should be BlockhashNotFound
1613        let error_str =
1614            r#"{"code": -32002, "message": "Transaction simulation failed: Blockhash not found"}"#;
1615        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1616        assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_)));
1617
1618        // -32002 with "insufficient funds" should be InsufficientFunds
1619        let error_str =
1620            r#"{"code": -32002, "message": "Transaction simulation failed: Insufficient funds"}"#;
1621        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1622        assert!(matches!(result, SolanaProviderError::InsufficientFunds(_)));
1623
1624        // -32002 with other message should be InvalidTransaction
1625        let error_str = r#"{"code": -32002, "message": "Transaction simulation failed: Invalid instruction data"}"#;
1626        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1627        assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1628    }
1629
1630    #[test]
1631    fn test_from_rpc_response_error_signature_verification() {
1632        let mock_error = create_mock_client_error();
1633
1634        // -32003 should be InvalidTransaction
1635        let error_str = r#"{"code": -32003, "message": "Signature verification failure"}"#;
1636        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1637        assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1638    }
1639
1640    #[test]
1641    fn test_from_rpc_response_error_transient_errors() {
1642        let mock_error = create_mock_client_error();
1643
1644        // -32004: Block not available - should be RpcError (transient)
1645        let error_str = r#"{"code": -32004, "message": "Block not available for slot"}"#;
1646        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1647        assert!(matches!(result, SolanaProviderError::RpcError(_)));
1648
1649        // -32005: Node is behind - should be RpcError (transient)
1650        let error_str = r#"{"code": -32005, "message": "Node is behind"}"#;
1651        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1652        assert!(matches!(result, SolanaProviderError::RpcError(_)));
1653
1654        // -32008: Blockhash not found - should be BlockhashNotFound (transient)
1655        let error_str = r#"{"code": -32008, "message": "Blockhash not found"}"#;
1656        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1657        assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_)));
1658
1659        // -32014: Block status not available - should be RpcError (transient)
1660        let error_str = r#"{"code": -32014, "message": "Block status not yet available"}"#;
1661        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1662        assert!(matches!(result, SolanaProviderError::RpcError(_)));
1663
1664        // -32016: Minimum context slot not reached - should be RpcError (transient)
1665        let error_str = r#"{"code": -32016, "message": "Minimum context slot not reached"}"#;
1666        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1667        assert!(matches!(result, SolanaProviderError::RpcError(_)));
1668    }
1669
1670    #[test]
1671    fn test_from_rpc_response_error_permanent_errors() {
1672        let mock_error = create_mock_client_error();
1673
1674        // -32007: Slot skipped - should be NetworkConfiguration (permanent)
1675        let error_str = r#"{"code": -32007, "message": "Slot skipped"}"#;
1676        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1677        assert!(matches!(
1678            result,
1679            SolanaProviderError::NetworkConfiguration(_)
1680        ));
1681
1682        // -32009: Already processed - should be AlreadyProcessed (permanent)
1683        let error_str = r#"{"code": -32009, "message": "Already processed"}"#;
1684        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1685        assert!(matches!(result, SolanaProviderError::AlreadyProcessed(_)));
1686
1687        // -32010: Key excluded from secondary indexes - should be NetworkConfiguration (permanent)
1688        let error_str = r#"{"code": -32010, "message": "Key excluded from secondary indexes"}"#;
1689        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1690        assert!(matches!(
1691            result,
1692            SolanaProviderError::NetworkConfiguration(_)
1693        ));
1694
1695        // -32013: Transaction signature length mismatch - should be InvalidTransaction (permanent)
1696        let error_str = r#"{"code": -32013, "message": "Transaction signature length mismatch"}"#;
1697        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1698        assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1699
1700        // -32015: Transaction version not supported - should be InvalidTransaction (permanent)
1701        let error_str = r#"{"code": -32015, "message": "Transaction version not supported"}"#;
1702        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1703        assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1704
1705        // -32602: Invalid params - should be InvalidTransaction (permanent)
1706        let error_str = r#"{"code": -32602, "message": "Invalid params"}"#;
1707        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1708        assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1709    }
1710
1711    #[test]
1712    fn test_from_rpc_response_error_string_pattern_matching() {
1713        let mock_error = create_mock_client_error();
1714
1715        // Test case-insensitive and space-insensitive pattern matching
1716        let error_str = r#"{"code": -32000, "message": "INSUFFICIENTFUNDS for transaction"}"#;
1717        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1718        assert!(matches!(result, SolanaProviderError::InsufficientFunds(_)));
1719
1720        let error_str = r#"{"code": -32000, "message": "BlockhashNotFound"}"#;
1721        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1722        assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_)));
1723
1724        let error_str = r#"{"code": -32000, "message": "AlreadyProcessed"}"#;
1725        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1726        assert!(matches!(result, SolanaProviderError::AlreadyProcessed(_)));
1727    }
1728
1729    #[test]
1730    fn test_from_rpc_response_error_unknown_code() {
1731        let mock_error = create_mock_client_error();
1732
1733        // Unknown error code should default to RpcError (transient)
1734        let error_str = r#"{"code": -99999, "message": "Unknown error"}"#;
1735        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1736        assert!(matches!(result, SolanaProviderError::RpcError(_)));
1737    }
1738
1739    // Helper function to create a mock ClientError for testing
1740    fn create_mock_client_error() -> ClientError {
1741        use solana_client::rpc_request::RpcRequest;
1742        // Create a simple ClientError using available constructors
1743        ClientError::new_with_request(
1744            ClientErrorKind::RpcError(solana_client::rpc_request::RpcError::RpcRequestError(
1745                "test".to_string(),
1746            )),
1747            RpcRequest::GetHealth,
1748        )
1749    }
1750
1751    #[test]
1752    fn test_from_rpc_error_integration() {
1753        // Test that a typical RPC error string gets classified correctly
1754        let mock_error = create_mock_client_error();
1755
1756        // Test the fallback string matching for "insufficient funds"
1757        let error_str = r#"{"code": -32000, "message": "Account has insufficient funds"}"#;
1758        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1759        assert!(matches!(result, SolanaProviderError::InsufficientFunds(_)));
1760
1761        // Test the fallback string matching for "blockhash not found"
1762        let error_str = r#"{"code": -32000, "message": "Blockhash not found"}"#;
1763        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1764        assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_)));
1765
1766        // Test the fallback string matching for "already processed"
1767        let error_str = r#"{"code": -32000, "message": "Transaction was already processed"}"#;
1768        let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1769        assert!(matches!(result, SolanaProviderError::AlreadyProcessed(_)));
1770    }
1771
1772    #[test]
1773    fn test_request_error_is_transient() {
1774        // Test retriable 5xx errors
1775        let error = SolanaProviderError::RequestError {
1776            error: "Server error".to_string(),
1777            status_code: 500,
1778        };
1779        assert!(error.is_transient());
1780
1781        let error = SolanaProviderError::RequestError {
1782            error: "Bad gateway".to_string(),
1783            status_code: 502,
1784        };
1785        assert!(error.is_transient());
1786
1787        let error = SolanaProviderError::RequestError {
1788            error: "Service unavailable".to_string(),
1789            status_code: 503,
1790        };
1791        assert!(error.is_transient());
1792
1793        let error = SolanaProviderError::RequestError {
1794            error: "Gateway timeout".to_string(),
1795            status_code: 504,
1796        };
1797        assert!(error.is_transient());
1798
1799        // Test retriable 4xx errors
1800        let error = SolanaProviderError::RequestError {
1801            error: "Request timeout".to_string(),
1802            status_code: 408,
1803        };
1804        assert!(error.is_transient());
1805
1806        let error = SolanaProviderError::RequestError {
1807            error: "Too early".to_string(),
1808            status_code: 425,
1809        };
1810        assert!(error.is_transient());
1811
1812        let error = SolanaProviderError::RequestError {
1813            error: "Too many requests".to_string(),
1814            status_code: 429,
1815        };
1816        assert!(error.is_transient());
1817
1818        // Test non-retriable 5xx errors
1819        let error = SolanaProviderError::RequestError {
1820            error: "Not implemented".to_string(),
1821            status_code: 501,
1822        };
1823        assert!(!error.is_transient());
1824
1825        let error = SolanaProviderError::RequestError {
1826            error: "HTTP version not supported".to_string(),
1827            status_code: 505,
1828        };
1829        assert!(!error.is_transient());
1830
1831        // Test non-retriable 4xx errors
1832        let error = SolanaProviderError::RequestError {
1833            error: "Bad request".to_string(),
1834            status_code: 400,
1835        };
1836        assert!(!error.is_transient());
1837
1838        let error = SolanaProviderError::RequestError {
1839            error: "Unauthorized".to_string(),
1840            status_code: 401,
1841        };
1842        assert!(!error.is_transient());
1843
1844        let error = SolanaProviderError::RequestError {
1845            error: "Forbidden".to_string(),
1846            status_code: 403,
1847        };
1848        assert!(!error.is_transient());
1849
1850        let error = SolanaProviderError::RequestError {
1851            error: "Not found".to_string(),
1852            status_code: 404,
1853        };
1854        assert!(!error.is_transient());
1855    }
1856
1857    #[test]
1858    fn test_request_error_display() {
1859        let error = SolanaProviderError::RequestError {
1860            error: "Server error".to_string(),
1861            status_code: 500,
1862        };
1863        let error_str = format!("{}", error);
1864        assert!(error_str.contains("HTTP 500"));
1865        assert!(error_str.contains("Server error"));
1866    }
1867}