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: {}",
549            err
550        );
551    }
552
553    #[test]
554    fn test_deserialize_rpc_urls_invalid_number_in_array() {
555        let json = r#"{"rpc_urls": [123, 456]}"#;
556        let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
557
558        assert!(result.is_err());
559        let err = result.unwrap_err().to_string();
560        assert!(
561            err.contains("rpc_urls must be an array of strings or RpcConfig objects"),
562            "Error should mention valid types: {}",
563            err
564        );
565    }
566
567    #[test]
568    fn test_deserialize_rpc_urls_invalid_boolean_in_array() {
569        let json = r#"{"rpc_urls": [true, false]}"#;
570        let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
571
572        assert!(result.is_err());
573    }
574
575    #[test]
576    fn test_deserialize_rpc_urls_invalid_nested_array() {
577        let json = r#"{"rpc_urls": [["nested", "array"]]}"#;
578        let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
579
580        assert!(result.is_err());
581    }
582
583    #[test]
584    fn test_deserialize_rpc_urls_invalid_object_in_array() {
585        let json = r#"{"rpc_urls": {"not": "an_array"}}"#;
586        let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
587
588        assert!(result.is_err());
589    }
590
591    #[test]
592    fn test_deserialize_rpc_urls_invalid_object_missing_url() {
593        // Object format requires 'url' field
594        let json = r#"{"rpc_urls": [{"weight": 50}]}"#;
595        let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
596
597        assert!(result.is_err());
598        let err = result.unwrap_err().to_string();
599        assert!(
600            err.contains("url") || err.contains("missing field"),
601            "Error should mention missing url field: {}",
602            err
603        );
604    }
605
606    #[test]
607    fn test_deserialize_rpc_urls_mixed_valid_and_invalid() {
608        // One valid string followed by an invalid number
609        let json = r#"{"rpc_urls": ["https://valid.com", 12345]}"#;
610        let result: Result<TestRpcUrlsContainer, _> = serde_json::from_str(json);
611
612        assert!(result.is_err());
613    }
614
615    #[test]
616    fn test_deserialize_rpc_urls_preserves_url_with_special_chars() {
617        let json = r#"{"rpc_urls": ["https://rpc.example.com/v1?api_key=abc123&network=mainnet"]}"#;
618        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
619
620        let urls = result.rpc_urls.unwrap();
621        assert_eq!(
622            urls[0].url,
623            "https://rpc.example.com/v1?api_key=abc123&network=mainnet"
624        );
625    }
626
627    #[test]
628    fn test_deserialize_rpc_urls_preserves_url_with_port() {
629        let json = r#"{"rpc_urls": ["http://localhost:8545"]}"#;
630        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
631
632        let urls = result.rpc_urls.unwrap();
633        assert_eq!(urls[0].url, "http://localhost:8545");
634    }
635
636    #[test]
637    fn test_deserialize_rpc_urls_unicode_url() {
638        let json = r#"{"rpc_urls": ["https://测试.example.com"]}"#;
639        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
640
641        let urls = result.rpc_urls.unwrap();
642        assert_eq!(urls[0].url, "https://测试.example.com");
643    }
644
645    #[test]
646    fn test_deserialize_rpc_urls_empty_string_url() {
647        // Empty string is technically valid JSON, deserialization should succeed
648        // (validation happens at a different layer)
649        let json = r#"{"rpc_urls": [""]}"#;
650        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
651
652        let urls = result.rpc_urls.unwrap();
653        assert_eq!(urls[0].url, "");
654    }
655
656    #[test]
657    fn test_deserialize_rpc_urls_whitespace_url() {
658        let json = r#"{"rpc_urls": ["  https://rpc.example.com  "]}"#;
659        let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap();
660
661        let urls = result.rpc_urls.unwrap();
662        // Whitespace is preserved (trimming is a validation concern)
663        assert_eq!(urls[0].url, "  https://rpc.example.com  ");
664    }
665
666    // =========================================================================
667    // Tests for MaskedRpcConfig
668    // =========================================================================
669
670    #[test]
671    fn test_masked_rpc_config_from_rpc_config() {
672        let config = RpcConfig::new("https://eth-mainnet.g.alchemy.com/v2/secret-key".to_string());
673        let masked: MaskedRpcConfig = config.into();
674
675        assert_eq!(masked.url, "https://eth-mainnet.g.alchemy.com/***");
676        assert_eq!(masked.weight, DEFAULT_RPC_WEIGHT);
677    }
678
679    #[test]
680    fn test_masked_rpc_config_preserves_weight() {
681        let config =
682            RpcConfig::with_weight("https://mainnet.infura.io/v3/project-id".to_string(), 75)
683                .unwrap();
684        let masked: MaskedRpcConfig = config.into();
685
686        assert_eq!(masked.url, "https://mainnet.infura.io/***");
687        assert_eq!(masked.weight, 75);
688    }
689
690    #[test]
691    fn test_masked_rpc_config_from_reference() {
692        let config = RpcConfig::new("https://rpc.ankr.com/eth/secret".to_string());
693        let masked = MaskedRpcConfig::from(&config);
694
695        assert_eq!(masked.url, "https://rpc.ankr.com/***");
696        assert_eq!(masked.weight, DEFAULT_RPC_WEIGHT);
697    }
698
699    #[test]
700    fn test_masked_rpc_config_serialization() {
701        let masked = MaskedRpcConfig {
702            url: "https://eth-mainnet.g.alchemy.com/***".to_string(),
703            weight: 100,
704        };
705
706        let serialized = serde_json::to_string(&masked).unwrap();
707        assert!(serialized.contains("https://eth-mainnet.g.alchemy.com/***"));
708        assert!(serialized.contains("100"));
709    }
710
711    #[test]
712    fn test_masked_rpc_config_deserialization() {
713        let json = r#"{"url": "https://rpc.example.com/***", "weight": 50}"#;
714        let masked: MaskedRpcConfig = serde_json::from_str(json).unwrap();
715
716        assert_eq!(masked.url, "https://rpc.example.com/***");
717        assert_eq!(masked.weight, 50);
718    }
719}