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