openzeppelin_relayer/services/provider/
mod.rs

1use std::num::ParseIntError;
2use std::time::Duration;
3
4use once_cell::sync::Lazy;
5use reqwest::Client as ReqwestClient;
6use tracing::debug;
7
8use crate::config::ServerConfig;
9use crate::constants::{
10    DEFAULT_HTTP_CLIENT_CONNECT_TIMEOUT_SECONDS,
11    DEFAULT_HTTP_CLIENT_HTTP2_KEEP_ALIVE_INTERVAL_SECONDS,
12    DEFAULT_HTTP_CLIENT_HTTP2_KEEP_ALIVE_TIMEOUT_SECONDS,
13    DEFAULT_HTTP_CLIENT_POOL_IDLE_TIMEOUT_SECONDS, DEFAULT_HTTP_CLIENT_POOL_MAX_IDLE_PER_HOST,
14    DEFAULT_HTTP_CLIENT_TCP_KEEPALIVE_SECONDS,
15};
16use crate::models::{EvmNetwork, RpcConfig, SolanaNetwork, StellarNetwork};
17use crate::utils::create_secure_redirect_policy;
18use serde::Serialize;
19use thiserror::Error;
20
21use alloy::transports::RpcError;
22
23pub mod evm;
24pub use evm::*;
25
26mod solana;
27pub use solana::*;
28
29mod stellar;
30pub use stellar::*;
31
32mod retry;
33pub use retry::*;
34
35pub mod rpc_health_store;
36pub mod rpc_selector;
37
38pub use rpc_health_store::{RpcConfigMetadata, RpcHealthStore};
39
40/// Configuration for creating a provider instance.
41///
42/// This struct encapsulates all the parameters needed to create a provider,
43/// making the API cleaner and easier to maintain.
44#[derive(Debug, Clone)]
45pub struct ProviderConfig {
46    /// RPC endpoint configurations (URLs and weights)
47    pub rpc_configs: Vec<RpcConfig>,
48    /// Timeout duration in seconds for RPC requests
49    pub timeout_seconds: u64,
50    /// Number of consecutive failures before pausing a provider
51    pub failure_threshold: u32,
52    /// Duration in seconds to pause a provider after reaching failure threshold
53    pub pause_duration_secs: u64,
54    /// Duration in seconds after which failures are considered stale and reset
55    pub failure_expiration_secs: u64,
56}
57
58impl ProviderConfig {
59    /// Creates a new `ProviderConfig` from individual parameters.
60    ///
61    /// # Arguments
62    /// * `rpc_configs` - RPC endpoint configurations
63    /// * `timeout_seconds` - Timeout duration in seconds
64    /// * `failure_threshold` - Number of consecutive failures before pausing
65    /// * `pause_duration_secs` - Duration in seconds to pause after threshold
66    /// * `failure_expiration_secs` - Duration in seconds after which failures are considered stale
67    pub fn new(
68        rpc_configs: Vec<RpcConfig>,
69        timeout_seconds: u64,
70        failure_threshold: u32,
71        pause_duration_secs: u64,
72        failure_expiration_secs: u64,
73    ) -> Self {
74        Self {
75            rpc_configs,
76            timeout_seconds,
77            failure_threshold,
78            pause_duration_secs,
79            failure_expiration_secs,
80        }
81    }
82
83    /// Creates a `ProviderConfig` from `ServerConfig` with the given RPC configs.
84    ///
85    /// This is a convenience method that extracts provider-related configuration
86    /// from the server configuration.
87    ///
88    /// # Arguments
89    /// * `server_config` - The server configuration
90    /// * `rpc_configs` - RPC endpoint configurations
91    pub fn from_server_config(server_config: &ServerConfig, rpc_configs: Vec<RpcConfig>) -> Self {
92        let timeout_seconds = server_config.rpc_timeout_ms / 1000; // Convert ms to s
93        Self {
94            rpc_configs,
95            timeout_seconds,
96            failure_threshold: server_config.provider_failure_threshold,
97            pause_duration_secs: server_config.provider_pause_duration_secs,
98            failure_expiration_secs: server_config.provider_failure_expiration_secs,
99        }
100    }
101
102    /// Creates a `ProviderConfig` from environment variables with the given RPC configs.
103    ///
104    /// This loads configuration from `ServerConfig::from_env()`.
105    ///
106    /// # Arguments
107    /// * `rpc_configs` - RPC endpoint configurations
108    pub fn from_env(rpc_configs: Vec<RpcConfig>) -> Self {
109        let server_config = ServerConfig::from_env();
110        Self::from_server_config(&server_config, rpc_configs)
111    }
112}
113
114/// Pre-configured `reqwest::ClientBuilder` with standard pool, keepalive, TLS,
115/// and redirect settings. Callers chain on extras (e.g., `.timeout(...)`) then `.build()`.
116fn base_rpc_client_builder() -> reqwest::ClientBuilder {
117    ReqwestClient::builder()
118        .connect_timeout(Duration::from_secs(
119            DEFAULT_HTTP_CLIENT_CONNECT_TIMEOUT_SECONDS,
120        ))
121        .pool_max_idle_per_host(DEFAULT_HTTP_CLIENT_POOL_MAX_IDLE_PER_HOST)
122        .pool_idle_timeout(Duration::from_secs(
123            DEFAULT_HTTP_CLIENT_POOL_IDLE_TIMEOUT_SECONDS,
124        ))
125        .tcp_keepalive(Duration::from_secs(
126            DEFAULT_HTTP_CLIENT_TCP_KEEPALIVE_SECONDS,
127        ))
128        .http2_keep_alive_interval(Some(Duration::from_secs(
129            DEFAULT_HTTP_CLIENT_HTTP2_KEEP_ALIVE_INTERVAL_SECONDS,
130        )))
131        .http2_keep_alive_timeout(Duration::from_secs(
132            DEFAULT_HTTP_CLIENT_HTTP2_KEEP_ALIVE_TIMEOUT_SECONDS,
133        ))
134        .use_rustls_tls()
135        .redirect(create_secure_redirect_policy())
136}
137
138/// Shared `reqwest::Client` for RPC providers that set per-request timeouts
139/// (e.g., Stellar raw HTTP). No request-level timeout is baked in.
140static SHARED_RPC_HTTP_CLIENT: Lazy<Result<ReqwestClient, String>> = Lazy::new(|| {
141    debug!("Creating shared RPC HTTP client");
142    base_rpc_client_builder()
143        .build()
144        .map_err(|e| format!("Failed to create shared RPC HTTP client: {e}"))
145});
146
147/// Get the shared RPC HTTP client (no per-request timeout).
148pub fn get_shared_rpc_http_client() -> Result<ReqwestClient, ProviderError> {
149    SHARED_RPC_HTTP_CLIENT
150        .as_ref()
151        .map(|c| c.clone())
152        .map_err(|e| ProviderError::NetworkConfiguration(e.clone()))
153}
154
155/// Build a new RPC HTTP client with standard settings plus a per-request timeout.
156/// Use when the provider needs timeouts baked into the client (e.g., EVM via alloy transport).
157pub fn build_rpc_http_client_with_timeout(
158    timeout: Duration,
159) -> Result<ReqwestClient, ProviderError> {
160    base_rpc_client_builder()
161        .timeout(timeout)
162        .build()
163        .map_err(|e| {
164            ProviderError::NetworkConfiguration(format!("Failed to build RPC HTTP client: {e}"))
165        })
166}
167
168#[derive(Error, Debug, Serialize)]
169pub enum ProviderError {
170    #[error("RPC client error: {0}")]
171    SolanaRpcError(#[from] SolanaProviderError),
172    #[error("Invalid address: {0}")]
173    InvalidAddress(String),
174    #[error("Network configuration error: {0}")]
175    NetworkConfiguration(String),
176    #[error("Request timeout")]
177    Timeout,
178    #[error("Rate limited (HTTP 429)")]
179    RateLimited,
180    #[error("Bad gateway (HTTP 502)")]
181    BadGateway,
182    #[error("Request error (HTTP {status_code}): {error}")]
183    RequestError { error: String, status_code: u16 },
184    #[error("JSON-RPC error (code {code}): {message}")]
185    RpcErrorCode { code: i64, message: String },
186    #[error("Transport error: {0}")]
187    TransportError(String),
188    #[error("Other provider error: {0}")]
189    Other(String),
190}
191
192impl ProviderError {
193    /// Determines if this error is transient (can retry) or permanent (should fail).
194    pub fn is_transient(&self) -> bool {
195        is_retriable_error(self)
196    }
197}
198
199impl From<hex::FromHexError> for ProviderError {
200    fn from(err: hex::FromHexError) -> Self {
201        ProviderError::InvalidAddress(err.to_string())
202    }
203}
204
205impl From<std::net::AddrParseError> for ProviderError {
206    fn from(err: std::net::AddrParseError) -> Self {
207        ProviderError::NetworkConfiguration(format!("Invalid network address: {err}"))
208    }
209}
210
211impl From<ParseIntError> for ProviderError {
212    fn from(err: ParseIntError) -> Self {
213        ProviderError::Other(format!("Number parsing error: {err}"))
214    }
215}
216
217/// Categorizes a reqwest error into an appropriate `ProviderError` variant.
218///
219/// This function analyzes the given reqwest error and maps it to a specific
220/// `ProviderError` variant based on the error's properties:
221/// - Timeout errors become `ProviderError::Timeout`
222/// - HTTP 429 responses become `ProviderError::RateLimited`
223/// - HTTP 502 responses become `ProviderError::BadGateway`
224/// - All other errors become `ProviderError::Other` with the error message
225///
226/// # Arguments
227///
228/// * `err` - A reference to the reqwest error to categorize
229///
230/// # Returns
231///
232/// The appropriate `ProviderError` variant based on the error type
233fn categorize_reqwest_error(err: &reqwest::Error) -> ProviderError {
234    if err.is_timeout() {
235        return ProviderError::Timeout;
236    }
237
238    if let Some(status) = err.status() {
239        match status.as_u16() {
240            429 => return ProviderError::RateLimited,
241            502 => return ProviderError::BadGateway,
242            _ => {
243                return ProviderError::RequestError {
244                    error: err.to_string(),
245                    status_code: status.as_u16(),
246                }
247            }
248        }
249    }
250
251    ProviderError::Other(err.to_string())
252}
253
254impl From<reqwest::Error> for ProviderError {
255    fn from(err: reqwest::Error) -> Self {
256        categorize_reqwest_error(&err)
257    }
258}
259
260impl From<&reqwest::Error> for ProviderError {
261    fn from(err: &reqwest::Error) -> Self {
262        categorize_reqwest_error(err)
263    }
264}
265
266impl From<eyre::Report> for ProviderError {
267    fn from(err: eyre::Report) -> Self {
268        // Downcast to known error types first
269        if let Some(reqwest_err) = err.downcast_ref::<reqwest::Error>() {
270            return ProviderError::from(reqwest_err);
271        }
272
273        // Default to Other for unknown error types
274        ProviderError::Other(err.to_string())
275    }
276}
277
278// Add conversion from String to ProviderError
279impl From<String> for ProviderError {
280    fn from(error: String) -> Self {
281        ProviderError::Other(error)
282    }
283}
284
285// Generic implementation for all RpcError types
286impl<E> From<RpcError<E>> for ProviderError
287where
288    E: std::fmt::Display + std::any::Any + 'static,
289{
290    fn from(err: RpcError<E>) -> Self {
291        match err {
292            RpcError::Transport(transport_err) => {
293                // First check if it's a reqwest::Error using downcasting
294                if let Some(reqwest_err) =
295                    (&transport_err as &dyn std::any::Any).downcast_ref::<reqwest::Error>()
296                {
297                    return categorize_reqwest_error(reqwest_err);
298                }
299
300                ProviderError::TransportError(transport_err.to_string())
301            }
302            RpcError::ErrorResp(json_rpc_err) => ProviderError::RpcErrorCode {
303                code: json_rpc_err.code,
304                message: json_rpc_err.message.to_string(),
305            },
306            _ => ProviderError::Other(format!("Other RPC error: {err}")),
307        }
308    }
309}
310
311// Implement From for RpcSelectorError
312impl From<rpc_selector::RpcSelectorError> for ProviderError {
313    fn from(err: rpc_selector::RpcSelectorError) -> Self {
314        ProviderError::NetworkConfiguration(format!("RPC selector error: {err}"))
315    }
316}
317
318pub trait NetworkConfiguration: Sized {
319    type Provider;
320
321    fn public_rpc_urls(&self) -> Vec<RpcConfig>;
322
323    /// Creates a new provider instance using the provided configuration.
324    ///
325    /// # Arguments
326    /// * `config` - Provider configuration containing RPC configs and settings
327    fn new_provider(config: ProviderConfig) -> Result<Self::Provider, ProviderError>;
328}
329
330impl NetworkConfiguration for EvmNetwork {
331    type Provider = EvmProvider;
332
333    fn public_rpc_urls(&self) -> Vec<RpcConfig> {
334        self.rpc_urls.clone()
335    }
336
337    fn new_provider(config: ProviderConfig) -> Result<Self::Provider, ProviderError> {
338        EvmProvider::new(config)
339    }
340}
341
342impl NetworkConfiguration for SolanaNetwork {
343    type Provider = SolanaProvider;
344
345    fn public_rpc_urls(&self) -> Vec<RpcConfig> {
346        self.rpc_urls.clone()
347    }
348
349    fn new_provider(config: ProviderConfig) -> Result<Self::Provider, ProviderError> {
350        SolanaProvider::new(config)
351    }
352}
353
354impl NetworkConfiguration for StellarNetwork {
355    type Provider = StellarProvider;
356
357    fn public_rpc_urls(&self) -> Vec<RpcConfig> {
358        self.rpc_urls.clone()
359    }
360
361    fn new_provider(config: ProviderConfig) -> Result<Self::Provider, ProviderError> {
362        StellarProvider::new(config)
363    }
364}
365
366/// Creates a network-specific provider instance based on the provided configuration.
367///
368/// # Type Parameters
369///
370/// * `N`: The type of the network, which must implement the `NetworkConfiguration` trait.
371///   This determines the specific provider type (`N::Provider`) and how to obtain
372///   public RPC URLs.
373///
374/// # Arguments
375///
376/// * `network`: A reference to the network configuration object (`&N`).
377/// * `custom_rpc_urls`: An `Option<Vec<RpcConfig>>`. If `Some` and not empty, these URLs
378///   are used to configure the provider. If `None` or `Some` but empty, the function
379///   falls back to using the public RPC URLs defined by the `network`'s
380///   `NetworkConfiguration` implementation.
381///
382/// # Returns
383///
384/// * `Ok(N::Provider)`: An instance of the network-specific provider on success.
385/// * `Err(ProviderError)`: An error if configuration fails, such as when no custom URLs
386///   are provided and the network has no public RPC URLs defined
387///   (`ProviderError::NetworkConfiguration`).
388pub fn get_network_provider<N: NetworkConfiguration>(
389    network: &N,
390    custom_rpc_urls: Option<Vec<RpcConfig>>,
391) -> Result<N::Provider, ProviderError> {
392    let rpc_urls = match custom_rpc_urls {
393        Some(configs) if !configs.is_empty() => configs,
394        _ => {
395            let configs = network.public_rpc_urls();
396            if configs.is_empty() {
397                return Err(ProviderError::NetworkConfiguration(
398                    "No public RPC URLs available for this network".to_string(),
399                ));
400            }
401            configs
402        }
403    };
404
405    let provider_config = ProviderConfig::from_env(rpc_urls);
406    N::new_provider(provider_config)
407}
408
409/// Determines if an HTTP status code indicates the provider should be marked as failed.
410///
411/// This is a low-level function that can be reused across different error types.
412///
413/// Returns `true` for:
414/// - 5xx Server Errors (500-599) - RPC node is having issues
415/// - Specific 4xx Client Errors that indicate provider issues:
416///   - 401 (Unauthorized) - auth required but not provided
417///   - 403 (Forbidden) - node is blocking requests or auth issues
418///   - 404 (Not Found) - endpoint doesn't exist or misconfigured
419///   - 410 (Gone) - endpoint permanently removed
420pub fn should_mark_provider_failed_by_status_code(status_code: u16) -> bool {
421    match status_code {
422        // 5xx Server Errors - RPC node is having issues
423        500..=599 => true,
424
425        // 4xx Client Errors that indicate we can't use this provider
426        401 => true, // Unauthorized - auth required but not provided
427        403 => true, // Forbidden - node is blocking requests or auth issues
428        404 => true, // Not Found - endpoint doesn't exist or misconfigured
429        410 => true, // Gone - endpoint permanently removed
430
431        _ => false,
432    }
433}
434
435pub fn should_mark_provider_failed(error: &ProviderError) -> bool {
436    match error {
437        ProviderError::RequestError { status_code, .. } => {
438            should_mark_provider_failed_by_status_code(*status_code)
439        }
440        _ => false,
441    }
442}
443
444// Errors that are retriable
445pub fn is_retriable_error(error: &ProviderError) -> bool {
446    match error {
447        // HTTP-level errors that are retriable
448        ProviderError::Timeout
449        | ProviderError::RateLimited
450        | ProviderError::BadGateway
451        | ProviderError::TransportError(_) => true,
452
453        ProviderError::RequestError { status_code, .. } => {
454            match *status_code {
455                // Non-retriable 5xx: persistent server-side issues
456                501 | 505 => false, // Not Implemented, HTTP Version Not Supported
457
458                // Retriable 5xx: temporary server-side issues
459                500 | 502..=504 | 506..=599 => true,
460
461                // Retriable 4xx: timeout or rate-limit related
462                408 | 425 | 429 => true,
463
464                // Non-retriable 4xx: client errors
465                400..=499 => false,
466
467                // Other status codes: not retriable
468                _ => false,
469            }
470        }
471
472        // JSON-RPC error codes (EIP-1474)
473        ProviderError::RpcErrorCode { code, .. } => {
474            match code {
475                // -32002: Resource unavailable (temporary state)
476                -32002 => true,
477                // -32005: Limit exceeded / rate limited
478                -32005 => true,
479                // -32603: Internal error (may be temporary)
480                -32603 => true,
481                // -32000: Invalid input
482                -32000 => false,
483                // -32001: Resource not found
484                -32001 => false,
485                // -32003: Transaction rejected
486                -32003 => false,
487                // -32004: Method not supported
488                -32004 => false,
489
490                // Standard JSON-RPC 2.0 errors (not retriable)
491                // -32700: Parse error
492                // -32600: Invalid request
493                // -32601: Method not found
494                // -32602: Invalid params
495                -32700..=-32600 => false,
496
497                // All other error codes: not retriable by default
498                _ => false,
499            }
500        }
501
502        ProviderError::SolanaRpcError(err) => err.is_transient(),
503
504        // Any other errors: check message for network-related issues
505        _ => {
506            let err_msg = format!("{error}");
507            let msg_lower = err_msg.to_lowercase();
508            msg_lower.contains("timeout")
509                || msg_lower.contains("connection")
510                || msg_lower.contains("reset")
511        }
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use lazy_static::lazy_static;
519    use std::env;
520    use std::sync::Mutex;
521    use std::time::Duration;
522
523    // Use a mutex to ensure tests don't run in parallel when modifying env vars
524    lazy_static! {
525        static ref ENV_MUTEX: Mutex<()> = Mutex::new(());
526    }
527
528    fn setup_test_env() {
529        env::set_var("API_KEY", "7EF1CB7C-5003-4696-B384-C72AF8C3E15D"); // noboost
530        env::set_var("REDIS_URL", "redis://localhost:6379");
531        env::set_var("RPC_TIMEOUT_MS", "5000");
532    }
533
534    fn cleanup_test_env() {
535        env::remove_var("API_KEY");
536        env::remove_var("REDIS_URL");
537        env::remove_var("RPC_TIMEOUT_MS");
538    }
539
540    fn create_test_evm_network() -> EvmNetwork {
541        EvmNetwork {
542            network: "test-evm".to_string(),
543            rpc_urls: vec![RpcConfig::new("https://rpc.example.com".to_string())],
544            explorer_urls: None,
545            average_blocktime_ms: 12000,
546            is_testnet: true,
547            tags: vec![],
548            chain_id: 1337,
549            required_confirmations: 1,
550            features: vec![],
551            symbol: "ETH".to_string(),
552            gas_price_cache: None,
553        }
554    }
555
556    fn create_test_solana_network(network_str: &str) -> SolanaNetwork {
557        SolanaNetwork {
558            network: network_str.to_string(),
559            rpc_urls: vec![RpcConfig::new("https://api.testnet.solana.com".to_string())],
560            explorer_urls: None,
561            average_blocktime_ms: 400,
562            is_testnet: true,
563            tags: vec![],
564        }
565    }
566
567    fn create_test_stellar_network() -> StellarNetwork {
568        StellarNetwork {
569            network: "testnet".to_string(),
570            rpc_urls: vec![RpcConfig::new(
571                "https://soroban-testnet.stellar.org".to_string(),
572            )],
573            explorer_urls: None,
574            average_blocktime_ms: 5000,
575            is_testnet: true,
576            tags: vec![],
577            passphrase: "Test SDF Network ; September 2015".to_string(),
578            horizon_url: Some("https://horizon-testnet.stellar.org".to_string()),
579        }
580    }
581
582    #[test]
583    fn test_from_hex_error() {
584        let hex_error = hex::FromHexError::OddLength;
585        let provider_error: ProviderError = hex_error.into();
586        assert!(matches!(provider_error, ProviderError::InvalidAddress(_)));
587    }
588
589    #[test]
590    fn test_from_addr_parse_error() {
591        let addr_error = "invalid:address"
592            .parse::<std::net::SocketAddr>()
593            .unwrap_err();
594        let provider_error: ProviderError = addr_error.into();
595        assert!(matches!(
596            provider_error,
597            ProviderError::NetworkConfiguration(_)
598        ));
599    }
600
601    #[test]
602    fn test_from_parse_int_error() {
603        let parse_error = "not_a_number".parse::<u64>().unwrap_err();
604        let provider_error: ProviderError = parse_error.into();
605        assert!(matches!(provider_error, ProviderError::Other(_)));
606    }
607
608    #[actix_rt::test]
609    async fn test_categorize_reqwest_error_timeout() {
610        let client = reqwest::Client::new();
611        let timeout_err = client
612            .get("http://example.com")
613            .timeout(Duration::from_nanos(1))
614            .send()
615            .await
616            .unwrap_err();
617
618        assert!(timeout_err.is_timeout());
619
620        let provider_error = categorize_reqwest_error(&timeout_err);
621        assert!(matches!(provider_error, ProviderError::Timeout));
622    }
623
624    #[actix_rt::test]
625    async fn test_categorize_reqwest_error_rate_limited() {
626        let mut mock_server = mockito::Server::new_async().await;
627
628        let _mock = mock_server
629            .mock("GET", mockito::Matcher::Any)
630            .with_status(429)
631            .create_async()
632            .await;
633
634        let client = reqwest::Client::new();
635        let response = client
636            .get(mock_server.url())
637            .send()
638            .await
639            .expect("Failed to get response");
640
641        let err = response
642            .error_for_status()
643            .expect_err("Expected error for status 429");
644
645        assert!(err.status().is_some());
646        assert_eq!(err.status().unwrap().as_u16(), 429);
647
648        let provider_error = categorize_reqwest_error(&err);
649        assert!(matches!(provider_error, ProviderError::RateLimited));
650    }
651
652    #[actix_rt::test]
653    async fn test_categorize_reqwest_error_bad_gateway() {
654        let mut mock_server = mockito::Server::new_async().await;
655
656        let _mock = mock_server
657            .mock("GET", mockito::Matcher::Any)
658            .with_status(502)
659            .create_async()
660            .await;
661
662        let client = reqwest::Client::new();
663        let response = client
664            .get(mock_server.url())
665            .send()
666            .await
667            .expect("Failed to get response");
668
669        let err = response
670            .error_for_status()
671            .expect_err("Expected error for status 502");
672
673        assert!(err.status().is_some());
674        assert_eq!(err.status().unwrap().as_u16(), 502);
675
676        let provider_error = categorize_reqwest_error(&err);
677        assert!(matches!(provider_error, ProviderError::BadGateway));
678    }
679
680    #[actix_rt::test]
681    async fn test_categorize_reqwest_error_other() {
682        let client = reqwest::Client::new();
683        let err = client
684            .get("http://non-existent-host-12345.local")
685            .send()
686            .await
687            .unwrap_err();
688
689        assert!(!err.is_timeout());
690        assert!(err.status().is_none()); // No status code
691
692        let provider_error = categorize_reqwest_error(&err);
693        assert!(matches!(provider_error, ProviderError::Other(_)));
694    }
695
696    #[test]
697    fn test_from_eyre_report_other_error() {
698        let eyre_error: eyre::Report = eyre::eyre!("Generic error");
699        let provider_error: ProviderError = eyre_error.into();
700        assert!(matches!(provider_error, ProviderError::Other(_)));
701    }
702
703    #[test]
704    fn test_get_evm_network_provider_valid_network() {
705        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
706        setup_test_env();
707
708        let network = create_test_evm_network();
709        let result = get_network_provider(&network, None);
710
711        cleanup_test_env();
712        assert!(result.is_ok());
713    }
714
715    #[test]
716    fn test_get_evm_network_provider_with_custom_urls() {
717        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
718        setup_test_env();
719
720        let network = create_test_evm_network();
721        let custom_urls = vec![
722            RpcConfig {
723                url: "https://custom-rpc1.example.com".to_string(),
724                weight: 1,
725                ..Default::default()
726            },
727            RpcConfig {
728                url: "https://custom-rpc2.example.com".to_string(),
729                weight: 1,
730                ..Default::default()
731            },
732        ];
733        let result = get_network_provider(&network, Some(custom_urls));
734
735        cleanup_test_env();
736        assert!(result.is_ok());
737    }
738
739    #[test]
740    fn test_get_evm_network_provider_with_empty_custom_urls() {
741        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
742        setup_test_env();
743
744        let network = create_test_evm_network();
745        let custom_urls: Vec<RpcConfig> = vec![];
746        let result = get_network_provider(&network, Some(custom_urls));
747
748        cleanup_test_env();
749        assert!(result.is_ok()); // Should fall back to public URLs
750    }
751
752    #[test]
753    fn test_get_solana_network_provider_valid_network_mainnet_beta() {
754        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
755        setup_test_env();
756
757        let network = create_test_solana_network("mainnet-beta");
758        let result = get_network_provider(&network, None);
759
760        cleanup_test_env();
761        assert!(result.is_ok());
762    }
763
764    #[test]
765    fn test_get_solana_network_provider_valid_network_testnet() {
766        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
767        setup_test_env();
768
769        let network = create_test_solana_network("testnet");
770        let result = get_network_provider(&network, None);
771
772        cleanup_test_env();
773        assert!(result.is_ok());
774    }
775
776    #[test]
777    fn test_get_solana_network_provider_with_custom_urls() {
778        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
779        setup_test_env();
780
781        let network = create_test_solana_network("testnet");
782        let custom_urls = vec![
783            RpcConfig {
784                url: "https://custom-rpc1.example.com".to_string(),
785                weight: 1,
786                ..Default::default()
787            },
788            RpcConfig {
789                url: "https://custom-rpc2.example.com".to_string(),
790                weight: 1,
791                ..Default::default()
792            },
793        ];
794        let result = get_network_provider(&network, Some(custom_urls));
795
796        cleanup_test_env();
797        assert!(result.is_ok());
798    }
799
800    #[test]
801    fn test_get_solana_network_provider_with_empty_custom_urls() {
802        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
803        setup_test_env();
804
805        let network = create_test_solana_network("testnet");
806        let custom_urls: Vec<RpcConfig> = vec![];
807        let result = get_network_provider(&network, Some(custom_urls));
808
809        cleanup_test_env();
810        assert!(result.is_ok()); // Should fall back to public URLs
811    }
812
813    // Tests for Stellar Network Provider
814    #[test]
815    fn test_get_stellar_network_provider_valid_network_fallback_public() {
816        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
817        setup_test_env();
818
819        let network = create_test_stellar_network();
820        let result = get_network_provider(&network, None); // No custom URLs
821
822        cleanup_test_env();
823        assert!(result.is_ok()); // Should fall back to public URLs for testnet
824                                 // StellarProvider::new will use the first public URL: https://soroban-testnet.stellar.org
825    }
826
827    #[test]
828    fn test_get_stellar_network_provider_with_custom_urls() {
829        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
830        setup_test_env();
831
832        let network = create_test_stellar_network();
833        let custom_urls = vec![
834            RpcConfig::new("https://custom-stellar-rpc1.example.com".to_string()),
835            RpcConfig::with_weight("http://custom-stellar-rpc2.example.com".to_string(), 50)
836                .unwrap(),
837        ];
838        let result = get_network_provider(&network, Some(custom_urls));
839
840        cleanup_test_env();
841        assert!(result.is_ok());
842        // StellarProvider::new will pick custom-stellar-rpc1 (default weight 100) over custom-stellar-rpc2 (weight 50)
843    }
844
845    #[test]
846    fn test_get_stellar_network_provider_with_empty_custom_urls_fallback() {
847        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
848        setup_test_env();
849
850        let network = create_test_stellar_network();
851        let custom_urls: Vec<RpcConfig> = vec![]; // Empty custom URLs
852        let result = get_network_provider(&network, Some(custom_urls));
853
854        cleanup_test_env();
855        assert!(result.is_ok()); // Should fall back to public URLs for mainnet
856                                 // StellarProvider::new will use the first public URL: https://horizon.stellar.org
857    }
858
859    #[test]
860    fn test_get_stellar_network_provider_custom_urls_with_zero_weight() {
861        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
862        setup_test_env();
863
864        let network = create_test_stellar_network();
865        let custom_urls = vec![
866            RpcConfig::with_weight("http://zero-weight-rpc.example.com".to_string(), 0).unwrap(),
867            RpcConfig::new("http://active-rpc.example.com".to_string()), // Default weight 100
868        ];
869        let result = get_network_provider(&network, Some(custom_urls));
870        cleanup_test_env();
871        assert!(result.is_ok()); // active-rpc should be chosen
872    }
873
874    #[test]
875    fn test_get_stellar_network_provider_all_custom_urls_zero_weight_fallback() {
876        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
877        setup_test_env();
878
879        let network = create_test_stellar_network();
880        let custom_urls = vec![
881            RpcConfig::with_weight("http://zero1.example.com".to_string(), 0).unwrap(),
882            RpcConfig::with_weight("http://zero2.example.com".to_string(), 0).unwrap(),
883        ];
884        // Since StellarProvider::new filters out zero-weight URLs, and if the list becomes empty,
885        // get_network_provider does NOT re-trigger fallback to public. Instead, StellarProvider::new itself will error.
886        // The current get_network_provider logic passes the custom_urls to N::new_provider if Some and not empty.
887        // If custom_urls becomes effectively empty *inside* N::new_provider (like StellarProvider::new after filtering weights),
888        // then N::new_provider is responsible for erroring or handling.
889        let result = get_network_provider(&network, Some(custom_urls));
890        cleanup_test_env();
891        assert!(result.is_err());
892        match result.unwrap_err() {
893            ProviderError::NetworkConfiguration(msg) => {
894                assert!(msg.contains("No active RPC configurations provided"));
895            }
896            _ => panic!("Unexpected error type"),
897        }
898    }
899
900    #[test]
901    fn test_provider_error_rpc_error_code_variant() {
902        let error = ProviderError::RpcErrorCode {
903            code: -32000,
904            message: "insufficient funds".to_string(),
905        };
906        let error_string = format!("{error}");
907        assert!(error_string.contains("-32000"));
908        assert!(error_string.contains("insufficient funds"));
909    }
910
911    #[test]
912    fn test_get_stellar_network_provider_invalid_custom_url_scheme() {
913        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
914        setup_test_env();
915        let network = create_test_stellar_network();
916        let custom_urls = vec![RpcConfig::new("ftp://custom-ftp.example.com".to_string())];
917        let result = get_network_provider(&network, Some(custom_urls));
918        cleanup_test_env();
919        assert!(result.is_err());
920        match result.unwrap_err() {
921            ProviderError::NetworkConfiguration(msg) => {
922                // This error comes from RpcConfig::validate_list inside StellarProvider::new
923                assert!(msg.contains("Invalid URL scheme"));
924            }
925            _ => panic!("Unexpected error type"),
926        }
927    }
928
929    #[test]
930    fn test_should_mark_provider_failed_server_errors() {
931        // 5xx errors should mark provider as failed
932        for status_code in 500..=599 {
933            let error = ProviderError::RequestError {
934                error: format!("Server error {status_code}"),
935                status_code,
936            };
937            assert!(
938                should_mark_provider_failed(&error),
939                "Status code {status_code} should mark provider as failed"
940            );
941        }
942    }
943
944    #[test]
945    fn test_should_mark_provider_failed_auth_errors() {
946        // Authentication/authorization errors should mark provider as failed
947        let auth_errors = [401, 403];
948        for &status_code in &auth_errors {
949            let error = ProviderError::RequestError {
950                error: format!("Auth error {status_code}"),
951                status_code,
952            };
953            assert!(
954                should_mark_provider_failed(&error),
955                "Status code {status_code} should mark provider as failed"
956            );
957        }
958    }
959
960    #[test]
961    fn test_should_mark_provider_failed_not_found_errors() {
962        // 404 and 410 should mark provider as failed (endpoint issues)
963        let not_found_errors = [404, 410];
964        for &status_code in &not_found_errors {
965            let error = ProviderError::RequestError {
966                error: format!("Not found error {status_code}"),
967                status_code,
968            };
969            assert!(
970                should_mark_provider_failed(&error),
971                "Status code {status_code} should mark provider as failed"
972            );
973        }
974    }
975
976    #[test]
977    fn test_should_mark_provider_failed_client_errors_not_failed() {
978        // These 4xx errors should NOT mark provider as failed (client-side issues)
979        let client_errors = [400, 405, 413, 414, 415, 422, 429];
980        for &status_code in &client_errors {
981            let error = ProviderError::RequestError {
982                error: format!("Client error {status_code}"),
983                status_code,
984            };
985            assert!(
986                !should_mark_provider_failed(&error),
987                "Status code {status_code} should NOT mark provider as failed"
988            );
989        }
990    }
991
992    #[test]
993    fn test_should_mark_provider_failed_other_error_types() {
994        // Test non-RequestError types - these should NOT mark provider as failed
995        let errors = [
996            ProviderError::Timeout,
997            ProviderError::RateLimited,
998            ProviderError::BadGateway,
999            ProviderError::InvalidAddress("test".to_string()),
1000            ProviderError::NetworkConfiguration("test".to_string()),
1001            ProviderError::Other("test".to_string()),
1002        ];
1003
1004        for error in errors {
1005            assert!(
1006                !should_mark_provider_failed(&error),
1007                "Error type {error:?} should NOT mark provider as failed"
1008            );
1009        }
1010    }
1011
1012    #[test]
1013    fn test_should_mark_provider_failed_edge_cases() {
1014        // Test some edge case status codes
1015        let edge_cases = [
1016            (200, false), // Success - shouldn't happen in error context but test anyway
1017            (300, false), // Redirection
1018            (418, false), // I'm a teapot - should not mark as failed
1019            (451, false), // Unavailable for legal reasons - client issue
1020            (499, false), // Client closed request - client issue
1021        ];
1022
1023        for (status_code, should_fail) in edge_cases {
1024            let error = ProviderError::RequestError {
1025                error: format!("Edge case error {status_code}"),
1026                status_code,
1027            };
1028            assert_eq!(
1029                should_mark_provider_failed(&error),
1030                should_fail,
1031                "Status code {} should {} mark provider as failed",
1032                status_code,
1033                if should_fail { "" } else { "NOT" }
1034            );
1035        }
1036    }
1037
1038    #[test]
1039    fn test_is_retriable_error_retriable_types() {
1040        // These error types should be retriable
1041        let retriable_errors = [
1042            ProviderError::Timeout,
1043            ProviderError::RateLimited,
1044            ProviderError::BadGateway,
1045            ProviderError::TransportError("test".to_string()),
1046        ];
1047
1048        for error in retriable_errors {
1049            assert!(
1050                is_retriable_error(&error),
1051                "Error type {error:?} should be retriable"
1052            );
1053        }
1054    }
1055
1056    #[test]
1057    fn test_is_retriable_error_non_retriable_types() {
1058        // These error types should NOT be retriable
1059        let non_retriable_errors = [
1060            ProviderError::InvalidAddress("test".to_string()),
1061            ProviderError::NetworkConfiguration("test".to_string()),
1062            ProviderError::RequestError {
1063                error: "Some error".to_string(),
1064                status_code: 400,
1065            },
1066        ];
1067
1068        for error in non_retriable_errors {
1069            assert!(
1070                !is_retriable_error(&error),
1071                "Error type {error:?} should NOT be retriable"
1072            );
1073        }
1074    }
1075
1076    #[test]
1077    fn test_is_retriable_error_message_based_detection() {
1078        // Test errors that should be retriable based on message content
1079        let retriable_messages = [
1080            "Connection timeout occurred",
1081            "Network connection reset",
1082            "Connection refused",
1083            "TIMEOUT error happened",
1084            "Connection was reset by peer",
1085        ];
1086
1087        for message in retriable_messages {
1088            let error = ProviderError::Other(message.to_string());
1089            assert!(
1090                is_retriable_error(&error),
1091                "Error with message '{message}' should be retriable"
1092            );
1093        }
1094    }
1095
1096    #[test]
1097    fn test_is_retriable_error_message_based_non_retriable() {
1098        // Test errors that should NOT be retriable based on message content
1099        let non_retriable_messages = [
1100            "Invalid address format",
1101            "Bad request parameters",
1102            "Authentication failed",
1103            "Method not found",
1104            "Some other error",
1105        ];
1106
1107        for message in non_retriable_messages {
1108            let error = ProviderError::Other(message.to_string());
1109            assert!(
1110                !is_retriable_error(&error),
1111                "Error with message '{message}' should NOT be retriable"
1112            );
1113        }
1114    }
1115
1116    #[test]
1117    fn test_is_retriable_error_case_insensitive() {
1118        // Test that message-based detection is case insensitive
1119        let case_variations = [
1120            "TIMEOUT",
1121            "Timeout",
1122            "timeout",
1123            "CONNECTION",
1124            "Connection",
1125            "connection",
1126            "RESET",
1127            "Reset",
1128            "reset",
1129        ];
1130
1131        for message in case_variations {
1132            let error = ProviderError::Other(message.to_string());
1133            assert!(
1134                is_retriable_error(&error),
1135                "Error with message '{message}' should be retriable (case insensitive)"
1136            );
1137        }
1138    }
1139
1140    #[test]
1141    fn test_is_retriable_error_request_error_retriable_5xx() {
1142        // Test retriable 5xx status codes
1143        let retriable_5xx = vec![
1144            (500, "Internal Server Error"),
1145            (502, "Bad Gateway"),
1146            (503, "Service Unavailable"),
1147            (504, "Gateway Timeout"),
1148            (506, "Variant Also Negotiates"),
1149            (507, "Insufficient Storage"),
1150            (508, "Loop Detected"),
1151            (510, "Not Extended"),
1152            (511, "Network Authentication Required"),
1153            (599, "Network Connect Timeout Error"),
1154        ];
1155
1156        for (status_code, description) in retriable_5xx {
1157            let error = ProviderError::RequestError {
1158                error: description.to_string(),
1159                status_code,
1160            };
1161            assert!(
1162                is_retriable_error(&error),
1163                "Status code {status_code} ({description}) should be retriable"
1164            );
1165        }
1166    }
1167
1168    #[test]
1169    fn test_is_retriable_error_request_error_non_retriable_5xx() {
1170        // Test non-retriable 5xx status codes (persistent server issues)
1171        let non_retriable_5xx = vec![
1172            (501, "Not Implemented"),
1173            (505, "HTTP Version Not Supported"),
1174        ];
1175
1176        for (status_code, description) in non_retriable_5xx {
1177            let error = ProviderError::RequestError {
1178                error: description.to_string(),
1179                status_code,
1180            };
1181            assert!(
1182                !is_retriable_error(&error),
1183                "Status code {status_code} ({description}) should NOT be retriable"
1184            );
1185        }
1186    }
1187
1188    #[test]
1189    fn test_is_retriable_error_request_error_retriable_4xx() {
1190        // Test retriable 4xx status codes (timeout/rate-limit related)
1191        let retriable_4xx = vec![
1192            (408, "Request Timeout"),
1193            (425, "Too Early"),
1194            (429, "Too Many Requests"),
1195        ];
1196
1197        for (status_code, description) in retriable_4xx {
1198            let error = ProviderError::RequestError {
1199                error: description.to_string(),
1200                status_code,
1201            };
1202            assert!(
1203                is_retriable_error(&error),
1204                "Status code {status_code} ({description}) should be retriable"
1205            );
1206        }
1207    }
1208
1209    #[test]
1210    fn test_is_retriable_error_request_error_non_retriable_4xx() {
1211        // Test non-retriable 4xx status codes (client errors)
1212        let non_retriable_4xx = vec![
1213            (400, "Bad Request"),
1214            (401, "Unauthorized"),
1215            (403, "Forbidden"),
1216            (404, "Not Found"),
1217            (405, "Method Not Allowed"),
1218            (406, "Not Acceptable"),
1219            (407, "Proxy Authentication Required"),
1220            (409, "Conflict"),
1221            (410, "Gone"),
1222            (411, "Length Required"),
1223            (412, "Precondition Failed"),
1224            (413, "Payload Too Large"),
1225            (414, "URI Too Long"),
1226            (415, "Unsupported Media Type"),
1227            (416, "Range Not Satisfiable"),
1228            (417, "Expectation Failed"),
1229            (418, "I'm a teapot"),
1230            (421, "Misdirected Request"),
1231            (422, "Unprocessable Entity"),
1232            (423, "Locked"),
1233            (424, "Failed Dependency"),
1234            (426, "Upgrade Required"),
1235            (428, "Precondition Required"),
1236            (431, "Request Header Fields Too Large"),
1237            (451, "Unavailable For Legal Reasons"),
1238            (499, "Client Closed Request"),
1239        ];
1240
1241        for (status_code, description) in non_retriable_4xx {
1242            let error = ProviderError::RequestError {
1243                error: description.to_string(),
1244                status_code,
1245            };
1246            assert!(
1247                !is_retriable_error(&error),
1248                "Status code {status_code} ({description}) should NOT be retriable"
1249            );
1250        }
1251    }
1252
1253    #[test]
1254    fn test_is_retriable_error_request_error_other_status_codes() {
1255        // Test other status codes (1xx, 2xx, 3xx) - should not be retriable
1256        let other_status_codes = vec![
1257            (100, "Continue"),
1258            (101, "Switching Protocols"),
1259            (200, "OK"),
1260            (201, "Created"),
1261            (204, "No Content"),
1262            (300, "Multiple Choices"),
1263            (301, "Moved Permanently"),
1264            (302, "Found"),
1265            (304, "Not Modified"),
1266            (600, "Custom status"),
1267            (999, "Unknown status"),
1268        ];
1269
1270        for (status_code, description) in other_status_codes {
1271            let error = ProviderError::RequestError {
1272                error: description.to_string(),
1273                status_code,
1274            };
1275            assert!(
1276                !is_retriable_error(&error),
1277                "Status code {status_code} ({description}) should NOT be retriable"
1278            );
1279        }
1280    }
1281
1282    #[test]
1283    fn test_is_retriable_error_request_error_boundary_cases() {
1284        // Test boundary cases for our ranges
1285        let test_cases = vec![
1286            // Just before retriable 4xx range
1287            (407, false, "Proxy Authentication Required"),
1288            (408, true, "Request Timeout - first retriable 4xx"),
1289            (409, false, "Conflict"),
1290            // Around 425
1291            (424, false, "Failed Dependency"),
1292            (425, true, "Too Early"),
1293            (426, false, "Upgrade Required"),
1294            // Around 429
1295            (428, false, "Precondition Required"),
1296            (429, true, "Too Many Requests"),
1297            (430, false, "Would be non-retriable if it existed"),
1298            // 5xx boundaries
1299            (499, false, "Last 4xx"),
1300            (500, true, "First 5xx - retriable"),
1301            (501, false, "Not Implemented - exception"),
1302            (502, true, "Bad Gateway - retriable"),
1303            (505, false, "HTTP Version Not Supported - exception"),
1304            (506, true, "First after 505 exception"),
1305            (599, true, "Last defined 5xx"),
1306        ];
1307
1308        for (status_code, should_be_retriable, description) in test_cases {
1309            let error = ProviderError::RequestError {
1310                error: description.to_string(),
1311                status_code,
1312            };
1313            assert_eq!(
1314                is_retriable_error(&error),
1315                should_be_retriable,
1316                "Status code {} ({}) should{} be retriable",
1317                status_code,
1318                description,
1319                if should_be_retriable { "" } else { " NOT" }
1320            );
1321        }
1322    }
1323}