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