openzeppelin_relayer/models/relayer/
rpc_config.rs

1//! Configuration for RPC endpoints.
2//!
3//! This module provides configuration structures for RPC endpoints,
4//! including URLs and weights for load balancing.
5
6use crate::constants::DEFAULT_RPC_WEIGHT;
7use crate::utils::mask_url;
8use eyre::eyre;
9use serde::{
10    de::Error as DeError, ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer,
11};
12use std::hash::{Hash, Hasher};
13use thiserror::Error;
14use utoipa::ToSchema;
15
16#[derive(Debug, Error, PartialEq)]
17pub enum RpcConfigError {
18    #[error("Invalid weight: {value}. Must be between 0 and 100.")]
19    InvalidWeight { value: u8 },
20}
21
22/// Returns the default RPC weight for OpenAPI schema generation.
23fn default_rpc_weight() -> u8 {
24    DEFAULT_RPC_WEIGHT
25}
26
27/// Configuration for an RPC endpoint.
28///
29/// This struct contains only persistent configuration (URL and weight).
30/// Health metadata (failures, pause state) is managed separately via `RpcHealthStore`.
31#[derive(Clone, Debug, PartialEq, Eq, Default, ToSchema)]
32#[schema(example = json!({"url": "https://rpc.example.com", "weight": 100}))]
33pub struct RpcConfig {
34    /// The RPC endpoint URL.
35    pub url: String,
36    /// The weight of this endpoint in the weighted round-robin selection.
37    /// Defaults to [`DEFAULT_RPC_WEIGHT`]. Should be between 0 and 100.
38    #[schema(default = default_rpc_weight, minimum = 0, maximum = 100)]
39    pub weight: u8,
40}
41
42impl Hash for RpcConfig {
43    fn hash<H: Hasher>(&self, state: &mut H) {
44        self.url.hash(state);
45        self.weight.hash(state);
46    }
47}
48
49impl Serialize for RpcConfig {
50    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
51    where
52        S: Serializer,
53    {
54        let mut state = serializer.serialize_struct("RpcConfig", 2)?;
55        state.serialize_field("url", &self.url)?;
56        state.serialize_field("weight", &self.weight)?;
57        state.end()
58    }
59}
60
61impl<'de> Deserialize<'de> for RpcConfig {
62    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
63    where
64        D: Deserializer<'de>,
65    {
66        #[derive(Deserialize)]
67        struct RpcConfigHelper {
68            url: String,
69            weight: Option<u8>,
70        }
71
72        let helper = RpcConfigHelper::deserialize(deserializer)?;
73        Ok(RpcConfig {
74            url: helper.url,
75            weight: helper.weight.unwrap_or(DEFAULT_RPC_WEIGHT),
76        })
77    }
78}
79
80impl RpcConfig {
81    /// Creates a new RPC configuration with the given URL and default weight (DEFAULT_RPC_WEIGHT).
82    ///
83    /// # Arguments
84    ///
85    /// * `url` - A string slice that holds the URL of the RPC endpoint.
86    pub fn new(url: String) -> Self {
87        Self {
88            url,
89            weight: DEFAULT_RPC_WEIGHT,
90        }
91    }
92
93    /// Creates a new RPC configuration with the given URL and weight.
94    ///
95    /// # Arguments
96    ///
97    /// * `url` - A string that holds the URL of the RPC endpoint.
98    /// * `weight` - A u8 value representing the weight of the endpoint. Must be between 0 and 100 (inclusive).
99    ///
100    /// # Returns
101    ///
102    /// * `Ok(RpcConfig)` if the weight is valid.
103    /// * `Err(RpcConfigError::InvalidWeight)` if the weight is greater than 100.
104    pub fn with_weight(url: String, weight: u8) -> Result<Self, RpcConfigError> {
105        if weight > 100 {
106            return Err(RpcConfigError::InvalidWeight { value: weight });
107        }
108        Ok(Self { url, weight })
109    }
110
111    /// Gets the weight of this RPC endpoint.
112    ///
113    /// # Returns
114    ///
115    /// * `u8` - The weight of the RPC endpoint.
116    pub fn get_weight(&self) -> u8 {
117        self.weight
118    }
119
120    /// Validates that a URL has an HTTP or HTTPS scheme.
121    /// Helper function, hence private.
122    fn validate_url_scheme(url: &str) -> Result<(), eyre::Report> {
123        if !url.starts_with("http://") && !url.starts_with("https://") {
124            return Err(eyre!(
125                "Invalid URL scheme for {}: Only HTTP and HTTPS are supported",
126                url
127            ));
128        }
129        Ok(())
130    }
131
132    /// Validates all URLs in a slice of RpcConfig objects.
133    ///
134    /// # Arguments
135    /// * `configs` - A slice of RpcConfig objects
136    ///
137    /// # Returns
138    /// * `Result<()>` - Ok if all URLs have valid schemes, error on first invalid URL
139    ///
140    /// # Examples
141    /// ```rust, ignore
142    /// use openzeppelin_relayer::models::RpcConfig;
143    ///
144    /// let configs = vec![
145    ///     RpcConfig::new("https://api.example.com".to_string()),
146    ///     RpcConfig::new("http://localhost:8545".to_string()),
147    /// ];
148    /// assert!(RpcConfig::validate_list(&configs).is_ok());
149    /// ```
150    pub fn validate_list(configs: &[RpcConfig]) -> Result<(), eyre::Report> {
151        for config in configs {
152            // Call the helper function using Self to refer to the type for associated functions
153            Self::validate_url_scheme(&config.url)?;
154        }
155        Ok(())
156    }
157}
158
159/// RPC configuration with masked URL for API responses.
160///
161/// This type is used in API responses to prevent exposing sensitive API keys
162/// that are often embedded in RPC endpoint URLs (e.g., Alchemy, Infura, QuickNode).
163/// The URL path and query parameters are masked while keeping the host visible,
164/// allowing users to identify which provider is configured.
165#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
166#[schema(example = json!({"url": "https://eth-mainnet.g.alchemy.com/***", "weight": 100}))]
167pub struct MaskedRpcConfig {
168    /// The RPC endpoint URL with path/query masked.
169    pub url: String,
170    /// The weight of this endpoint in the weighted round-robin selection.
171    #[schema(minimum = 0, maximum = 100)]
172    pub weight: u8,
173}
174
175impl From<&RpcConfig> for MaskedRpcConfig {
176    fn from(config: &RpcConfig) -> Self {
177        Self {
178            url: mask_url(&config.url),
179            weight: config.weight,
180        }
181    }
182}
183
184impl From<RpcConfig> for MaskedRpcConfig {
185    fn from(config: RpcConfig) -> Self {
186        Self::from(&config)
187    }
188}
189
190/// Custom deserializer for `Option<Vec<RpcConfig>>` that supports multiple input formats.
191///
192/// This function is designed to be used with `#[serde(deserialize_with = "...")]` and supports:
193///
194/// - **Simple format**: Array of strings, e.g., `["https://rpc1.com", "https://rpc2.com"]`
195///   Each string is converted to an `RpcConfig` with default weight (100).
196///
197/// - **Extended format**: Array of objects, e.g., `[{"url": "https://rpc.com", "weight": 50}]`
198///   Each object is deserialized directly as an `RpcConfig`.
199///
200/// - **Mixed format**: Array containing both strings and objects
201///   e.g., `["https://rpc1.com", {"url": "https://rpc2.com", "weight": 50}]`
202///
203/// # Example Usage
204///
205/// ```rust,ignore
206/// use serde::Deserialize;
207/// use openzeppelin_relayer::models::relayer::{RpcConfig, deserialize_rpc_urls};
208///
209/// #[derive(Deserialize)]
210/// struct MyConfig {
211///     #[serde(default, deserialize_with = "deserialize_rpc_urls")]
212///     rpc_urls: Option<Vec<RpcConfig>>,
213/// }
214/// ```
215pub fn deserialize_rpc_urls<'de, D>(deserializer: D) -> Result<Option<Vec<RpcConfig>>, D::Error>
216where
217    D: Deserializer<'de>,
218{
219    // First, deserialize as a generic Value to check what we have
220    let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
221
222    match value {
223        None => Ok(None),
224        Some(serde_json::Value::Array(arr)) => {
225            let mut configs = Vec::with_capacity(arr.len());
226            for item in arr {
227                match item {
228                    serde_json::Value::String(url) => {
229                        // Simple format: string -> convert to RpcConfig with default weight
230                        configs.push(RpcConfig::new(url));
231                    }
232                    serde_json::Value::Object(obj) => {
233                        // Extended format: object -> deserialize as RpcConfig
234                        let config: RpcConfig =
235                            serde_json::from_value(serde_json::Value::Object(obj))
236                                .map_err(DeError::custom)?;
237                        configs.push(config);
238                    }
239                    _ => {
240                        return Err(DeError::custom(
241                            "rpc_urls must be an array of strings or RpcConfig objects",
242                        ));
243                    }
244                }
245            }
246            Ok(Some(configs))
247        }
248        Some(_) => Err(DeError::custom(
249            "rpc_urls must be an array of strings or RpcConfig objects",
250        )),
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::constants::DEFAULT_RPC_WEIGHT;
258
259    #[test]
260    fn test_new_creates_config_with_default_weight() {
261        let url = "https://example.com".to_string();
262        let config = RpcConfig::new(url.clone());
263
264        assert_eq!(config.url, url);
265        assert_eq!(config.weight, DEFAULT_RPC_WEIGHT);
266    }
267
268    #[test]
269    fn test_with_weight_creates_config_with_custom_weight() {
270        let url = "https://example.com".to_string();
271        let weight: u8 = 5;
272        let result = RpcConfig::with_weight(url.clone(), weight);
273        assert!(result.is_ok());
274
275        let config = result.unwrap();
276        assert_eq!(config.url, url);
277        assert_eq!(config.weight, weight);
278    }
279
280    #[test]
281    fn test_get_weight_returns_weight_value() {
282        let url = "https://example.com".to_string();
283        let weight: u8 = 10;
284        let config = RpcConfig {
285            url,
286            weight,
287            ..Default::default()
288        };
289
290        assert_eq!(config.get_weight(), weight);
291    }
292
293    #[test]
294    fn test_equality_of_configs() {
295        let url = "https://example.com".to_string();
296        let config1 = RpcConfig::new(url.clone());
297        let config2 = RpcConfig::new(url.clone()); // Same as config1
298        let config3 = RpcConfig::with_weight(url.clone(), 5u8).unwrap(); // Different weight
299        let config4 =
300            RpcConfig::with_weight("https://different.com".to_string(), DEFAULT_RPC_WEIGHT)
301                .unwrap(); // Different URL
302
303        assert_eq!(config1, config2);
304        assert_ne!(config1, config3);
305        assert_ne!(config1, config4);
306    }
307
308    // Tests for URL validation
309    #[test]
310    fn test_validate_url_scheme_with_http() {
311        let result = RpcConfig::validate_url_scheme("http://example.com");
312        assert!(result.is_ok(), "HTTP URL should be valid");
313    }
314
315    #[test]
316    fn test_validate_url_scheme_with_https() {
317        let result = RpcConfig::validate_url_scheme("https://secure.example.com");
318        assert!(result.is_ok(), "HTTPS URL should be valid");
319    }
320
321    #[test]
322    fn test_validate_url_scheme_with_query_params() {
323        let result =
324            RpcConfig::validate_url_scheme("https://example.com/api?param=value&other=123");
325        assert!(result.is_ok(), "URL with query parameters should be valid");
326    }
327
328    #[test]
329    fn test_validate_url_scheme_with_port() {
330        let result = RpcConfig::validate_url_scheme("http://localhost:8545");
331        assert!(result.is_ok(), "URL with port should be valid");
332    }
333
334    #[test]
335    fn test_validate_url_scheme_with_ftp() {
336        let result = RpcConfig::validate_url_scheme("ftp://example.com");
337        assert!(result.is_err(), "FTP URL should be invalid");
338    }
339
340    #[test]
341    fn test_validate_url_scheme_with_invalid_url() {
342        let result = RpcConfig::validate_url_scheme("invalid-url");
343        assert!(result.is_err(), "Invalid URL format should be rejected");
344    }
345
346    #[test]
347    fn test_validate_url_scheme_with_empty_string() {
348        let result = RpcConfig::validate_url_scheme("");
349        assert!(result.is_err(), "Empty string should be rejected");
350    }
351
352    // Tests for validate_list function
353    #[test]
354    fn test_validate_list_with_empty_vec() {
355        let configs: Vec<RpcConfig> = vec![];
356        let result = RpcConfig::validate_list(&configs);
357        assert!(result.is_ok(), "Empty config vector should be valid");
358    }
359
360    #[test]
361    fn test_validate_list_with_valid_urls() {
362        let configs = vec![
363            RpcConfig::new("https://api.example.com".to_string()),
364            RpcConfig::new("http://localhost:8545".to_string()),
365        ];
366        let result = RpcConfig::validate_list(&configs);
367        assert!(result.is_ok(), "All URLs are valid, should return Ok");
368    }
369
370    #[test]
371    fn test_validate_list_with_one_invalid_url() {
372        let configs = vec![
373            RpcConfig::new("https://api.example.com".to_string()),
374            RpcConfig::new("ftp://invalid-scheme.com".to_string()),
375            RpcConfig::new("http://another-valid.com".to_string()),
376        ];
377        let result = RpcConfig::validate_list(&configs);
378        assert!(result.is_err(), "Should fail on first invalid URL");
379    }
380
381    #[test]
382    fn test_validate_list_with_all_invalid_urls() {
383        let configs = vec![
384            RpcConfig::new("ws://websocket.example.com".to_string()),
385            RpcConfig::new("ftp://invalid-scheme.com".to_string()),
386        ];
387        let result = RpcConfig::validate_list(&configs);
388        assert!(result.is_err(), "Should fail with all invalid URLs");
389    }
390
391    // =========================================================================
392    // Tests for deserialize_rpc_urls function
393    // =========================================================================
394
395    /// Helper struct to test the deserialize_rpc_urls function via serde
396    #[derive(Deserialize, Debug)]
397    struct TestRpcUrlsContainer {
398        #[serde(default, deserialize_with = "super::deserialize_rpc_urls")]
399        rpc_urls: Option<Vec<RpcConfig>>,
400    }
401
402    #[test]
403    fn test_deserialize_rpc_urls_simple_format_single_url() {
404        let json = r#"{"rpc_urls": ["https://rpc.example.com"]}"#;
405        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
406
407        let urls = result.rpc_urls.unwrap();
408        assert_eq!(urls.len(), 1);
409        assert_eq!(urls[0].url, "https://rpc.example.com");
410        assert_eq!(urls[0].weight, DEFAULT_RPC_WEIGHT);
411    }
412
413    #[test]
414    fn test_deserialize_rpc_urls_simple_format_multiple_urls() {
415        let json = r#"{"rpc_urls": ["https://rpc1.com", "https://rpc2.com", "https://rpc3.com"]}"#;
416        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
417
418        let urls = result.rpc_urls.unwrap();
419        assert_eq!(urls.len(), 3);
420        assert_eq!(urls[0].url, "https://rpc1.com");
421        assert_eq!(urls[1].url, "https://rpc2.com");
422        assert_eq!(urls[2].url, "https://rpc3.com");
423        // All should have default weight
424        for url in &urls {
425            assert_eq!(url.weight, DEFAULT_RPC_WEIGHT);
426        }
427    }
428
429    #[test]
430    fn test_deserialize_rpc_urls_extended_format_single_config() {
431        let json = r#"{"rpc_urls": [{"url": "https://rpc.example.com", "weight": 50}]}"#;
432        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
433
434        let urls = result.rpc_urls.unwrap();
435        assert_eq!(urls.len(), 1);
436        assert_eq!(urls[0].url, "https://rpc.example.com");
437        assert_eq!(urls[0].weight, 50);
438    }
439
440    #[test]
441    fn test_deserialize_rpc_urls_extended_format_multiple_configs() {
442        let json = r#"{"rpc_urls": [
443            {"url": "https://primary.com", "weight": 80},
444            {"url": "https://secondary.com", "weight": 15},
445            {"url": "https://fallback.com", "weight": 5}
446        ]}"#;
447        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
448
449        let urls = result.rpc_urls.unwrap();
450        assert_eq!(urls.len(), 3);
451        assert_eq!(urls[0].url, "https://primary.com");
452        assert_eq!(urls[0].weight, 80);
453        assert_eq!(urls[1].url, "https://secondary.com");
454        assert_eq!(urls[1].weight, 15);
455        assert_eq!(urls[2].url, "https://fallback.com");
456        assert_eq!(urls[2].weight, 5);
457    }
458
459    #[test]
460    fn test_deserialize_rpc_urls_extended_format_without_weight() {
461        // When weight is omitted in extended format, it should default
462        let json = r#"{"rpc_urls": [{"url": "https://rpc.example.com"}]}"#;
463        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
464
465        let urls = result.rpc_urls.unwrap();
466        assert_eq!(urls.len(), 1);
467        assert_eq!(urls[0].url, "https://rpc.example.com");
468        assert_eq!(urls[0].weight, DEFAULT_RPC_WEIGHT);
469    }
470
471    #[test]
472    fn test_deserialize_rpc_urls_mixed_format() {
473        let json = r#"{"rpc_urls": [
474            "https://simple.com",
475            {"url": "https://weighted.com", "weight": 75},
476            "https://another-simple.com"
477        ]}"#;
478        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
479
480        let urls = result.rpc_urls.unwrap();
481        assert_eq!(urls.len(), 3);
482
483        // First: simple string format
484        assert_eq!(urls[0].url, "https://simple.com");
485        assert_eq!(urls[0].weight, DEFAULT_RPC_WEIGHT);
486
487        // Second: extended object format
488        assert_eq!(urls[1].url, "https://weighted.com");
489        assert_eq!(urls[1].weight, 75);
490
491        // Third: simple string format
492        assert_eq!(urls[2].url, "https://another-simple.com");
493        assert_eq!(urls[2].weight, DEFAULT_RPC_WEIGHT);
494    }
495
496    #[test]
497    fn test_deserialize_rpc_urls_none_when_field_missing() {
498        let json = r#"{}"#;
499        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
500
501        assert!(result.rpc_urls.is_none());
502    }
503
504    #[test]
505    fn test_deserialize_rpc_urls_none_when_null() {
506        let json = r#"{"rpc_urls": null}"#;
507        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
508
509        assert!(result.rpc_urls.is_none());
510    }
511
512    #[test]
513    fn test_deserialize_rpc_urls_empty_array() {
514        let json = r#"{"rpc_urls": []}"#;
515        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
516
517        let urls = result.rpc_urls.unwrap();
518        assert!(urls.is_empty());
519    }
520
521    #[test]
522    fn test_deserialize_rpc_urls_weight_zero() {
523        let json = r#"{"rpc_urls": [{"url": "https://disabled.com", "weight": 0}]}"#;
524        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
525
526        let urls = result.rpc_urls.unwrap();
527        assert_eq!(urls[0].weight, 0);
528    }
529
530    #[test]
531    fn test_deserialize_rpc_urls_weight_max() {
532        let json = r#"{"rpc_urls": [{"url": "https://max.com", "weight": 100}]}"#;
533        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
534
535        let urls = result.rpc_urls.unwrap();
536        assert_eq!(urls[0].weight, 100);
537    }
538
539    #[test]
540    fn test_deserialize_rpc_urls_invalid_not_array() {
541        let json = r#"{"rpc_urls": "https://not-an-array.com"}"#;
542        let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
543
544        assert!(result.is_err());
545        let err = result.unwrap_err().to_string();
546        assert!(
547            err.contains("rpc_urls must be an array"),
548            "Error should mention array requirement: {err}"
549        );
550    }
551
552    #[test]
553    fn test_deserialize_rpc_urls_invalid_number_in_array() {
554        let json = r#"{"rpc_urls": [123, 456]}"#;
555        let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
556
557        assert!(result.is_err());
558        let err = result.unwrap_err().to_string();
559        assert!(
560            err.contains("rpc_urls must be an array of strings or RpcConfig objects"),
561            "Error should mention valid types: {err}"
562        );
563    }
564
565    #[test]
566    fn test_deserialize_rpc_urls_invalid_boolean_in_array() {
567        let json = r#"{"rpc_urls": [true, false]}"#;
568        let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
569
570        assert!(result.is_err());
571    }
572
573    #[test]
574    fn test_deserialize_rpc_urls_invalid_nested_array() {
575        let json = r#"{"rpc_urls": [["nested", "array"]]}"#;
576        let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
577
578        assert!(result.is_err());
579    }
580
581    #[test]
582    fn test_deserialize_rpc_urls_invalid_object_in_array() {
583        let json = r#"{"rpc_urls": {"not": "an_array"}}"#;
584        let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
585
586        assert!(result.is_err());
587    }
588
589    #[test]
590    fn test_deserialize_rpc_urls_invalid_object_missing_url() {
591        // Object format requires 'url' field
592        let json = r#"{"rpc_urls": [{"weight": 50}]}"#;
593        let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
594
595        assert!(result.is_err());
596        let err = result.unwrap_err().to_string();
597        assert!(
598            err.contains("url") || err.contains("missing field"),
599            "Error should mention missing url field: {err}"
600        );
601    }
602
603    #[test]
604    fn test_deserialize_rpc_urls_mixed_valid_and_invalid() {
605        // One valid string followed by an invalid number
606        let json = r#"{"rpc_urls": ["https://valid.com", 12345]}"#;
607        let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
608
609        assert!(result.is_err());
610    }
611
612    #[test]
613    fn test_deserialize_rpc_urls_preserves_url_with_special_chars() {
614        let json = r#"{"rpc_urls": ["https://rpc.example.com/v1?api_key=abc123&network=mainnet"]}"#;
615        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
616
617        let urls = result.rpc_urls.unwrap();
618        assert_eq!(
619            urls[0].url,
620            "https://rpc.example.com/v1?api_key=abc123&network=mainnet"
621        );
622    }
623
624    #[test]
625    fn test_deserialize_rpc_urls_preserves_url_with_port() {
626        let json = r#"{"rpc_urls": ["http://localhost:8545"]}"#;
627        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
628
629        let urls = result.rpc_urls.unwrap();
630        assert_eq!(urls[0].url, "http://localhost:8545");
631    }
632
633    #[test]
634    fn test_deserialize_rpc_urls_unicode_url() {
635        let json = r#"{"rpc_urls": ["https://测试.example.com"]}"#;
636        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
637
638        let urls = result.rpc_urls.unwrap();
639        assert_eq!(urls[0].url, "https://测试.example.com");
640    }
641
642    #[test]
643    fn test_deserialize_rpc_urls_empty_string_url() {
644        // Empty string is technically valid JSON, deserialization should succeed
645        // (validation happens at a different layer)
646        let json = r#"{"rpc_urls": [""]}"#;
647        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
648
649        let urls = result.rpc_urls.unwrap();
650        assert_eq!(urls[0].url, "");
651    }
652
653    #[test]
654    fn test_deserialize_rpc_urls_whitespace_url() {
655        let json = r#"{"rpc_urls": ["  https://rpc.example.com  "]}"#;
656        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
657
658        let urls = result.rpc_urls.unwrap();
659        // Whitespace is preserved (trimming is a validation concern)
660        assert_eq!(urls[0].url, "  https://rpc.example.com  ");
661    }
662
663    // =========================================================================
664    // Tests for MaskedRpcConfig
665    // =========================================================================
666
667    #[test]
668    fn test_masked_rpc_config_from_rpc_config() {
669        let config = RpcConfig::new("https://eth-mainnet.g.alchemy.com/v2/secret-key".to_string());
670        let masked: MaskedRpcConfig = config.into();
671
672        assert_eq!(masked.url, "https://eth-mainnet.g.alchemy.com/***");
673        assert_eq!(masked.weight, DEFAULT_RPC_WEIGHT);
674    }
675
676    #[test]
677    fn test_masked_rpc_config_preserves_weight() {
678        let config =
679            RpcConfig::with_weight("https://mainnet.infura.io/v3/project-id".to_string(), 75)
680                .unwrap();
681        let masked: MaskedRpcConfig = config.into();
682
683        assert_eq!(masked.url, "https://mainnet.infura.io/***");
684        assert_eq!(masked.weight, 75);
685    }
686
687    #[test]
688    fn test_masked_rpc_config_from_reference() {
689        let config = RpcConfig::new("https://rpc.ankr.com/eth/secret".to_string());
690        let masked = MaskedRpcConfig::from(&config);
691
692        assert_eq!(masked.url, "https://rpc.ankr.com/***");
693        assert_eq!(masked.weight, DEFAULT_RPC_WEIGHT);
694    }
695
696    #[test]
697    fn test_masked_rpc_config_serialization() {
698        let masked = MaskedRpcConfig {
699            url: "https://eth-mainnet.g.alchemy.com/***".to_string(),
700            weight: 100,
701        };
702
703        let serialized = serde_json::to_string(&masked).unwrap();
704        assert!(serialized.contains("https://eth-mainnet.g.alchemy.com/***"));
705        assert!(serialized.contains("100"));
706    }
707
708    #[test]
709    fn test_masked_rpc_config_deserialization() {
710        let json = r#"{"url": "https://rpc.example.com/***", "weight": 50}"#;
711        let masked: MaskedRpcConfig = serde_json::from_str(json).unwrap();
712
713        assert_eq!(masked.url, "https://rpc.example.com/***");
714        assert_eq!(masked.weight, 50);
715    }
716}