openzeppelin_relayer/services/provider/
mod.rs

1use std::num::ParseIntError;
2
3use crate::config::ServerConfig;
4use crate::models::{EvmNetwork, RpcConfig, SolanaNetwork, StellarNetwork};
5use serde::Serialize;
6use thiserror::Error;
7
8use alloy::transports::RpcError;
9
10pub mod evm;
11pub use evm::*;
12
13mod solana;
14pub use solana::*;
15
16mod stellar;
17pub use stellar::*;
18
19mod retry;
20pub use retry::*;
21
22pub mod rpc_selector;
23
24#[derive(Error, Debug, Serialize)]
25pub enum ProviderError {
26    #[error("RPC client error: {0}")]
27    SolanaRpcError(#[from] SolanaProviderError),
28    #[error("Invalid address: {0}")]
29    InvalidAddress(String),
30    #[error("Network configuration error: {0}")]
31    NetworkConfiguration(String),
32    #[error("Request timeout")]
33    Timeout,
34    #[error("Rate limited (HTTP 429)")]
35    RateLimited,
36    #[error("Bad gateway (HTTP 502)")]
37    BadGateway,
38    #[error("Request error (HTTP {status_code}): {error}")]
39    RequestError { error: String, status_code: u16 },
40    #[error("Other provider error: {0}")]
41    Other(String),
42}
43
44impl From<hex::FromHexError> for ProviderError {
45    fn from(err: hex::FromHexError) -> Self {
46        ProviderError::InvalidAddress(err.to_string())
47    }
48}
49
50impl From<std::net::AddrParseError> for ProviderError {
51    fn from(err: std::net::AddrParseError) -> Self {
52        ProviderError::NetworkConfiguration(format!("Invalid network address: {}", err))
53    }
54}
55
56impl From<ParseIntError> for ProviderError {
57    fn from(err: ParseIntError) -> Self {
58        ProviderError::Other(format!("Number parsing error: {}", err))
59    }
60}
61
62/// Categorizes a reqwest error into an appropriate `ProviderError` variant.
63///
64/// This function analyzes the given reqwest error and maps it to a specific
65/// `ProviderError` variant based on the error's properties:
66/// - Timeout errors become `ProviderError::Timeout`
67/// - HTTP 429 responses become `ProviderError::RateLimited`
68/// - HTTP 502 responses become `ProviderError::BadGateway`
69/// - All other errors become `ProviderError::Other` with the error message
70///
71/// # Arguments
72///
73/// * `err` - A reference to the reqwest error to categorize
74///
75/// # Returns
76///
77/// The appropriate `ProviderError` variant based on the error type
78fn categorize_reqwest_error(err: &reqwest::Error) -> ProviderError {
79    if err.is_timeout() {
80        return ProviderError::Timeout;
81    }
82
83    if let Some(status) = err.status() {
84        match status.as_u16() {
85            429 => return ProviderError::RateLimited,
86            502 => return ProviderError::BadGateway,
87            _ => {
88                return ProviderError::RequestError {
89                    error: err.to_string(),
90                    status_code: status.as_u16(),
91                }
92            }
93        }
94    }
95
96    ProviderError::Other(err.to_string())
97}
98
99impl From<reqwest::Error> for ProviderError {
100    fn from(err: reqwest::Error) -> Self {
101        categorize_reqwest_error(&err)
102    }
103}
104
105impl From<&reqwest::Error> for ProviderError {
106    fn from(err: &reqwest::Error) -> Self {
107        categorize_reqwest_error(err)
108    }
109}
110
111impl From<eyre::Report> for ProviderError {
112    fn from(err: eyre::Report) -> Self {
113        // Downcast to known error types first
114        if let Some(reqwest_err) = err.downcast_ref::<reqwest::Error>() {
115            return ProviderError::from(reqwest_err);
116        }
117
118        // Default to Other for unknown error types
119        ProviderError::Other(err.to_string())
120    }
121}
122
123// Add conversion from String to ProviderError
124impl From<String> for ProviderError {
125    fn from(error: String) -> Self {
126        ProviderError::Other(error)
127    }
128}
129
130// Generic implementation for all RpcError types
131impl<E> From<RpcError<E>> for ProviderError
132where
133    E: std::fmt::Display + std::any::Any + 'static,
134{
135    fn from(err: RpcError<E>) -> Self {
136        match err {
137            RpcError::Transport(transport_err) => {
138                // First check if it's a reqwest::Error using downcasting
139                if let Some(reqwest_err) =
140                    (&transport_err as &dyn std::any::Any).downcast_ref::<reqwest::Error>()
141                {
142                    return categorize_reqwest_error(reqwest_err);
143                }
144
145                // Fallback for other transport error types
146                ProviderError::Other(format!("Transport error: {}", transport_err))
147            }
148            RpcError::ErrorResp(json_rpc_err) => ProviderError::Other(format!(
149                "JSON-RPC error ({}): {}",
150                json_rpc_err.code, json_rpc_err.message
151            )),
152            _ => ProviderError::Other(format!("Other RPC error: {}", err)),
153        }
154    }
155}
156
157// Implement From for RpcSelectorError
158impl From<super::rpc_selector::RpcSelectorError> for ProviderError {
159    fn from(err: super::rpc_selector::RpcSelectorError) -> Self {
160        ProviderError::NetworkConfiguration(format!("RPC selector error: {}", err))
161    }
162}
163
164pub trait NetworkConfiguration: Sized {
165    type Provider;
166
167    fn public_rpc_urls(&self) -> Vec<String>;
168
169    fn new_provider(
170        rpc_urls: Vec<RpcConfig>,
171        timeout_seconds: u64,
172    ) -> Result<Self::Provider, ProviderError>;
173}
174
175impl NetworkConfiguration for EvmNetwork {
176    type Provider = EvmProvider;
177
178    fn public_rpc_urls(&self) -> Vec<String> {
179        (*self)
180            .public_rpc_urls()
181            .map(|urls| urls.iter().map(|url| url.to_string()).collect())
182            .unwrap_or_default()
183    }
184
185    fn new_provider(
186        rpc_urls: Vec<RpcConfig>,
187        timeout_seconds: u64,
188    ) -> Result<Self::Provider, ProviderError> {
189        EvmProvider::new(rpc_urls, timeout_seconds)
190    }
191}
192
193impl NetworkConfiguration for SolanaNetwork {
194    type Provider = SolanaProvider;
195
196    fn public_rpc_urls(&self) -> Vec<String> {
197        (*self)
198            .public_rpc_urls()
199            .map(|urls| urls.to_vec())
200            .unwrap_or_default()
201    }
202
203    fn new_provider(
204        rpc_urls: Vec<RpcConfig>,
205        timeout_seconds: u64,
206    ) -> Result<Self::Provider, ProviderError> {
207        SolanaProvider::new(rpc_urls, timeout_seconds)
208    }
209}
210
211impl NetworkConfiguration for StellarNetwork {
212    type Provider = StellarProvider;
213
214    fn public_rpc_urls(&self) -> Vec<String> {
215        (*self)
216            .public_rpc_urls()
217            .map(|urls| urls.to_vec())
218            .unwrap_or_default()
219    }
220
221    fn new_provider(
222        rpc_urls: Vec<RpcConfig>,
223        timeout_seconds: u64,
224    ) -> Result<Self::Provider, ProviderError> {
225        StellarProvider::new(rpc_urls, timeout_seconds)
226    }
227}
228
229/// Creates a network-specific provider instance based on the provided configuration.
230///
231/// # Type Parameters
232///
233/// * `N`: The type of the network, which must implement the `NetworkConfiguration` trait.
234///   This determines the specific provider type (`N::Provider`) and how to obtain
235///   public RPC URLs.
236///
237/// # Arguments
238///
239/// * `network`: A reference to the network configuration object (`&N`).
240/// * `custom_rpc_urls`: An `Option<Vec<RpcConfig>>`. If `Some` and not empty, these URLs
241///   are used to configure the provider. If `None` or `Some` but empty, the function
242///   falls back to using the public RPC URLs defined by the `network`'s
243///   `NetworkConfiguration` implementation.
244///
245/// # Returns
246///
247/// * `Ok(N::Provider)`: An instance of the network-specific provider on success.
248/// * `Err(ProviderError)`: An error if configuration fails, such as when no custom URLs
249///   are provided and the network has no public RPC URLs defined
250///   (`ProviderError::NetworkConfiguration`).
251pub fn get_network_provider<N: NetworkConfiguration>(
252    network: &N,
253    custom_rpc_urls: Option<Vec<RpcConfig>>,
254) -> Result<N::Provider, ProviderError> {
255    let rpc_timeout_ms = ServerConfig::from_env().rpc_timeout_ms;
256    let timeout_seconds = rpc_timeout_ms / 1000; // Convert ms to s
257
258    let rpc_urls = match custom_rpc_urls {
259        Some(configs) if !configs.is_empty() => configs,
260        _ => {
261            let urls = network.public_rpc_urls();
262            if urls.is_empty() {
263                return Err(ProviderError::NetworkConfiguration(
264                    "No public RPC URLs available for this network".to_string(),
265                ));
266            }
267            urls.into_iter().map(RpcConfig::new).collect()
268        }
269    };
270
271    N::new_provider(rpc_urls, timeout_seconds)
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use lazy_static::lazy_static;
278    use std::env;
279    use std::sync::Mutex;
280    use std::time::Duration;
281    use wiremock::matchers::any;
282    use wiremock::{Mock, MockServer, ResponseTemplate};
283
284    // Use a mutex to ensure tests don't run in parallel when modifying env vars
285    lazy_static! {
286        static ref ENV_MUTEX: Mutex<()> = Mutex::new(());
287    }
288
289    fn setup_test_env() {
290        env::set_var("API_KEY", "7EF1CB7C-5003-4696-B384-C72AF8C3E15D"); // noboost
291        env::set_var("REDIS_URL", "redis://localhost:6379");
292        env::set_var("RPC_TIMEOUT_MS", "5000");
293    }
294
295    fn cleanup_test_env() {
296        env::remove_var("API_KEY");
297        env::remove_var("REDIS_URL");
298        env::remove_var("RPC_TIMEOUT_MS");
299    }
300
301    fn create_test_evm_network() -> EvmNetwork {
302        EvmNetwork {
303            network: "test-evm".to_string(),
304            rpc_urls: vec!["https://rpc.example.com".to_string()],
305            explorer_urls: None,
306            average_blocktime_ms: 12000,
307            is_testnet: true,
308            tags: vec![],
309            chain_id: 1337,
310            required_confirmations: 1,
311            features: vec![],
312            symbol: "ETH".to_string(),
313        }
314    }
315
316    fn create_test_solana_network(network_str: &str) -> SolanaNetwork {
317        SolanaNetwork {
318            network: network_str.to_string(),
319            rpc_urls: vec!["https://api.testnet.solana.com".to_string()],
320            explorer_urls: None,
321            average_blocktime_ms: 400,
322            is_testnet: true,
323            tags: vec![],
324        }
325    }
326
327    fn create_test_stellar_network() -> StellarNetwork {
328        StellarNetwork {
329            network: "testnet".to_string(),
330            rpc_urls: vec!["https://soroban-testnet.stellar.org".to_string()],
331            explorer_urls: None,
332            average_blocktime_ms: 5000,
333            is_testnet: true,
334            tags: vec![],
335            passphrase: "Test SDF Network ; September 2015".to_string(),
336        }
337    }
338
339    #[test]
340    fn test_from_hex_error() {
341        let hex_error = hex::FromHexError::OddLength;
342        let provider_error: ProviderError = hex_error.into();
343        assert!(matches!(provider_error, ProviderError::InvalidAddress(_)));
344    }
345
346    #[test]
347    fn test_from_addr_parse_error() {
348        let addr_error = "invalid:address"
349            .parse::<std::net::SocketAddr>()
350            .unwrap_err();
351        let provider_error: ProviderError = addr_error.into();
352        assert!(matches!(
353            provider_error,
354            ProviderError::NetworkConfiguration(_)
355        ));
356    }
357
358    #[test]
359    fn test_from_parse_int_error() {
360        let parse_error = "not_a_number".parse::<u64>().unwrap_err();
361        let provider_error: ProviderError = parse_error.into();
362        assert!(matches!(provider_error, ProviderError::Other(_)));
363    }
364
365    #[actix_rt::test]
366    async fn test_categorize_reqwest_error_timeout() {
367        let client = reqwest::Client::new();
368        let timeout_err = client
369            .get("http://example.com")
370            .timeout(Duration::from_nanos(1))
371            .send()
372            .await
373            .unwrap_err();
374
375        assert!(timeout_err.is_timeout());
376
377        let provider_error = categorize_reqwest_error(&timeout_err);
378        assert!(matches!(provider_error, ProviderError::Timeout));
379    }
380
381    #[actix_rt::test]
382    async fn test_categorize_reqwest_error_rate_limited() {
383        let mock_server = MockServer::start().await;
384
385        Mock::given(any())
386            .respond_with(ResponseTemplate::new(429))
387            .mount(&mock_server)
388            .await;
389
390        let client = reqwest::Client::new();
391        let response = client
392            .get(mock_server.uri())
393            .send()
394            .await
395            .expect("Failed to get response");
396
397        let err = response
398            .error_for_status()
399            .expect_err("Expected error for status 429");
400
401        assert!(err.status().is_some());
402        assert_eq!(err.status().unwrap().as_u16(), 429);
403
404        let provider_error = categorize_reqwest_error(&err);
405        assert!(matches!(provider_error, ProviderError::RateLimited));
406    }
407
408    #[actix_rt::test]
409    async fn test_categorize_reqwest_error_bad_gateway() {
410        let mock_server = MockServer::start().await;
411
412        Mock::given(any())
413            .respond_with(ResponseTemplate::new(502))
414            .mount(&mock_server)
415            .await;
416
417        let client = reqwest::Client::new();
418        let response = client
419            .get(mock_server.uri())
420            .send()
421            .await
422            .expect("Failed to get response");
423
424        let err = response
425            .error_for_status()
426            .expect_err("Expected error for status 502");
427
428        assert!(err.status().is_some());
429        assert_eq!(err.status().unwrap().as_u16(), 502);
430
431        let provider_error = categorize_reqwest_error(&err);
432        assert!(matches!(provider_error, ProviderError::BadGateway));
433    }
434
435    #[actix_rt::test]
436    async fn test_categorize_reqwest_error_other() {
437        let client = reqwest::Client::new();
438        let err = client
439            .get("http://non-existent-host-12345.local")
440            .send()
441            .await
442            .unwrap_err();
443
444        assert!(!err.is_timeout());
445        assert!(err.status().is_none()); // No status code
446
447        let provider_error = categorize_reqwest_error(&err);
448        assert!(matches!(provider_error, ProviderError::Other(_)));
449    }
450
451    #[test]
452    fn test_from_eyre_report_other_error() {
453        let eyre_error: eyre::Report = eyre::eyre!("Generic error");
454        let provider_error: ProviderError = eyre_error.into();
455        assert!(matches!(provider_error, ProviderError::Other(_)));
456    }
457
458    #[test]
459    fn test_get_evm_network_provider_valid_network() {
460        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
461        setup_test_env();
462
463        let network = create_test_evm_network();
464        let result = get_network_provider(&network, None);
465
466        cleanup_test_env();
467        assert!(result.is_ok());
468    }
469
470    #[test]
471    fn test_get_evm_network_provider_with_custom_urls() {
472        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
473        setup_test_env();
474
475        let network = create_test_evm_network();
476        let custom_urls = vec![
477            RpcConfig {
478                url: "https://custom-rpc1.example.com".to_string(),
479                weight: 1,
480            },
481            RpcConfig {
482                url: "https://custom-rpc2.example.com".to_string(),
483                weight: 1,
484            },
485        ];
486        let result = get_network_provider(&network, Some(custom_urls));
487
488        cleanup_test_env();
489        assert!(result.is_ok());
490    }
491
492    #[test]
493    fn test_get_evm_network_provider_with_empty_custom_urls() {
494        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
495        setup_test_env();
496
497        let network = create_test_evm_network();
498        let custom_urls: Vec<RpcConfig> = vec![];
499        let result = get_network_provider(&network, Some(custom_urls));
500
501        cleanup_test_env();
502        assert!(result.is_ok()); // Should fall back to public URLs
503    }
504
505    #[test]
506    fn test_get_solana_network_provider_valid_network_mainnet_beta() {
507        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
508        setup_test_env();
509
510        let network = create_test_solana_network("mainnet-beta");
511        let result = get_network_provider(&network, None);
512
513        cleanup_test_env();
514        assert!(result.is_ok());
515    }
516
517    #[test]
518    fn test_get_solana_network_provider_valid_network_testnet() {
519        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
520        setup_test_env();
521
522        let network = create_test_solana_network("testnet");
523        let result = get_network_provider(&network, None);
524
525        cleanup_test_env();
526        assert!(result.is_ok());
527    }
528
529    #[test]
530    fn test_get_solana_network_provider_with_custom_urls() {
531        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
532        setup_test_env();
533
534        let network = create_test_solana_network("testnet");
535        let custom_urls = vec![
536            RpcConfig {
537                url: "https://custom-rpc1.example.com".to_string(),
538                weight: 1,
539            },
540            RpcConfig {
541                url: "https://custom-rpc2.example.com".to_string(),
542                weight: 1,
543            },
544        ];
545        let result = get_network_provider(&network, Some(custom_urls));
546
547        cleanup_test_env();
548        assert!(result.is_ok());
549    }
550
551    #[test]
552    fn test_get_solana_network_provider_with_empty_custom_urls() {
553        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
554        setup_test_env();
555
556        let network = create_test_solana_network("testnet");
557        let custom_urls: Vec<RpcConfig> = vec![];
558        let result = get_network_provider(&network, Some(custom_urls));
559
560        cleanup_test_env();
561        assert!(result.is_ok()); // Should fall back to public URLs
562    }
563
564    // Tests for Stellar Network Provider
565    #[test]
566    fn test_get_stellar_network_provider_valid_network_fallback_public() {
567        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
568        setup_test_env();
569
570        let network = create_test_stellar_network();
571        let result = get_network_provider(&network, None); // No custom URLs
572
573        cleanup_test_env();
574        assert!(result.is_ok()); // Should fall back to public URLs for testnet
575                                 // StellarProvider::new will use the first public URL: https://soroban-testnet.stellar.org
576    }
577
578    #[test]
579    fn test_get_stellar_network_provider_with_custom_urls() {
580        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
581        setup_test_env();
582
583        let network = create_test_stellar_network();
584        let custom_urls = vec![
585            RpcConfig::new("https://custom-stellar-rpc1.example.com".to_string()),
586            RpcConfig::with_weight("http://custom-stellar-rpc2.example.com".to_string(), 50)
587                .unwrap(),
588        ];
589        let result = get_network_provider(&network, Some(custom_urls));
590
591        cleanup_test_env();
592        assert!(result.is_ok());
593        // StellarProvider::new will pick custom-stellar-rpc1 (default weight 100) over custom-stellar-rpc2 (weight 50)
594    }
595
596    #[test]
597    fn test_get_stellar_network_provider_with_empty_custom_urls_fallback() {
598        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
599        setup_test_env();
600
601        let network = create_test_stellar_network();
602        let custom_urls: Vec<RpcConfig> = vec![]; // Empty custom URLs
603        let result = get_network_provider(&network, Some(custom_urls));
604
605        cleanup_test_env();
606        assert!(result.is_ok()); // Should fall back to public URLs for mainnet
607                                 // StellarProvider::new will use the first public URL: https://horizon.stellar.org
608    }
609
610    #[test]
611    fn test_get_stellar_network_provider_custom_urls_with_zero_weight() {
612        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
613        setup_test_env();
614
615        let network = create_test_stellar_network();
616        let custom_urls = vec![
617            RpcConfig::with_weight("http://zero-weight-rpc.example.com".to_string(), 0).unwrap(),
618            RpcConfig::new("http://active-rpc.example.com".to_string()), // Default weight 100
619        ];
620        let result = get_network_provider(&network, Some(custom_urls));
621        cleanup_test_env();
622        assert!(result.is_ok()); // active-rpc should be chosen
623    }
624
625    #[test]
626    fn test_get_stellar_network_provider_all_custom_urls_zero_weight_fallback() {
627        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
628        setup_test_env();
629
630        let network = create_test_stellar_network();
631        let custom_urls = vec![
632            RpcConfig::with_weight("http://zero1.example.com".to_string(), 0).unwrap(),
633            RpcConfig::with_weight("http://zero2.example.com".to_string(), 0).unwrap(),
634        ];
635        // Since StellarProvider::new filters out zero-weight URLs, and if the list becomes empty,
636        // get_network_provider does NOT re-trigger fallback to public. Instead, StellarProvider::new itself will error.
637        // The current get_network_provider logic passes the custom_urls to N::new_provider if Some and not empty.
638        // If custom_urls becomes effectively empty *inside* N::new_provider (like StellarProvider::new after filtering weights),
639        // then N::new_provider is responsible for erroring or handling.
640        let result = get_network_provider(&network, Some(custom_urls));
641        cleanup_test_env();
642        assert!(result.is_err());
643        match result.unwrap_err() {
644            ProviderError::NetworkConfiguration(msg) => {
645                assert!(msg.contains("No active RPC configurations provided"));
646            }
647            _ => panic!("Unexpected error type"),
648        }
649    }
650
651    #[test]
652    fn test_get_stellar_network_provider_invalid_custom_url_scheme() {
653        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
654        setup_test_env();
655        let network = create_test_stellar_network();
656        let custom_urls = vec![RpcConfig::new("ftp://custom-ftp.example.com".to_string())];
657        let result = get_network_provider(&network, Some(custom_urls));
658        cleanup_test_env();
659        assert!(result.is_err());
660        match result.unwrap_err() {
661            ProviderError::NetworkConfiguration(msg) => {
662                // This error comes from RpcConfig::validate_list inside StellarProvider::new
663                assert!(msg.contains("Invalid URL scheme"));
664            }
665            _ => panic!("Unexpected error type"),
666        }
667    }
668}