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/// Default value for gas price cache enabled flag
17fn default_gas_cache_enabled() -> bool {
18    false
19}
20
21/// Default value for gas price cache stale after duration in milliseconds
22fn default_gas_cache_stale_after_ms() -> u64 {
23    20_000 // 20 seconds
24}
25
26/// Default value for gas price cache expire after duration in milliseconds
27fn default_gas_cache_expire_after_ms() -> u64 {
28    45_000 // 45 seconds
29}
30
31/// Configuration for gas price caching
32#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
33#[serde(deny_unknown_fields)]
34pub struct GasPriceCacheConfig {
35    /// Enable gas price caching for this network
36    #[serde(default = "default_gas_cache_enabled")]
37    pub enabled: bool,
38
39    /// When data becomes stale (milliseconds)
40    #[serde(default = "default_gas_cache_stale_after_ms")]
41    pub stale_after_ms: u64,
42
43    /// When to expire and force refresh (milliseconds)
44    #[serde(default = "default_gas_cache_expire_after_ms")]
45    pub expire_after_ms: u64,
46}
47
48impl Default for GasPriceCacheConfig {
49    fn default() -> Self {
50        Self {
51            enabled: default_gas_cache_enabled(),
52            stale_after_ms: default_gas_cache_stale_after_ms(),
53            expire_after_ms: default_gas_cache_expire_after_ms(),
54        }
55    }
56}
57
58impl GasPriceCacheConfig {
59    /// Validates the gas price cache configuration
60    ///
61    /// # Returns
62    /// - `Ok(())` if the configuration is valid
63    /// - `Err(ConfigFileError)` if validation fails
64    pub fn validate(&self) -> Result<(), ConfigFileError> {
65        // Check that durations are non-zero
66        if self.stale_after_ms == 0 {
67            return Err(ConfigFileError::InvalidFormat(
68                "Gas price cache stale_after_ms must be greater than zero".into(),
69            ));
70        }
71
72        if self.expire_after_ms == 0 {
73            return Err(ConfigFileError::InvalidFormat(
74                "Gas price cache expire_after_ms must be greater than zero".into(),
75            ));
76        }
77
78        // Check that expire_after_ms > stale_after_ms
79        if self.expire_after_ms <= self.stale_after_ms {
80            return Err(ConfigFileError::InvalidFormat(
81                "Gas price cache expire_after_ms must be greater than stale_after_ms".into(),
82            ));
83        }
84
85        Ok(())
86    }
87}
88
89/// Configuration specific to EVM-compatible networks.
90#[derive(Debug, Serialize, Deserialize, Clone)]
91#[serde(deny_unknown_fields)]
92pub struct EvmNetworkConfig {
93    /// Common network fields.
94    #[serde(flatten)]
95    pub common: NetworkConfigCommon,
96
97    /// The unique chain identifier (Chain ID) for the EVM network.
98    pub chain_id: Option<u64>,
99    /// Number of block confirmations required before a transaction is considered final.
100    pub required_confirmations: Option<u64>,
101    /// List of specific features supported by the network (e.g., "eip1559").
102    pub features: Option<Vec<String>>,
103    /// The symbol of the network's native currency (e.g., "ETH", "MATIC").
104    pub symbol: Option<String>,
105    /// Gas price cache configuration
106    pub gas_price_cache: Option<GasPriceCacheConfig>,
107}
108
109impl EvmNetworkConfig {
110    /// Validates the specific configuration fields for an EVM network.
111    ///
112    /// # Returns
113    /// - `Ok(())` if the EVM configuration is valid.
114    /// - `Err(ConfigFileError)` if validation fails (e.g., missing fields, invalid URLs).
115    pub fn validate(&self) -> Result<(), ConfigFileError> {
116        self.common.validate()?;
117
118        // Chain ID is required for non-inherited networks
119        if self.chain_id.is_none() {
120            return Err(ConfigFileError::MissingField("chain_id".into()));
121        }
122
123        if self.required_confirmations.is_none() {
124            return Err(ConfigFileError::MissingField(
125                "required_confirmations".into(),
126            ));
127        }
128
129        if self.symbol.is_none() || self.symbol.as_ref().unwrap_or(&String::new()).is_empty() {
130            return Err(ConfigFileError::MissingField("symbol".into()));
131        }
132
133        // Validate gas price cache configuration if present
134        if let Some(gas_price_cache) = &self.gas_price_cache {
135            gas_price_cache.validate()?;
136        }
137
138        Ok(())
139    }
140
141    /// Creates a new EVM configuration by merging this config with a parent, where child values override parent defaults.
142    ///
143    /// # Arguments
144    /// * `parent` - The parent EVM configuration to merge with.
145    ///
146    /// # Returns
147    /// A new `EvmNetworkConfig` with merged values where child takes precedence over parent.
148    pub fn merge_with_parent(&self, parent: &Self) -> Self {
149        Self {
150            common: self.common.merge_with_parent(&parent.common),
151            chain_id: self.chain_id.or(parent.chain_id),
152            required_confirmations: self
153                .required_confirmations
154                .or(parent.required_confirmations),
155            features: merge_optional_string_vecs(&self.features, &parent.features),
156            symbol: self.symbol.clone().or_else(|| parent.symbol.clone()),
157            gas_price_cache: self
158                .gas_price_cache
159                .clone()
160                .or_else(|| parent.gas_price_cache.clone()),
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::config::config_file::network::test_utils::*;
169
170    #[test]
171    fn test_validate_success_complete_config() {
172        let config = create_evm_network("ethereum-mainnet");
173        let result = config.validate();
174        assert!(result.is_ok());
175    }
176
177    #[test]
178    fn test_validate_success_minimal_config() {
179        let mut config = create_evm_network("minimal-evm");
180        config.features = None;
181        let result = config.validate();
182        assert!(result.is_ok());
183    }
184
185    #[test]
186    fn test_validate_missing_chain_id() {
187        let mut config = create_evm_network("ethereum-mainnet");
188        config.chain_id = None;
189
190        let result = config.validate();
191        assert!(result.is_err());
192        assert!(matches!(
193            result.unwrap_err(),
194            ConfigFileError::MissingField(_)
195        ));
196    }
197
198    #[test]
199    fn test_validate_missing_required_confirmations() {
200        let mut config = create_evm_network("ethereum-mainnet");
201        config.required_confirmations = None;
202
203        let result = config.validate();
204        assert!(result.is_err());
205        assert!(matches!(
206            result.unwrap_err(),
207            ConfigFileError::MissingField(_)
208        ));
209    }
210
211    #[test]
212    fn test_validate_missing_symbol() {
213        let mut config = create_evm_network("ethereum-mainnet");
214        config.symbol = None;
215
216        let result = config.validate();
217        assert!(result.is_err());
218        assert!(matches!(
219            result.unwrap_err(),
220            ConfigFileError::MissingField(_)
221        ));
222    }
223
224    #[test]
225    fn test_validate_invalid_common_fields() {
226        let mut config = create_evm_network("ethereum-mainnet");
227        config.common.network = String::new(); // Invalid empty network name
228
229        let result = config.validate();
230        assert!(result.is_err());
231        assert!(matches!(
232            result.unwrap_err(),
233            ConfigFileError::MissingField(_)
234        ));
235    }
236
237    #[test]
238    fn test_validate_invalid_rpc_urls() {
239        let mut config = create_evm_network("ethereum-mainnet");
240        config.common.rpc_urls = Some(vec!["invalid-url".to_string()]);
241
242        let result = config.validate();
243        assert!(result.is_err());
244        assert!(matches!(
245            result.unwrap_err(),
246            ConfigFileError::InvalidFormat(_)
247        ));
248    }
249
250    #[test]
251    fn test_validate_with_zero_chain_id() {
252        let mut config = create_evm_network("ethereum-mainnet");
253        config.chain_id = Some(0);
254
255        let result = config.validate();
256        assert!(result.is_ok()); // Zero is a valid chain ID
257    }
258
259    #[test]
260    fn test_validate_with_large_chain_id() {
261        let mut config = create_evm_network("ethereum-mainnet");
262        config.chain_id = Some(u64::MAX);
263
264        let result = config.validate();
265        assert!(result.is_ok());
266    }
267
268    #[test]
269    fn test_validate_with_zero_confirmations() {
270        let mut config = create_evm_network("ethereum-mainnet");
271        config.required_confirmations = Some(0);
272
273        let result = config.validate();
274        assert!(result.is_ok()); // Zero confirmations is valid
275    }
276
277    #[test]
278    fn test_validate_with_empty_features() {
279        let mut config = create_evm_network("ethereum-mainnet");
280        config.features = Some(vec![]);
281
282        let result = config.validate();
283        assert!(result.is_ok());
284    }
285
286    #[test]
287    fn test_validate_with_empty_symbol() {
288        let mut config = create_evm_network("ethereum-mainnet");
289        config.symbol = Some(String::new());
290
291        let result = config.validate();
292        assert!(result.is_err());
293    }
294
295    #[test]
296    fn test_merge_with_parent_child_overrides() {
297        let parent = EvmNetworkConfig {
298            common: NetworkConfigCommon {
299                network: "parent".to_string(),
300                from: Some("parent".to_string()),
301                rpc_urls: Some(vec!["https://parent-rpc.example.com".to_string()]),
302                explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]),
303                average_blocktime_ms: Some(10000),
304                is_testnet: Some(true),
305                tags: Some(vec!["parent-tag".to_string()]),
306            },
307            chain_id: Some(1),
308            required_confirmations: Some(6),
309            features: Some(vec!["legacy".to_string()]),
310            symbol: Some("PETH".to_string()),
311            gas_price_cache: Some(GasPriceCacheConfig {
312                enabled: true,
313                stale_after_ms: 20_000,
314                expire_after_ms: 100_000,
315            }),
316        };
317
318        let child = EvmNetworkConfig {
319            common: NetworkConfigCommon {
320                network: "child".to_string(),
321                from: Some("parent".to_string()),
322                rpc_urls: Some(vec!["https://child-rpc.example.com".to_string()]),
323                explorer_urls: Some(vec!["https://child-explorer.example.com".to_string()]),
324                average_blocktime_ms: Some(15000),
325                is_testnet: Some(false),
326                tags: Some(vec!["child-tag".to_string()]),
327            },
328            chain_id: Some(31337),
329            required_confirmations: Some(1),
330            features: Some(vec!["eip1559".to_string()]),
331            symbol: Some("CETH".to_string()),
332            gas_price_cache: Some(GasPriceCacheConfig {
333                enabled: false,
334                stale_after_ms: 40_000,
335                expire_after_ms: 200_000,
336            }),
337        };
338
339        let result = child.merge_with_parent(&parent);
340
341        // Child values should override parent values
342        assert_eq!(result.common.network, "child");
343        assert_eq!(result.common.from, Some("parent".to_string()));
344        assert_eq!(
345            result.common.rpc_urls,
346            Some(vec!["https://child-rpc.example.com".to_string()])
347        );
348        assert_eq!(
349            result.common.explorer_urls,
350            Some(vec!["https://child-explorer.example.com".to_string()])
351        );
352        assert_eq!(result.common.average_blocktime_ms, Some(15000));
353        assert_eq!(result.common.is_testnet, Some(false));
354        assert_eq!(
355            result.common.tags,
356            Some(vec!["parent-tag".to_string(), "child-tag".to_string()])
357        );
358        assert_eq!(result.chain_id, Some(31337));
359        assert_eq!(result.required_confirmations, Some(1));
360        assert_eq!(
361            result.features,
362            Some(vec!["legacy".to_string(), "eip1559".to_string()])
363        );
364        assert_eq!(result.symbol, Some("CETH".to_string()));
365        assert_eq!(
366            result.gas_price_cache,
367            Some(GasPriceCacheConfig {
368                enabled: false,
369                stale_after_ms: 40_000,
370                expire_after_ms: 200_000,
371            })
372        );
373    }
374
375    #[test]
376    fn test_merge_with_parent_child_inherits() {
377        let parent = EvmNetworkConfig {
378            common: NetworkConfigCommon {
379                network: "parent".to_string(),
380                from: None,
381                rpc_urls: Some(vec!["https://parent-rpc.example.com".to_string()]),
382                explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]),
383                average_blocktime_ms: Some(10000),
384                is_testnet: Some(true),
385                tags: Some(vec!["parent-tag".to_string()]),
386            },
387            chain_id: Some(1),
388            required_confirmations: Some(6),
389            features: Some(vec!["eip1559".to_string()]),
390            symbol: Some("ETH".to_string()),
391            gas_price_cache: Some(GasPriceCacheConfig {
392                enabled: true,
393                stale_after_ms: 20_000,
394                expire_after_ms: 100_000,
395            }),
396        };
397
398        let child = create_evm_network_for_inheritance_test("ethereum-testnet", "ethereum-mainnet");
399
400        let result = child.merge_with_parent(&parent);
401
402        // Child should inherit parent values where child has None
403        assert_eq!(result.common.network, "ethereum-testnet");
404        assert_eq!(result.common.from, Some("ethereum-mainnet".to_string()));
405        assert_eq!(
406            result.common.rpc_urls,
407            Some(vec!["https://parent-rpc.example.com".to_string()])
408        );
409        assert_eq!(
410            result.common.explorer_urls,
411            Some(vec!["https://parent-explorer.example.com".to_string()])
412        );
413        assert_eq!(result.common.average_blocktime_ms, Some(10000));
414        assert_eq!(result.common.is_testnet, Some(true));
415        assert_eq!(result.common.tags, Some(vec!["parent-tag".to_string()]));
416        assert_eq!(result.chain_id, Some(1));
417        assert_eq!(result.required_confirmations, Some(6));
418        assert_eq!(result.features, Some(vec!["eip1559".to_string()]));
419        assert_eq!(result.symbol, Some("ETH".to_string()));
420        assert_eq!(
421            result.gas_price_cache,
422            Some(GasPriceCacheConfig {
423                enabled: true,
424                stale_after_ms: 20_000,
425                expire_after_ms: 100_000,
426            })
427        );
428    }
429
430    #[test]
431    fn test_merge_with_parent_mixed_inheritance() {
432        let parent = EvmNetworkConfig {
433            common: NetworkConfigCommon {
434                network: "parent".to_string(),
435                from: None,
436                rpc_urls: Some(vec!["https://parent-rpc.example.com".to_string()]),
437                explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]),
438                average_blocktime_ms: Some(10000),
439                is_testnet: Some(true),
440                tags: Some(vec!["parent-tag1".to_string(), "parent-tag2".to_string()]),
441            },
442            chain_id: Some(1),
443            required_confirmations: Some(6),
444            features: Some(vec!["eip155".to_string(), "eip1559".to_string()]),
445            symbol: Some("ETH".to_string()),
446            gas_price_cache: Some(GasPriceCacheConfig {
447                enabled: true,
448                stale_after_ms: 20_000,
449                expire_after_ms: 100_000,
450            }),
451        };
452
453        let child = EvmNetworkConfig {
454            common: NetworkConfigCommon {
455                network: "child".to_string(),
456                from: Some("parent".to_string()),
457                rpc_urls: Some(vec!["https://child-rpc.example.com".to_string()]), // Override
458                explorer_urls: Some(vec!["https://child-explorer.example.com".to_string()]), // Override
459                average_blocktime_ms: None,                // Inherit
460                is_testnet: Some(false),                   // Override
461                tags: Some(vec!["child-tag".to_string()]), // Merge
462            },
463            chain_id: Some(31337),                       // Override
464            required_confirmations: None,                // Inherit
465            features: Some(vec!["eip2930".to_string()]), // Merge
466            symbol: None,                                // Inherit
467            gas_price_cache: Some(GasPriceCacheConfig {
468                enabled: false,
469                stale_after_ms: 40_000,
470                expire_after_ms: 200_000,
471            }),
472        };
473
474        let result = child.merge_with_parent(&parent);
475
476        assert_eq!(result.common.network, "child");
477        assert_eq!(
478            result.common.rpc_urls,
479            Some(vec!["https://child-rpc.example.com".to_string()])
480        ); // Overridden
481        assert_eq!(
482            result.common.explorer_urls,
483            Some(vec!["https://child-explorer.example.com".to_string()])
484        ); // Overridden
485        assert_eq!(result.common.average_blocktime_ms, Some(10000)); // Inherited
486        assert_eq!(result.common.is_testnet, Some(false)); // Overridden
487        assert_eq!(
488            result.common.tags,
489            Some(vec![
490                "parent-tag1".to_string(),
491                "parent-tag2".to_string(),
492                "child-tag".to_string()
493            ])
494        ); // Merged
495        assert_eq!(result.chain_id, Some(31337)); // Overridden
496        assert_eq!(result.required_confirmations, Some(6)); // Inherited
497        assert_eq!(
498            result.features,
499            Some(vec![
500                "eip155".to_string(),
501                "eip1559".to_string(),
502                "eip2930".to_string()
503            ])
504        ); // Merged
505        assert_eq!(result.symbol, Some("ETH".to_string())); // Inherited
506        assert_eq!(
507            result.gas_price_cache,
508            Some(GasPriceCacheConfig {
509                enabled: false,
510                stale_after_ms: 40_000,
511                expire_after_ms: 200_000,
512            })
513        );
514    }
515
516    #[test]
517    fn test_merge_with_parent_both_empty() {
518        let parent = EvmNetworkConfig {
519            common: NetworkConfigCommon {
520                network: "parent".to_string(),
521                from: None,
522                rpc_urls: None,
523                explorer_urls: None,
524                average_blocktime_ms: None,
525                is_testnet: None,
526                tags: None,
527            },
528            chain_id: None,
529            required_confirmations: None,
530            features: None,
531            symbol: None,
532            gas_price_cache: None,
533        };
534
535        let child = EvmNetworkConfig {
536            common: NetworkConfigCommon {
537                network: "child".to_string(),
538                from: Some("parent".to_string()),
539                rpc_urls: None,
540                explorer_urls: None,
541                average_blocktime_ms: None,
542                is_testnet: None,
543                tags: None,
544            },
545            chain_id: None,
546            required_confirmations: None,
547            features: None,
548            symbol: None,
549            gas_price_cache: None,
550        };
551
552        let result = child.merge_with_parent(&parent);
553
554        assert_eq!(result.common.network, "child");
555        assert_eq!(result.common.from, Some("parent".to_string()));
556        assert_eq!(result.common.rpc_urls, None);
557        assert_eq!(result.common.average_blocktime_ms, None);
558        assert_eq!(result.common.is_testnet, None);
559        assert_eq!(result.common.tags, None);
560        assert_eq!(result.chain_id, None);
561        assert_eq!(result.required_confirmations, None);
562        assert_eq!(result.features, None);
563        assert_eq!(result.symbol, None);
564        assert_eq!(result.gas_price_cache, None);
565    }
566
567    #[test]
568    fn test_merge_with_parent_complex_features_merging() {
569        let parent = EvmNetworkConfig {
570            common: NetworkConfigCommon {
571                network: "parent".to_string(),
572                from: None,
573                rpc_urls: Some(vec!["https://rpc.example.com".to_string()]),
574                explorer_urls: Some(vec!["https://explorer.example.com".to_string()]),
575                average_blocktime_ms: Some(12000),
576                is_testnet: Some(false),
577                tags: None,
578            },
579            chain_id: Some(1),
580            required_confirmations: Some(12),
581            features: Some(vec![
582                "eip155".to_string(),
583                "eip1559".to_string(),
584                "shared".to_string(),
585            ]),
586            symbol: Some("ETH".to_string()),
587            gas_price_cache: Some(GasPriceCacheConfig {
588                enabled: true,
589                stale_after_ms: 20_000,
590                expire_after_ms: 100_000,
591            }),
592        };
593
594        let child = EvmNetworkConfig {
595            common: NetworkConfigCommon {
596                network: "child".to_string(),
597                from: Some("parent".to_string()),
598                rpc_urls: None,
599                explorer_urls: None,
600                average_blocktime_ms: None,
601                is_testnet: None,
602                tags: None,
603            },
604            chain_id: None,
605            required_confirmations: None,
606            features: Some(vec![
607                "shared".to_string(),
608                "eip2930".to_string(),
609                "custom".to_string(),
610            ]),
611            symbol: None,
612            gas_price_cache: None,
613        };
614
615        let result = child.merge_with_parent(&parent);
616
617        // Features should be merged with parent first, then unique child features added
618        let expected_features = vec![
619            "eip155".to_string(),
620            "eip1559".to_string(),
621            "shared".to_string(), // Duplicate should not be added again
622            "eip2930".to_string(),
623            "custom".to_string(),
624        ];
625        assert_eq!(result.features, Some(expected_features));
626        assert_eq!(
627            result.gas_price_cache,
628            Some(GasPriceCacheConfig {
629                enabled: true,
630                stale_after_ms: 20_000,
631                expire_after_ms: 100_000,
632            })
633        );
634    }
635
636    #[test]
637    fn test_merge_with_parent_preserves_child_network_name() {
638        let parent = create_evm_network("ethereum-mainnet");
639        let mut child =
640            create_evm_network_for_inheritance_test("ethereum-testnet", "ethereum-mainnet");
641        child.common.network = "custom-child-name".to_string();
642
643        let result = child.merge_with_parent(&parent);
644
645        // Child network name should always be preserved
646        assert_eq!(result.common.network, "custom-child-name");
647    }
648
649    #[test]
650    fn test_merge_with_parent_preserves_child_from_field() {
651        let parent = EvmNetworkConfig {
652            common: NetworkConfigCommon {
653                network: "parent".to_string(),
654                from: Some("grandparent".to_string()),
655                rpc_urls: Some(vec!["https://parent.example.com".to_string()]),
656                explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]),
657                average_blocktime_ms: Some(10000),
658                is_testnet: Some(true),
659                tags: None,
660            },
661            chain_id: Some(1),
662            required_confirmations: Some(6),
663            features: None,
664            symbol: Some("ETH".to_string()),
665            gas_price_cache: Some(GasPriceCacheConfig {
666                enabled: true,
667                stale_after_ms: 20_000,
668                expire_after_ms: 100_000,
669            }),
670        };
671
672        let child = EvmNetworkConfig {
673            common: NetworkConfigCommon {
674                network: "child".to_string(),
675                from: Some("parent".to_string()),
676                rpc_urls: None,
677                explorer_urls: None,
678                average_blocktime_ms: None,
679                is_testnet: None,
680                tags: None,
681            },
682            chain_id: None,
683            required_confirmations: None,
684            features: None,
685            symbol: None,
686            gas_price_cache: None,
687        };
688
689        let result = child.merge_with_parent(&parent);
690
691        // Child's 'from' field should be preserved, not inherited from parent
692        assert_eq!(result.common.from, Some("parent".to_string()));
693        assert_eq!(
694            result.gas_price_cache,
695            Some(GasPriceCacheConfig {
696                enabled: true,
697                stale_after_ms: 20_000,
698                expire_after_ms: 100_000,
699            })
700        );
701    }
702
703    #[test]
704    fn test_validate_with_unicode_symbol() {
705        let mut config = create_evm_network("ethereum-mainnet");
706        config.symbol = Some("Ξ".to_string()); // Greek Xi symbol for Ethereum
707
708        let result = config.validate();
709        assert!(result.is_ok());
710    }
711
712    #[test]
713    fn test_validate_with_unicode_features() {
714        let mut config = create_evm_network("ethereum-mainnet");
715        config.features = Some(vec!["eip1559".to_string(), "测试功能".to_string()]);
716
717        let result = config.validate();
718        assert!(result.is_ok());
719    }
720
721    #[test]
722    fn test_merge_with_parent_with_empty_features() {
723        let parent = EvmNetworkConfig {
724            common: NetworkConfigCommon {
725                network: "parent".to_string(),
726                from: None,
727                rpc_urls: Some(vec!["https://rpc.example.com".to_string()]),
728                explorer_urls: Some(vec!["https://explorer.example.com".to_string()]),
729                average_blocktime_ms: Some(12000),
730                is_testnet: Some(false),
731                tags: None,
732            },
733            chain_id: Some(1),
734            required_confirmations: Some(12),
735            features: Some(vec![]),
736            symbol: Some("ETH".to_string()),
737            gas_price_cache: Some(GasPriceCacheConfig {
738                enabled: true,
739                stale_after_ms: 20_000,
740                expire_after_ms: 100_000,
741            }),
742        };
743
744        let child = EvmNetworkConfig {
745            common: NetworkConfigCommon {
746                network: "child".to_string(),
747                from: Some("parent".to_string()),
748                rpc_urls: None,
749                explorer_urls: None,
750                average_blocktime_ms: None,
751                is_testnet: None,
752                tags: None,
753            },
754            chain_id: None,
755            required_confirmations: None,
756            features: Some(vec!["eip1559".to_string()]),
757            symbol: None,
758            gas_price_cache: None,
759        };
760
761        let result = child.merge_with_parent(&parent);
762
763        // Should merge empty parent features with child features
764        assert_eq!(result.features, Some(vec!["eip1559".to_string()]));
765        assert_eq!(
766            result.gas_price_cache,
767            Some(GasPriceCacheConfig {
768                enabled: true,
769                stale_after_ms: 20_000,
770                expire_after_ms: 100_000,
771            })
772        );
773    }
774
775    #[test]
776    fn test_validate_with_very_large_confirmations() {
777        let mut config = create_evm_network("ethereum-mainnet");
778        config.required_confirmations = Some(u64::MAX);
779
780        let result = config.validate();
781        assert!(result.is_ok());
782    }
783
784    #[test]
785    fn test_merge_with_parent_identical_configs() {
786        let config = create_evm_network("ethereum-mainnet");
787        let result = config.merge_with_parent(&config);
788
789        // Merging identical configs should result in the same config
790        assert_eq!(result.common.network, config.common.network);
791        assert_eq!(result.chain_id, config.chain_id);
792        assert_eq!(result.required_confirmations, config.required_confirmations);
793        assert_eq!(result.features, config.features);
794        assert_eq!(result.symbol, config.symbol);
795        assert_eq!(result.gas_price_cache, config.gas_price_cache);
796    }
797
798    #[test]
799    fn test_validate_propagates_common_validation_errors() {
800        let mut config = create_evm_network("ethereum-mainnet");
801        config.common.rpc_urls = None; // This should cause common validation to fail
802
803        let result = config.validate();
804        assert!(result.is_err());
805        assert!(matches!(
806            result.unwrap_err(),
807            ConfigFileError::MissingField(_)
808        ));
809    }
810
811    #[test]
812    fn test_gas_price_cache_validation_zero_stale_after() {
813        let mut config = create_evm_network("ethereum-mainnet");
814        config.gas_price_cache = Some(GasPriceCacheConfig {
815            enabled: true,
816            stale_after_ms: 0, // Invalid: zero value
817            expire_after_ms: 45_000,
818        });
819
820        let result = config.validate();
821        assert!(result.is_err());
822        assert!(matches!(
823            result.unwrap_err(),
824            ConfigFileError::InvalidFormat(_)
825        ));
826    }
827
828    #[test]
829    fn test_gas_price_cache_validation_zero_expire_after() {
830        let mut config = create_evm_network("ethereum-mainnet");
831        config.gas_price_cache = Some(GasPriceCacheConfig {
832            enabled: true,
833            stale_after_ms: 20_000,
834            expire_after_ms: 0, // Invalid: zero value
835        });
836
837        let result = config.validate();
838        assert!(result.is_err());
839        assert!(matches!(
840            result.unwrap_err(),
841            ConfigFileError::InvalidFormat(_)
842        ));
843    }
844
845    #[test]
846    fn test_gas_price_cache_validation_expire_less_than_stale() {
847        let mut config = create_evm_network("ethereum-mainnet");
848        config.gas_price_cache = Some(GasPriceCacheConfig {
849            enabled: true,
850            stale_after_ms: 45_000,
851            expire_after_ms: 20_000, // Invalid: less than stale_after_ms
852        });
853
854        let result = config.validate();
855        assert!(result.is_err());
856        assert!(matches!(
857            result.unwrap_err(),
858            ConfigFileError::InvalidFormat(_)
859        ));
860    }
861
862    #[test]
863    fn test_gas_price_cache_validation_expire_equal_to_stale() {
864        let mut config = create_evm_network("ethereum-mainnet");
865        config.gas_price_cache = Some(GasPriceCacheConfig {
866            enabled: true,
867            stale_after_ms: 20_000,
868            expire_after_ms: 20_000, // Invalid: equal to stale_after_ms
869        });
870
871        let result = config.validate();
872        assert!(result.is_err());
873        assert!(matches!(
874            result.unwrap_err(),
875            ConfigFileError::InvalidFormat(_)
876        ));
877    }
878
879    #[test]
880    fn test_gas_price_cache_validation_valid_config() {
881        let mut config = create_evm_network("ethereum-mainnet");
882        config.gas_price_cache = Some(GasPriceCacheConfig {
883            enabled: true,
884            stale_after_ms: 20_000,
885            expire_after_ms: 45_000, // Valid: greater than stale_after_ms
886        });
887
888        let result = config.validate();
889        assert!(result.is_ok());
890    }
891
892    #[test]
893    fn test_gas_price_cache_default_values() {
894        let config = GasPriceCacheConfig::default();
895
896        assert!(!config.enabled);
897        assert_eq!(config.stale_after_ms, 20_000);
898        assert_eq!(config.expire_after_ms, 45_000);
899
900        // Validation should pass for default values
901        assert!(config.validate().is_ok());
902    }
903}