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