openzeppelin_relayer/config/config_file/network/
evm.rs

1//! EVM Network Configuration
2//!
3//! This module provides configuration support for EVM-compatible blockchain networks
4//! such as Ethereum, Polygon, BSC, Avalanche, and other Ethereum-compatible chains.
5//!
6//! ## Key Features
7//!
8//! - **Full inheritance support**: EVM networks can inherit from other EVM networks
9//! - **Feature merging**: Parent and child features are merged preserving unique items
10//! - **Type safety**: Inheritance only allowed between EVM networks
11
12use super::common::{merge_optional_string_vecs, NetworkConfigCommon};
13use crate::config::ConfigFileError;
14use serde::{Deserialize, Serialize};
15
16/// Configuration specific to EVM-compatible networks.
17#[derive(Debug, Serialize, Deserialize, Clone)]
18#[serde(deny_unknown_fields)]
19pub struct EvmNetworkConfig {
20    /// Common network fields.
21    #[serde(flatten)]
22    pub common: NetworkConfigCommon,
23
24    /// The unique chain identifier (Chain ID) for the EVM network.
25    pub chain_id: Option<u64>,
26    /// Number of block confirmations required before a transaction is considered final.
27    pub required_confirmations: Option<u64>,
28    /// List of specific features supported by the network (e.g., "eip1559").
29    pub features: Option<Vec<String>>,
30    /// The symbol of the network's native currency (e.g., "ETH", "MATIC").
31    pub symbol: Option<String>,
32}
33
34impl EvmNetworkConfig {
35    /// Validates the specific configuration fields for an EVM network.
36    ///
37    /// # Returns
38    /// - `Ok(())` if the EVM configuration is valid.
39    /// - `Err(ConfigFileError)` if validation fails (e.g., missing fields, invalid URLs).
40    pub fn validate(&self) -> Result<(), ConfigFileError> {
41        self.common.validate()?;
42
43        // Chain ID is required for non-inherited networks
44        if self.chain_id.is_none() {
45            return Err(ConfigFileError::MissingField("chain_id".into()));
46        }
47
48        if self.required_confirmations.is_none() {
49            return Err(ConfigFileError::MissingField(
50                "required_confirmations".into(),
51            ));
52        }
53
54        if self.symbol.is_none() || self.symbol.as_ref().unwrap_or(&String::new()).is_empty() {
55            return Err(ConfigFileError::MissingField("symbol".into()));
56        }
57
58        Ok(())
59    }
60
61    /// Creates a new EVM configuration by merging this config with a parent, where child values override parent defaults.
62    ///
63    /// # Arguments
64    /// * `parent` - The parent EVM configuration to merge with.
65    ///
66    /// # Returns
67    /// A new `EvmNetworkConfig` with merged values where child takes precedence over parent.
68    pub fn merge_with_parent(&self, parent: &Self) -> Self {
69        Self {
70            common: self.common.merge_with_parent(&parent.common),
71            chain_id: self.chain_id.or(parent.chain_id),
72            required_confirmations: self
73                .required_confirmations
74                .or(parent.required_confirmations),
75            features: merge_optional_string_vecs(&self.features, &parent.features),
76            symbol: self.symbol.clone().or_else(|| parent.symbol.clone()),
77        }
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::config::config_file::network::test_utils::*;
85
86    #[test]
87    fn test_validate_success_complete_config() {
88        let config = create_evm_network("ethereum-mainnet");
89        let result = config.validate();
90        assert!(result.is_ok());
91    }
92
93    #[test]
94    fn test_validate_success_minimal_config() {
95        let mut config = create_evm_network("minimal-evm");
96        config.features = None;
97        let result = config.validate();
98        assert!(result.is_ok());
99    }
100
101    #[test]
102    fn test_validate_missing_chain_id() {
103        let mut config = create_evm_network("ethereum-mainnet");
104        config.chain_id = None;
105
106        let result = config.validate();
107        assert!(result.is_err());
108        assert!(matches!(
109            result.unwrap_err(),
110            ConfigFileError::MissingField(_)
111        ));
112    }
113
114    #[test]
115    fn test_validate_missing_required_confirmations() {
116        let mut config = create_evm_network("ethereum-mainnet");
117        config.required_confirmations = None;
118
119        let result = config.validate();
120        assert!(result.is_err());
121        assert!(matches!(
122            result.unwrap_err(),
123            ConfigFileError::MissingField(_)
124        ));
125    }
126
127    #[test]
128    fn test_validate_missing_symbol() {
129        let mut config = create_evm_network("ethereum-mainnet");
130        config.symbol = None;
131
132        let result = config.validate();
133        assert!(result.is_err());
134        assert!(matches!(
135            result.unwrap_err(),
136            ConfigFileError::MissingField(_)
137        ));
138    }
139
140    #[test]
141    fn test_validate_invalid_common_fields() {
142        let mut config = create_evm_network("ethereum-mainnet");
143        config.common.network = String::new(); // Invalid empty network name
144
145        let result = config.validate();
146        assert!(result.is_err());
147        assert!(matches!(
148            result.unwrap_err(),
149            ConfigFileError::MissingField(_)
150        ));
151    }
152
153    #[test]
154    fn test_validate_invalid_rpc_urls() {
155        let mut config = create_evm_network("ethereum-mainnet");
156        config.common.rpc_urls = Some(vec!["invalid-url".to_string()]);
157
158        let result = config.validate();
159        assert!(result.is_err());
160        assert!(matches!(
161            result.unwrap_err(),
162            ConfigFileError::InvalidFormat(_)
163        ));
164    }
165
166    #[test]
167    fn test_validate_with_zero_chain_id() {
168        let mut config = create_evm_network("ethereum-mainnet");
169        config.chain_id = Some(0);
170
171        let result = config.validate();
172        assert!(result.is_ok()); // Zero is a valid chain ID
173    }
174
175    #[test]
176    fn test_validate_with_large_chain_id() {
177        let mut config = create_evm_network("ethereum-mainnet");
178        config.chain_id = Some(u64::MAX);
179
180        let result = config.validate();
181        assert!(result.is_ok());
182    }
183
184    #[test]
185    fn test_validate_with_zero_confirmations() {
186        let mut config = create_evm_network("ethereum-mainnet");
187        config.required_confirmations = Some(0);
188
189        let result = config.validate();
190        assert!(result.is_ok()); // Zero confirmations is valid
191    }
192
193    #[test]
194    fn test_validate_with_empty_features() {
195        let mut config = create_evm_network("ethereum-mainnet");
196        config.features = Some(vec![]);
197
198        let result = config.validate();
199        assert!(result.is_ok());
200    }
201
202    #[test]
203    fn test_validate_with_empty_symbol() {
204        let mut config = create_evm_network("ethereum-mainnet");
205        config.symbol = Some(String::new());
206
207        let result = config.validate();
208        assert!(result.is_err());
209    }
210
211    #[test]
212    fn test_merge_with_parent_child_overrides() {
213        let parent = EvmNetworkConfig {
214            common: NetworkConfigCommon {
215                network: "parent".to_string(),
216                from: None,
217                rpc_urls: Some(vec!["https://parent-rpc.example.com".to_string()]),
218                explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]),
219                average_blocktime_ms: Some(10000),
220                is_testnet: Some(true),
221                tags: Some(vec!["parent-tag".to_string()]),
222            },
223            chain_id: Some(1),
224            required_confirmations: Some(6),
225            features: Some(vec!["legacy".to_string()]),
226            symbol: Some("PETH".to_string()),
227        };
228
229        let child = EvmNetworkConfig {
230            common: NetworkConfigCommon {
231                network: "child".to_string(),
232                from: Some("parent".to_string()),
233                rpc_urls: Some(vec!["https://child-rpc.example.com".to_string()]),
234                explorer_urls: Some(vec!["https://child-explorer.example.com".to_string()]),
235                average_blocktime_ms: Some(15000),
236                is_testnet: Some(false),
237                tags: Some(vec!["child-tag".to_string()]),
238            },
239            chain_id: Some(31337),
240            required_confirmations: Some(1),
241            features: Some(vec!["eip1559".to_string()]),
242            symbol: Some("CETH".to_string()),
243        };
244
245        let result = child.merge_with_parent(&parent);
246
247        // Child values should override parent values
248        assert_eq!(result.common.network, "child");
249        assert_eq!(result.common.from, Some("parent".to_string()));
250        assert_eq!(
251            result.common.rpc_urls,
252            Some(vec!["https://child-rpc.example.com".to_string()])
253        );
254        assert_eq!(
255            result.common.explorer_urls,
256            Some(vec!["https://child-explorer.example.com".to_string()])
257        );
258        assert_eq!(result.common.average_blocktime_ms, Some(15000));
259        assert_eq!(result.common.is_testnet, Some(false));
260        assert_eq!(
261            result.common.tags,
262            Some(vec!["parent-tag".to_string(), "child-tag".to_string()])
263        );
264        assert_eq!(result.chain_id, Some(31337));
265        assert_eq!(result.required_confirmations, Some(1));
266        assert_eq!(
267            result.features,
268            Some(vec!["legacy".to_string(), "eip1559".to_string()])
269        );
270        assert_eq!(result.symbol, Some("CETH".to_string()));
271    }
272
273    #[test]
274    fn test_merge_with_parent_child_inherits() {
275        let parent = EvmNetworkConfig {
276            common: NetworkConfigCommon {
277                network: "parent".to_string(),
278                from: None,
279                rpc_urls: Some(vec!["https://parent-rpc.example.com".to_string()]),
280                explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]),
281                average_blocktime_ms: Some(10000),
282                is_testnet: Some(true),
283                tags: Some(vec!["parent-tag".to_string()]),
284            },
285            chain_id: Some(1),
286            required_confirmations: Some(6),
287            features: Some(vec!["eip1559".to_string()]),
288            symbol: Some("ETH".to_string()),
289        };
290
291        let child = create_evm_network_for_inheritance_test("ethereum-testnet", "ethereum-mainnet");
292
293        let result = child.merge_with_parent(&parent);
294
295        // Child should inherit parent values where child has None
296        assert_eq!(result.common.network, "ethereum-testnet");
297        assert_eq!(result.common.from, Some("ethereum-mainnet".to_string()));
298        assert_eq!(
299            result.common.rpc_urls,
300            Some(vec!["https://parent-rpc.example.com".to_string()])
301        );
302        assert_eq!(
303            result.common.explorer_urls,
304            Some(vec!["https://parent-explorer.example.com".to_string()])
305        );
306        assert_eq!(result.common.average_blocktime_ms, Some(10000));
307        assert_eq!(result.common.is_testnet, Some(true));
308        assert_eq!(result.common.tags, Some(vec!["parent-tag".to_string()]));
309        assert_eq!(result.chain_id, Some(1));
310        assert_eq!(result.required_confirmations, Some(6));
311        assert_eq!(result.features, Some(vec!["eip1559".to_string()]));
312        assert_eq!(result.symbol, Some("ETH".to_string()));
313    }
314
315    #[test]
316    fn test_merge_with_parent_mixed_inheritance() {
317        let parent = EvmNetworkConfig {
318            common: NetworkConfigCommon {
319                network: "parent".to_string(),
320                from: None,
321                rpc_urls: Some(vec!["https://parent-rpc.example.com".to_string()]),
322                explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]),
323                average_blocktime_ms: Some(10000),
324                is_testnet: Some(true),
325                tags: Some(vec!["parent-tag1".to_string(), "parent-tag2".to_string()]),
326            },
327            chain_id: Some(1),
328            required_confirmations: Some(6),
329            features: Some(vec!["eip155".to_string(), "eip1559".to_string()]),
330            symbol: Some("ETH".to_string()),
331        };
332
333        let child = EvmNetworkConfig {
334            common: NetworkConfigCommon {
335                network: "child".to_string(),
336                from: Some("parent".to_string()),
337                rpc_urls: Some(vec!["https://child-rpc.example.com".to_string()]), // Override
338                explorer_urls: Some(vec!["https://child-explorer.example.com".to_string()]), // Override
339                average_blocktime_ms: None,                // Inherit
340                is_testnet: Some(false),                   // Override
341                tags: Some(vec!["child-tag".to_string()]), // Merge
342            },
343            chain_id: Some(31337),                       // Override
344            required_confirmations: None,                // Inherit
345            features: Some(vec!["eip2930".to_string()]), // Merge
346            symbol: None,                                // Inherit
347        };
348
349        let result = child.merge_with_parent(&parent);
350
351        assert_eq!(result.common.network, "child");
352        assert_eq!(
353            result.common.rpc_urls,
354            Some(vec!["https://child-rpc.example.com".to_string()])
355        ); // Overridden
356        assert_eq!(
357            result.common.explorer_urls,
358            Some(vec!["https://child-explorer.example.com".to_string()])
359        ); // Overridden
360        assert_eq!(result.common.average_blocktime_ms, Some(10000)); // Inherited
361        assert_eq!(result.common.is_testnet, Some(false)); // Overridden
362        assert_eq!(
363            result.common.tags,
364            Some(vec![
365                "parent-tag1".to_string(),
366                "parent-tag2".to_string(),
367                "child-tag".to_string()
368            ])
369        ); // Merged
370        assert_eq!(result.chain_id, Some(31337)); // Overridden
371        assert_eq!(result.required_confirmations, Some(6)); // Inherited
372        assert_eq!(
373            result.features,
374            Some(vec![
375                "eip155".to_string(),
376                "eip1559".to_string(),
377                "eip2930".to_string()
378            ])
379        ); // Merged
380        assert_eq!(result.symbol, Some("ETH".to_string())); // Inherited
381    }
382
383    #[test]
384    fn test_merge_with_parent_both_empty() {
385        let parent = EvmNetworkConfig {
386            common: NetworkConfigCommon {
387                network: "parent".to_string(),
388                from: None,
389                rpc_urls: None,
390                explorer_urls: None,
391                average_blocktime_ms: None,
392                is_testnet: None,
393                tags: None,
394            },
395            chain_id: None,
396            required_confirmations: None,
397            features: None,
398            symbol: None,
399        };
400
401        let child = EvmNetworkConfig {
402            common: NetworkConfigCommon {
403                network: "child".to_string(),
404                from: Some("parent".to_string()),
405                rpc_urls: None,
406                explorer_urls: None,
407                average_blocktime_ms: None,
408                is_testnet: None,
409                tags: None,
410            },
411            chain_id: None,
412            required_confirmations: None,
413            features: None,
414            symbol: None,
415        };
416
417        let result = child.merge_with_parent(&parent);
418
419        assert_eq!(result.common.network, "child");
420        assert_eq!(result.common.from, Some("parent".to_string()));
421        assert_eq!(result.common.rpc_urls, None);
422        assert_eq!(result.common.average_blocktime_ms, None);
423        assert_eq!(result.common.is_testnet, None);
424        assert_eq!(result.common.tags, None);
425        assert_eq!(result.chain_id, None);
426        assert_eq!(result.required_confirmations, None);
427        assert_eq!(result.features, None);
428        assert_eq!(result.symbol, None);
429    }
430
431    #[test]
432    fn test_merge_with_parent_complex_features_merging() {
433        let parent = EvmNetworkConfig {
434            common: NetworkConfigCommon {
435                network: "parent".to_string(),
436                from: None,
437                rpc_urls: Some(vec!["https://rpc.example.com".to_string()]),
438                explorer_urls: Some(vec!["https://explorer.example.com".to_string()]),
439                average_blocktime_ms: Some(12000),
440                is_testnet: Some(false),
441                tags: None,
442            },
443            chain_id: Some(1),
444            required_confirmations: Some(12),
445            features: Some(vec![
446                "eip155".to_string(),
447                "eip1559".to_string(),
448                "shared".to_string(),
449            ]),
450            symbol: Some("ETH".to_string()),
451        };
452
453        let child = EvmNetworkConfig {
454            common: NetworkConfigCommon {
455                network: "child".to_string(),
456                from: Some("parent".to_string()),
457                rpc_urls: None,
458                explorer_urls: None,
459                average_blocktime_ms: None,
460                is_testnet: None,
461                tags: None,
462            },
463            chain_id: None,
464            required_confirmations: None,
465            features: Some(vec![
466                "shared".to_string(),
467                "eip2930".to_string(),
468                "custom".to_string(),
469            ]),
470            symbol: None,
471        };
472
473        let result = child.merge_with_parent(&parent);
474
475        // Features should be merged with parent first, then unique child features added
476        let expected_features = vec![
477            "eip155".to_string(),
478            "eip1559".to_string(),
479            "shared".to_string(), // Duplicate should not be added again
480            "eip2930".to_string(),
481            "custom".to_string(),
482        ];
483        assert_eq!(result.features, Some(expected_features));
484    }
485
486    #[test]
487    fn test_merge_with_parent_preserves_child_network_name() {
488        let parent = create_evm_network("ethereum-mainnet");
489        let mut child =
490            create_evm_network_for_inheritance_test("ethereum-testnet", "ethereum-mainnet");
491        child.common.network = "custom-child-name".to_string();
492
493        let result = child.merge_with_parent(&parent);
494
495        // Child network name should always be preserved
496        assert_eq!(result.common.network, "custom-child-name");
497    }
498
499    #[test]
500    fn test_merge_with_parent_preserves_child_from_field() {
501        let parent = EvmNetworkConfig {
502            common: NetworkConfigCommon {
503                network: "parent".to_string(),
504                from: Some("grandparent".to_string()),
505                rpc_urls: Some(vec!["https://parent.example.com".to_string()]),
506                explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]),
507                average_blocktime_ms: Some(10000),
508                is_testnet: Some(true),
509                tags: None,
510            },
511            chain_id: Some(1),
512            required_confirmations: Some(6),
513            features: None,
514            symbol: Some("ETH".to_string()),
515        };
516
517        let child = EvmNetworkConfig {
518            common: NetworkConfigCommon {
519                network: "child".to_string(),
520                from: Some("parent".to_string()),
521                rpc_urls: None,
522                explorer_urls: None,
523                average_blocktime_ms: None,
524                is_testnet: None,
525                tags: None,
526            },
527            chain_id: None,
528            required_confirmations: None,
529            features: None,
530            symbol: None,
531        };
532
533        let result = child.merge_with_parent(&parent);
534
535        // Child's 'from' field should be preserved, not inherited from parent
536        assert_eq!(result.common.from, Some("parent".to_string()));
537    }
538
539    #[test]
540    fn test_validate_with_unicode_symbol() {
541        let mut config = create_evm_network("ethereum-mainnet");
542        config.symbol = Some("Ξ".to_string()); // Greek Xi symbol for Ethereum
543
544        let result = config.validate();
545        assert!(result.is_ok());
546    }
547
548    #[test]
549    fn test_validate_with_unicode_features() {
550        let mut config = create_evm_network("ethereum-mainnet");
551        config.features = Some(vec!["eip1559".to_string(), "测试功能".to_string()]);
552
553        let result = config.validate();
554        assert!(result.is_ok());
555    }
556
557    #[test]
558    fn test_merge_with_parent_with_empty_features() {
559        let parent = EvmNetworkConfig {
560            common: NetworkConfigCommon {
561                network: "parent".to_string(),
562                from: None,
563                rpc_urls: Some(vec!["https://rpc.example.com".to_string()]),
564                explorer_urls: Some(vec!["https://explorer.example.com".to_string()]),
565                average_blocktime_ms: Some(12000),
566                is_testnet: Some(false),
567                tags: None,
568            },
569            chain_id: Some(1),
570            required_confirmations: Some(12),
571            features: Some(vec![]),
572            symbol: Some("ETH".to_string()),
573        };
574
575        let child = EvmNetworkConfig {
576            common: NetworkConfigCommon {
577                network: "child".to_string(),
578                from: Some("parent".to_string()),
579                rpc_urls: None,
580                explorer_urls: None,
581                average_blocktime_ms: None,
582                is_testnet: None,
583                tags: None,
584            },
585            chain_id: None,
586            required_confirmations: None,
587            features: Some(vec!["eip1559".to_string()]),
588            symbol: None,
589        };
590
591        let result = child.merge_with_parent(&parent);
592
593        // Should merge empty parent features with child features
594        assert_eq!(result.features, Some(vec!["eip1559".to_string()]));
595    }
596
597    #[test]
598    fn test_validate_with_very_large_confirmations() {
599        let mut config = create_evm_network("ethereum-mainnet");
600        config.required_confirmations = Some(u64::MAX);
601
602        let result = config.validate();
603        assert!(result.is_ok());
604    }
605
606    #[test]
607    fn test_merge_with_parent_identical_configs() {
608        let config = create_evm_network("ethereum-mainnet");
609        let result = config.merge_with_parent(&config);
610
611        // Merging identical configs should result in the same config
612        assert_eq!(result.common.network, config.common.network);
613        assert_eq!(result.chain_id, config.chain_id);
614        assert_eq!(result.required_confirmations, config.required_confirmations);
615        assert_eq!(result.features, config.features);
616        assert_eq!(result.symbol, config.symbol);
617    }
618
619    #[test]
620    fn test_validate_propagates_common_validation_errors() {
621        let mut config = create_evm_network("ethereum-mainnet");
622        config.common.rpc_urls = None; // This should cause common validation to fail
623
624        let result = config.validate();
625        assert!(result.is_err());
626        assert!(matches!(
627            result.unwrap_err(),
628            ConfigFileError::MissingField(_)
629        ));
630    }
631}