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//!
11//! TODO: add support for using multiple RPCs and retries
12use async_trait::async_trait;
13use eyre::Result;
14#[cfg(test)]
15use mockall::automock;
16use mpl_token_metadata::accounts::Metadata;
17use reqwest::Url;
18use serde::Serialize;
19use solana_client::{
20    nonblocking::rpc_client::RpcClient,
21    rpc_response::{RpcPrioritizationFee, RpcSimulateTransactionResult},
22};
23use solana_sdk::{
24    account::Account,
25    commitment_config::CommitmentConfig,
26    hash::Hash,
27    message::Message,
28    program_pack::Pack,
29    pubkey::Pubkey,
30    signature::Signature,
31    transaction::{Transaction, VersionedTransaction},
32};
33use spl_token::state::Mint;
34use std::{str::FromStr, sync::Arc, time::Duration};
35use thiserror::Error;
36
37use crate::{models::RpcConfig, services::retry_rpc_call};
38
39use super::ProviderError;
40use super::{
41    rpc_selector::{RpcSelector, RpcSelectorError},
42    RetryConfig,
43};
44
45#[derive(Error, Debug, Serialize)]
46pub enum SolanaProviderError {
47    #[error("RPC client error: {0}")]
48    RpcError(String),
49    #[error("Invalid address: {0}")]
50    InvalidAddress(String),
51    #[error("RPC selector error: {0}")]
52    SelectorError(RpcSelectorError),
53    #[error("Network configuration error: {0}")]
54    NetworkConfiguration(String),
55}
56
57/// A trait that abstracts common Solana provider operations.
58#[async_trait]
59#[cfg_attr(test, automock)]
60#[allow(dead_code)]
61pub trait SolanaProviderTrait: Send + Sync {
62    /// Retrieves the balance (in lamports) for the given address.
63    async fn get_balance(&self, address: &str) -> Result<u64, SolanaProviderError>;
64
65    /// Retrieves the latest blockhash as a 32-byte array.
66    async fn get_latest_blockhash(&self) -> Result<Hash, SolanaProviderError>;
67
68    // Retrieves the latest blockhash with the specified commitment.
69    async fn get_latest_blockhash_with_commitment(
70        &self,
71        commitment: CommitmentConfig,
72    ) -> Result<(Hash, u64), SolanaProviderError>;
73
74    /// Sends a transaction to the Solana network.
75    async fn send_transaction(
76        &self,
77        transaction: &Transaction,
78    ) -> Result<Signature, SolanaProviderError>;
79
80    /// Sends a transaction to the Solana network.
81    async fn send_versioned_transaction(
82        &self,
83        transaction: &VersionedTransaction,
84    ) -> Result<Signature, SolanaProviderError>;
85
86    /// Confirms a transaction given its signature.
87    async fn confirm_transaction(&self, signature: &Signature)
88        -> Result<bool, SolanaProviderError>;
89
90    /// Retrieves the minimum balance required for rent exemption for the specified data size.
91    async fn get_minimum_balance_for_rent_exemption(
92        &self,
93        data_size: usize,
94    ) -> Result<u64, SolanaProviderError>;
95
96    /// Simulates a transaction and returns the simulation result.
97    async fn simulate_transaction(
98        &self,
99        transaction: &Transaction,
100    ) -> Result<RpcSimulateTransactionResult, SolanaProviderError>;
101
102    /// Retrieve an account given its string representation.
103    async fn get_account_from_str(&self, account: &str) -> Result<Account, SolanaProviderError>;
104
105    /// Retrieve an account given its Pubkey.
106    async fn get_account_from_pubkey(
107        &self,
108        pubkey: &Pubkey,
109    ) -> Result<Account, SolanaProviderError>;
110
111    /// Retrieve token metadata from the provided pubkey.
112    async fn get_token_metadata_from_pubkey(
113        &self,
114        pubkey: &str,
115    ) -> Result<TokenMetadata, SolanaProviderError>;
116
117    /// Check if a blockhash is valid.
118    async fn is_blockhash_valid(
119        &self,
120        hash: &Hash,
121        commitment: CommitmentConfig,
122    ) -> Result<bool, SolanaProviderError>;
123
124    /// get fee for message
125    async fn get_fee_for_message(&self, message: &Message) -> Result<u64, SolanaProviderError>;
126
127    /// get recent prioritization fees
128    async fn get_recent_prioritization_fees(
129        &self,
130        addresses: &[Pubkey],
131    ) -> Result<Vec<RpcPrioritizationFee>, SolanaProviderError>;
132
133    /// calculate total fee
134    async fn calculate_total_fee(&self, message: &Message) -> Result<u64, SolanaProviderError>;
135}
136
137#[derive(Debug)]
138pub struct SolanaProvider {
139    // RPC selector for handling multiple client connections
140    selector: RpcSelector,
141    // Default timeout in seconds
142    timeout_seconds: Duration,
143    // Default commitment level
144    commitment: CommitmentConfig,
145    // Retry configuration for network requests
146    retry_config: RetryConfig,
147}
148
149impl From<String> for SolanaProviderError {
150    fn from(s: String) -> Self {
151        SolanaProviderError::RpcError(s)
152    }
153}
154
155const RETRIABLE_ERROR_SUBSTRINGS: &[&str] = &[
156    "timeout",
157    "connection",
158    "reset",
159    "temporarily unavailable",
160    "rate limit",
161    "too many requests",
162    "503",
163    "502",
164    "504",
165    "blockhash not found",
166    "node is behind",
167    "unhealthy",
168];
169
170fn is_retriable_error(msg: &str) -> bool {
171    RETRIABLE_ERROR_SUBSTRINGS
172        .iter()
173        .any(|substr| msg.contains(substr))
174}
175
176#[derive(Error, Debug, PartialEq)]
177pub struct TokenMetadata {
178    pub decimals: u8,
179    pub symbol: String,
180    pub mint: String,
181}
182
183impl std::fmt::Display for TokenMetadata {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        write!(
186            f,
187            "TokenMetadata {{ decimals: {}, symbol: {}, mint: {} }}",
188            self.decimals, self.symbol, self.mint
189        )
190    }
191}
192
193#[allow(dead_code)]
194impl SolanaProvider {
195    pub fn new(configs: Vec<RpcConfig>, timeout_seconds: u64) -> Result<Self, ProviderError> {
196        Self::new_with_commitment(configs, timeout_seconds, CommitmentConfig::confirmed())
197    }
198
199    /// Creates a new SolanaProvider with RPC configurations and optional settings.
200    ///
201    /// # Arguments
202    ///
203    /// * `configs` - A vector of RPC configurations
204    /// * `timeout` - Optional custom timeout
205    /// * `commitment` - Optional custom commitment level
206    ///
207    /// # Returns
208    ///
209    /// A Result containing the provider or an error
210    pub fn new_with_commitment(
211        configs: Vec<RpcConfig>,
212        timeout_seconds: u64,
213        commitment: CommitmentConfig,
214    ) -> Result<Self, ProviderError> {
215        if configs.is_empty() {
216            return Err(ProviderError::NetworkConfiguration(
217                "At least one RPC configuration must be provided".to_string(),
218            ));
219        }
220
221        RpcConfig::validate_list(&configs)
222            .map_err(|e| ProviderError::NetworkConfiguration(format!("Invalid URL: {}", e)))?;
223
224        // Now create the selector with validated configs
225        let selector = RpcSelector::new(configs).map_err(|e| {
226            ProviderError::NetworkConfiguration(format!("Failed to create RPC selector: {}", e))
227        })?;
228
229        let retry_config = RetryConfig::from_env();
230
231        Ok(Self {
232            selector,
233            timeout_seconds: Duration::from_secs(timeout_seconds),
234            commitment,
235            retry_config,
236        })
237    }
238
239    /// Retrieves an RPC client instance using the configured selector.
240    ///
241    /// # Returns
242    ///
243    /// A Result containing either:
244    /// - A configured RPC client connected to a selected endpoint
245    /// - A SolanaProviderError describing what went wrong
246    ///
247    fn get_client(&self) -> Result<RpcClient, SolanaProviderError> {
248        self.selector
249            .get_client(|url| {
250                Ok(RpcClient::new_with_timeout_and_commitment(
251                    url.to_string(),
252                    self.timeout_seconds,
253                    self.commitment,
254                ))
255            })
256            .map_err(SolanaProviderError::SelectorError)
257    }
258
259    /// Initialize a provider for a given URL
260    fn initialize_provider(&self, url: &str) -> Result<Arc<RpcClient>, SolanaProviderError> {
261        let rpc_url: Url = url.parse().map_err(|e| {
262            SolanaProviderError::NetworkConfiguration(format!("Invalid URL format: {}", e))
263        })?;
264
265        let client = RpcClient::new_with_timeout_and_commitment(
266            rpc_url.to_string(),
267            self.timeout_seconds,
268            self.commitment,
269        );
270
271        Ok(Arc::new(client))
272    }
273
274    /// Retry helper for Solana RPC calls
275    async fn retry_rpc_call<T, F, Fut>(
276        &self,
277        operation_name: &str,
278        operation: F,
279    ) -> Result<T, SolanaProviderError>
280    where
281        F: Fn(Arc<RpcClient>) -> Fut,
282        Fut: std::future::Future<Output = Result<T, SolanaProviderError>>,
283    {
284        let is_retriable = |e: &SolanaProviderError| match e {
285            SolanaProviderError::RpcError(msg) => is_retriable_error(msg),
286            _ => false,
287        };
288
289        log::debug!(
290            "Starting RPC operation '{}' with timeout: {}s",
291            operation_name,
292            self.timeout_seconds.as_secs()
293        );
294
295        retry_rpc_call(
296            &self.selector,
297            operation_name,
298            is_retriable,
299            |_| false, // TODO: implement fn to mark provider failed based on error
300            |url| match self.initialize_provider(url) {
301                Ok(provider) => Ok(provider),
302                Err(e) => Err(e),
303            },
304            operation,
305            Some(self.retry_config.clone()),
306        )
307        .await
308    }
309}
310
311#[async_trait]
312#[allow(dead_code)]
313impl SolanaProviderTrait for SolanaProvider {
314    /// Retrieves the balance (in lamports) for the given address.
315    /// # Errors
316    ///
317    /// Returns `ProviderError::InvalidAddress` if address parsing fails,
318    /// and `ProviderError::RpcError` if the RPC call fails.
319    async fn get_balance(&self, address: &str) -> Result<u64, SolanaProviderError> {
320        let pubkey = Pubkey::from_str(address)
321            .map_err(|e| SolanaProviderError::InvalidAddress(e.to_string()))?;
322
323        self.retry_rpc_call("get_balance", |client| async move {
324            client
325                .get_balance(&pubkey)
326                .await
327                .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
328        })
329        .await
330    }
331
332    /// Check if a blockhash is valid
333    async fn is_blockhash_valid(
334        &self,
335        hash: &Hash,
336        commitment: CommitmentConfig,
337    ) -> Result<bool, SolanaProviderError> {
338        self.retry_rpc_call("is_blockhash_valid", |client| async move {
339            client
340                .is_blockhash_valid(hash, commitment)
341                .await
342                .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
343        })
344        .await
345    }
346
347    /// Gets the latest blockhash.
348    async fn get_latest_blockhash(&self) -> Result<Hash, SolanaProviderError> {
349        self.retry_rpc_call("get_latest_blockhash", |client| async move {
350            client
351                .get_latest_blockhash()
352                .await
353                .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
354        })
355        .await
356    }
357
358    async fn get_latest_blockhash_with_commitment(
359        &self,
360        commitment: CommitmentConfig,
361    ) -> Result<(Hash, u64), SolanaProviderError> {
362        self.retry_rpc_call(
363            "get_latest_blockhash_with_commitment",
364            |client| async move {
365                client
366                    .get_latest_blockhash_with_commitment(commitment)
367                    .await
368                    .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
369            },
370        )
371        .await
372    }
373
374    /// Sends a transaction to the network.
375    async fn send_transaction(
376        &self,
377        transaction: &Transaction,
378    ) -> Result<Signature, SolanaProviderError> {
379        self.retry_rpc_call("send_transaction", |client| async move {
380            client
381                .send_transaction(transaction)
382                .await
383                .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
384        })
385        .await
386    }
387
388    /// Sends a transaction to the network.
389    async fn send_versioned_transaction(
390        &self,
391        transaction: &VersionedTransaction,
392    ) -> Result<Signature, SolanaProviderError> {
393        self.retry_rpc_call("send_transaction", |client| async move {
394            client
395                .send_transaction(transaction)
396                .await
397                .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
398        })
399        .await
400    }
401
402    /// Confirms the given transaction signature.
403    async fn confirm_transaction(
404        &self,
405        signature: &Signature,
406    ) -> Result<bool, SolanaProviderError> {
407        self.retry_rpc_call("confirm_transaction", |client| async move {
408            client
409                .confirm_transaction(signature)
410                .await
411                .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
412        })
413        .await
414    }
415
416    /// Retrieves the minimum balance for rent exemption for the given data size.
417    async fn get_minimum_balance_for_rent_exemption(
418        &self,
419        data_size: usize,
420    ) -> Result<u64, SolanaProviderError> {
421        self.retry_rpc_call(
422            "get_minimum_balance_for_rent_exemption",
423            |client| async move {
424                client
425                    .get_minimum_balance_for_rent_exemption(data_size)
426                    .await
427                    .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
428            },
429        )
430        .await
431    }
432
433    /// Simulate transaction.
434    async fn simulate_transaction(
435        &self,
436        transaction: &Transaction,
437    ) -> Result<RpcSimulateTransactionResult, SolanaProviderError> {
438        self.retry_rpc_call("simulate_transaction", |client| async move {
439            client
440                .simulate_transaction(transaction)
441                .await
442                .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
443                .map(|response| response.value)
444        })
445        .await
446    }
447
448    /// Retrieves account data for the given account string.
449    async fn get_account_from_str(&self, account: &str) -> Result<Account, SolanaProviderError> {
450        let address = Pubkey::from_str(account).map_err(|e| {
451            SolanaProviderError::InvalidAddress(format!("Invalid pubkey {}: {}", account, e))
452        })?;
453        self.retry_rpc_call("get_account", |client| async move {
454            client
455                .get_account(&address)
456                .await
457                .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
458        })
459        .await
460    }
461
462    /// Retrieves account data for the given pubkey.
463    async fn get_account_from_pubkey(
464        &self,
465        pubkey: &Pubkey,
466    ) -> Result<Account, SolanaProviderError> {
467        self.retry_rpc_call("get_account_from_pubkey", |client| async move {
468            client
469                .get_account(pubkey)
470                .await
471                .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
472        })
473        .await
474    }
475
476    /// Retrieves token metadata from a provided mint address.
477    async fn get_token_metadata_from_pubkey(
478        &self,
479        pubkey: &str,
480    ) -> Result<TokenMetadata, SolanaProviderError> {
481        // Retrieve account associated with the given pubkey
482        let account = self.get_account_from_str(pubkey).await.map_err(|e| {
483            SolanaProviderError::RpcError(format!("Failed to fetch account for {}: {}", pubkey, e))
484        })?;
485
486        // Unpack the mint info from the account's data
487        let mint_info = Mint::unpack(&account.data).map_err(|e| {
488            SolanaProviderError::RpcError(format!("Failed to unpack mint info: {}", e))
489        })?;
490        let decimals = mint_info.decimals;
491
492        // Convert provided string into a Pubkey
493        let mint_pubkey = Pubkey::try_from(pubkey).map_err(|e| {
494            SolanaProviderError::RpcError(format!("Invalid pubkey {}: {}", pubkey, e))
495        })?;
496
497        // Derive the PDA for the token metadata
498        let metadata_pda = Metadata::find_pda(&mint_pubkey).0;
499
500        let symbol = match self.get_account_from_pubkey(&metadata_pda).await {
501            Ok(metadata_account) => match Metadata::from_bytes(&metadata_account.data) {
502                Ok(metadata) => metadata.symbol.trim_end_matches('\u{0}').to_string(),
503                Err(_) => String::new(),
504            },
505            Err(_) => String::new(), // Return empty symbol if metadata doesn't exist
506        };
507
508        Ok(TokenMetadata {
509            decimals,
510            symbol,
511            mint: pubkey.to_string(),
512        })
513    }
514
515    /// Get the fee for a message
516    async fn get_fee_for_message(&self, message: &Message) -> Result<u64, SolanaProviderError> {
517        self.retry_rpc_call("get_fee_for_message", |client| async move {
518            client
519                .get_fee_for_message(message)
520                .await
521                .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
522        })
523        .await
524    }
525
526    async fn get_recent_prioritization_fees(
527        &self,
528        addresses: &[Pubkey],
529    ) -> Result<Vec<RpcPrioritizationFee>, SolanaProviderError> {
530        self.retry_rpc_call("get_recent_prioritization_fees", |client| async move {
531            client
532                .get_recent_prioritization_fees(addresses)
533                .await
534                .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
535        })
536        .await
537    }
538
539    async fn calculate_total_fee(&self, message: &Message) -> Result<u64, SolanaProviderError> {
540        let base_fee = self.get_fee_for_message(message).await?;
541        let priority_fees = self.get_recent_prioritization_fees(&[]).await?;
542
543        let max_priority_fee = priority_fees
544            .iter()
545            .map(|fee| fee.prioritization_fee)
546            .max()
547            .unwrap_or(0);
548
549        Ok(base_fee + max_priority_fee)
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use lazy_static::lazy_static;
557    use solana_sdk::{
558        hash::Hash,
559        message::Message,
560        signer::{keypair::Keypair, Signer},
561        transaction::Transaction,
562    };
563    use std::sync::Mutex;
564
565    lazy_static! {
566        static ref EVM_TEST_ENV_MUTEX: Mutex<()> = Mutex::new(());
567    }
568
569    struct EvmTestEnvGuard {
570        _mutex_guard: std::sync::MutexGuard<'static, ()>,
571    }
572
573    impl EvmTestEnvGuard {
574        fn new(mutex_guard: std::sync::MutexGuard<'static, ()>) -> Self {
575            std::env::set_var(
576                "API_KEY",
577                "test_api_key_for_evm_provider_new_this_is_long_enough_32_chars",
578            );
579            std::env::set_var("REDIS_URL", "redis://test-dummy-url-for-evm-provider");
580
581            Self {
582                _mutex_guard: mutex_guard,
583            }
584        }
585    }
586
587    impl Drop for EvmTestEnvGuard {
588        fn drop(&mut self) {
589            std::env::remove_var("API_KEY");
590            std::env::remove_var("REDIS_URL");
591        }
592    }
593
594    // Helper function to set up the test environment
595    fn setup_test_env() -> EvmTestEnvGuard {
596        let guard = EVM_TEST_ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
597        EvmTestEnvGuard::new(guard)
598    }
599
600    fn get_funded_keypair() -> Keypair {
601        // address HCKHoE2jyk1qfAwpHQghvYH3cEfT8euCygBzF9AV6bhY
602        Keypair::from_bytes(&[
603            120, 248, 160, 20, 225, 60, 226, 195, 68, 137, 176, 87, 21, 129, 0, 76, 144, 129, 122,
604            250, 80, 4, 247, 50, 248, 82, 146, 77, 139, 156, 40, 41, 240, 161, 15, 81, 198, 198,
605            86, 167, 90, 148, 131, 13, 184, 222, 251, 71, 229, 212, 169, 2, 72, 202, 150, 184, 176,
606            148, 75, 160, 255, 233, 73, 31,
607        ])
608        .unwrap()
609    }
610
611    // Helper function to obtain a recent blockhash from the provider.
612    async fn get_recent_blockhash(provider: &SolanaProvider) -> Hash {
613        provider
614            .get_latest_blockhash()
615            .await
616            .expect("Failed to get blockhash")
617    }
618
619    fn create_test_rpc_config() -> RpcConfig {
620        RpcConfig {
621            url: "https://api.devnet.solana.com".to_string(),
622            weight: 1,
623        }
624    }
625
626    #[tokio::test]
627    async fn test_new_with_valid_config() {
628        let _env_guard = setup_test_env();
629        let configs = vec![create_test_rpc_config()];
630        let timeout = 30;
631
632        let result = SolanaProvider::new(configs, timeout);
633
634        assert!(result.is_ok());
635        let provider = result.unwrap();
636        assert_eq!(provider.timeout_seconds, Duration::from_secs(timeout));
637        assert_eq!(provider.commitment, CommitmentConfig::confirmed());
638    }
639
640    #[tokio::test]
641    async fn test_new_with_commitment_valid_config() {
642        let _env_guard = setup_test_env();
643
644        let configs = vec![create_test_rpc_config()];
645        let timeout = 30;
646        let commitment = CommitmentConfig::finalized();
647
648        let result = SolanaProvider::new_with_commitment(configs, timeout, commitment);
649
650        assert!(result.is_ok());
651        let provider = result.unwrap();
652        assert_eq!(provider.timeout_seconds, Duration::from_secs(timeout));
653        assert_eq!(provider.commitment, commitment);
654    }
655
656    #[tokio::test]
657    async fn test_new_with_empty_configs() {
658        let _env_guard = setup_test_env();
659        let configs: Vec<RpcConfig> = vec![];
660        let timeout = 30;
661
662        let result = SolanaProvider::new(configs, timeout);
663
664        assert!(result.is_err());
665        assert!(matches!(
666            result,
667            Err(ProviderError::NetworkConfiguration(_))
668        ));
669    }
670
671    #[tokio::test]
672    async fn test_new_with_commitment_empty_configs() {
673        let _env_guard = setup_test_env();
674        let configs: Vec<RpcConfig> = vec![];
675        let timeout = 30;
676        let commitment = CommitmentConfig::finalized();
677
678        let result = SolanaProvider::new_with_commitment(configs, timeout, commitment);
679
680        assert!(result.is_err());
681        assert!(matches!(
682            result,
683            Err(ProviderError::NetworkConfiguration(_))
684        ));
685    }
686
687    #[tokio::test]
688    async fn test_new_with_invalid_url() {
689        let _env_guard = setup_test_env();
690        let configs = vec![RpcConfig {
691            url: "invalid-url".to_string(),
692            weight: 1,
693        }];
694        let timeout = 30;
695
696        let result = SolanaProvider::new(configs, timeout);
697
698        assert!(result.is_err());
699        assert!(matches!(
700            result,
701            Err(ProviderError::NetworkConfiguration(_))
702        ));
703    }
704
705    #[tokio::test]
706    async fn test_new_with_commitment_invalid_url() {
707        let _env_guard = setup_test_env();
708        let configs = vec![RpcConfig {
709            url: "invalid-url".to_string(),
710            weight: 1,
711        }];
712        let timeout = 30;
713        let commitment = CommitmentConfig::finalized();
714
715        let result = SolanaProvider::new_with_commitment(configs, timeout, commitment);
716
717        assert!(result.is_err());
718        assert!(matches!(
719            result,
720            Err(ProviderError::NetworkConfiguration(_))
721        ));
722    }
723
724    #[tokio::test]
725    async fn test_new_with_multiple_configs() {
726        let _env_guard = setup_test_env();
727        let configs = vec![
728            create_test_rpc_config(),
729            RpcConfig {
730                url: "https://api.mainnet-beta.solana.com".to_string(),
731                weight: 1,
732            },
733        ];
734        let timeout = 30;
735
736        let result = SolanaProvider::new(configs, timeout);
737
738        assert!(result.is_ok());
739    }
740
741    #[tokio::test]
742    async fn test_provider_creation() {
743        let _env_guard = setup_test_env();
744        let configs = vec![create_test_rpc_config()];
745        let timeout = 30;
746        let provider = SolanaProvider::new(configs, timeout);
747        assert!(provider.is_ok());
748    }
749
750    #[tokio::test]
751    async fn test_get_balance() {
752        let _env_guard = setup_test_env();
753        let configs = vec![create_test_rpc_config()];
754        let timeout = 30;
755        let provider = SolanaProvider::new(configs, timeout).unwrap();
756        let keypair = Keypair::new();
757        let balance = provider.get_balance(&keypair.pubkey().to_string()).await;
758        assert!(balance.is_ok());
759        assert_eq!(balance.unwrap(), 0);
760    }
761
762    #[tokio::test]
763    async fn test_get_balance_funded_account() {
764        let _env_guard = setup_test_env();
765        let configs = vec![create_test_rpc_config()];
766        let timeout = 30;
767        let provider = SolanaProvider::new(configs, timeout).unwrap();
768        let keypair = get_funded_keypair();
769        let balance = provider.get_balance(&keypair.pubkey().to_string()).await;
770        assert!(balance.is_ok());
771        assert_eq!(balance.unwrap(), 1000000000);
772    }
773
774    #[tokio::test]
775    async fn test_get_latest_blockhash() {
776        let _env_guard = setup_test_env();
777        let configs = vec![create_test_rpc_config()];
778        let timeout = 30;
779        let provider = SolanaProvider::new(configs, timeout).unwrap();
780        let blockhash = provider.get_latest_blockhash().await;
781        assert!(blockhash.is_ok());
782    }
783
784    #[tokio::test]
785    async fn test_simulate_transaction() {
786        let _env_guard = setup_test_env();
787        let configs = vec![create_test_rpc_config()];
788        let timeout = 30;
789        let provider = SolanaProvider::new(configs, timeout).expect("Failed to create provider");
790
791        let fee_payer = get_funded_keypair();
792
793        // Construct a message with no instructions (a no-op transaction).
794        // Note: An empty instruction set is acceptable for simulation purposes.
795        let message = Message::new(&[], Some(&fee_payer.pubkey()));
796
797        let mut tx = Transaction::new_unsigned(message);
798
799        let recent_blockhash = get_recent_blockhash(&provider).await;
800        tx.try_sign(&[&fee_payer], recent_blockhash)
801            .expect("Failed to sign transaction");
802
803        let simulation_result = provider.simulate_transaction(&tx).await;
804
805        assert!(
806            simulation_result.is_ok(),
807            "Simulation failed: {:?}",
808            simulation_result
809        );
810
811        let result = simulation_result.unwrap();
812        // The simulation result may contain logs or an error field.
813        // For a no-op transaction, we expect no errors and possibly empty logs.
814        assert!(
815            result.err.is_none(),
816            "Simulation encountered an error: {:?}",
817            result.err
818        );
819    }
820
821    #[tokio::test]
822    async fn test_get_token_metadata_from_pubkey() {
823        let _env_guard = setup_test_env();
824        let configs = vec![RpcConfig {
825            url: "https://api.mainnet-beta.solana.com".to_string(),
826            weight: 1,
827        }];
828        let timeout = 30;
829        let provider = SolanaProvider::new(configs, timeout).unwrap();
830        let usdc_token_metadata = provider
831            .get_token_metadata_from_pubkey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
832            .await
833            .unwrap();
834
835        assert_eq!(
836            usdc_token_metadata,
837            TokenMetadata {
838                decimals: 6,
839                symbol: "USDC".to_string(),
840                mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
841            }
842        );
843
844        let usdt_token_metadata = provider
845            .get_token_metadata_from_pubkey("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB")
846            .await
847            .unwrap();
848
849        assert_eq!(
850            usdt_token_metadata,
851            TokenMetadata {
852                decimals: 6,
853                symbol: "USDT".to_string(),
854                mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(),
855            }
856        );
857    }
858
859    #[tokio::test]
860    async fn test_get_client_success() {
861        let _env_guard = setup_test_env();
862        let configs = vec![create_test_rpc_config()];
863        let timeout = 30;
864        let provider = SolanaProvider::new(configs, timeout).unwrap();
865
866        let client = provider.get_client();
867        assert!(client.is_ok());
868
869        let client = client.unwrap();
870        let health_result = client.get_health().await;
871        assert!(health_result.is_ok());
872    }
873
874    #[tokio::test]
875    async fn test_get_client_with_custom_commitment() {
876        let _env_guard = setup_test_env();
877        let configs = vec![create_test_rpc_config()];
878        let timeout = 30;
879        let commitment = CommitmentConfig::finalized();
880
881        let provider = SolanaProvider::new_with_commitment(configs, timeout, commitment).unwrap();
882
883        let client = provider.get_client();
884        assert!(client.is_ok());
885
886        let client = client.unwrap();
887        let health_result = client.get_health().await;
888        assert!(health_result.is_ok());
889    }
890
891    #[tokio::test]
892    async fn test_get_client_with_multiple_rpcs() {
893        let _env_guard = setup_test_env();
894        let configs = vec![
895            create_test_rpc_config(),
896            RpcConfig {
897                url: "https://api.mainnet-beta.solana.com".to_string(),
898                weight: 2,
899            },
900        ];
901        let timeout = 30;
902
903        let provider = SolanaProvider::new(configs, timeout).unwrap();
904
905        let client_result = provider.get_client();
906        assert!(client_result.is_ok());
907
908        // Call multiple times to exercise the selection logic
909        for _ in 0..5 {
910            let client = provider.get_client();
911            assert!(client.is_ok());
912        }
913    }
914
915    #[test]
916    fn test_initialize_provider_valid_url() {
917        let _env_guard = setup_test_env();
918
919        let configs = vec![RpcConfig {
920            url: "https://api.devnet.solana.com".to_string(),
921            weight: 1,
922        }];
923        let provider = SolanaProvider::new(configs, 10).unwrap();
924        let result = provider.initialize_provider("https://api.devnet.solana.com");
925        assert!(result.is_ok());
926        let arc_client = result.unwrap();
927        // Arc pointer should not be null and should point to RpcClient
928        let _client: &RpcClient = Arc::as_ref(&arc_client);
929    }
930
931    #[test]
932    fn test_initialize_provider_invalid_url() {
933        let _env_guard = setup_test_env();
934
935        let configs = vec![RpcConfig {
936            url: "https://api.devnet.solana.com".to_string(),
937            weight: 1,
938        }];
939        let provider = SolanaProvider::new(configs, 10).unwrap();
940        let result = provider.initialize_provider("not-a-valid-url");
941        assert!(result.is_err());
942        match result {
943            Err(SolanaProviderError::NetworkConfiguration(msg)) => {
944                assert!(msg.contains("Invalid URL format"))
945            }
946            _ => panic!("Expected NetworkConfiguration error"),
947        }
948    }
949
950    #[test]
951    fn test_from_string_for_solana_provider_error() {
952        let msg = "some rpc error".to_string();
953        let err: SolanaProviderError = msg.clone().into();
954        match err {
955            SolanaProviderError::RpcError(inner) => assert_eq!(inner, msg),
956            _ => panic!("Expected RpcError variant"),
957        }
958    }
959
960    #[test]
961    fn test_is_retriable_error_true() {
962        for msg in RETRIABLE_ERROR_SUBSTRINGS {
963            assert!(is_retriable_error(msg), "Should be retriable: {}", msg);
964        }
965    }
966
967    #[test]
968    fn test_is_retriable_error_false() {
969        let non_retriable_cases = [
970            "account not found",
971            "invalid signature",
972            "insufficient funds",
973            "unknown error",
974        ];
975        for msg in non_retriable_cases {
976            assert!(!is_retriable_error(msg), "Should NOT be retriable: {}", msg);
977        }
978    }
979
980    #[tokio::test]
981    async fn test_get_minimum_balance_for_rent_exemption() {
982        let _env_guard = super::tests::setup_test_env();
983        let configs = vec![super::tests::create_test_rpc_config()];
984        let timeout = 30;
985        let provider = SolanaProvider::new(configs, timeout).unwrap();
986
987        // 0 bytes is always valid, should return a value >= 0
988        let result = provider.get_minimum_balance_for_rent_exemption(0).await;
989        assert!(result.is_ok());
990    }
991
992    #[tokio::test]
993    async fn test_is_blockhash_valid_for_recent_blockhash() {
994        let _env_guard = super::tests::setup_test_env();
995        let configs = vec![super::tests::create_test_rpc_config()];
996        let timeout = 30;
997        let provider = SolanaProvider::new(configs, timeout).unwrap();
998
999        // Get a recent blockhash (should be valid)
1000        let blockhash = provider.get_latest_blockhash().await.unwrap();
1001        let is_valid = provider
1002            .is_blockhash_valid(&blockhash, CommitmentConfig::confirmed())
1003            .await;
1004        assert!(is_valid.is_ok());
1005    }
1006
1007    #[tokio::test]
1008    async fn test_is_blockhash_valid_for_invalid_blockhash() {
1009        let _env_guard = super::tests::setup_test_env();
1010        let configs = vec![super::tests::create_test_rpc_config()];
1011        let timeout = 30;
1012        let provider = SolanaProvider::new(configs, timeout).unwrap();
1013
1014        let invalid_blockhash = solana_sdk::hash::Hash::new_from_array([0u8; 32]);
1015        let is_valid = provider
1016            .is_blockhash_valid(&invalid_blockhash, CommitmentConfig::confirmed())
1017            .await;
1018        assert!(is_valid.is_ok());
1019    }
1020
1021    #[tokio::test]
1022    async fn test_get_latest_blockhash_with_commitment() {
1023        let _env_guard = super::tests::setup_test_env();
1024        let configs = vec![super::tests::create_test_rpc_config()];
1025        let timeout = 30;
1026        let provider = SolanaProvider::new(configs, timeout).unwrap();
1027
1028        let commitment = CommitmentConfig::confirmed();
1029        let result = provider
1030            .get_latest_blockhash_with_commitment(commitment)
1031            .await;
1032        assert!(result.is_ok());
1033        let (blockhash, last_valid_block_height) = result.unwrap();
1034        // Blockhash should not be all zeros and block height should be > 0
1035        assert_ne!(blockhash, solana_sdk::hash::Hash::new_from_array([0u8; 32]));
1036        assert!(last_valid_block_height > 0);
1037    }
1038}