openzeppelin_monitor/models/config/
network_config.rs

1//! Network configuration loading and validation.
2//!
3//! This module implements the ConfigLoader trait for Network configurations,
4//! allowing network definitions to be loaded from JSON files.
5
6use async_trait::async_trait;
7use std::{collections::HashMap, path::Path, str::FromStr};
8
9use crate::{
10	models::{config::error::ConfigError, BlockChainType, ConfigLoader, Network, SecretValue},
11	utils::{get_cron_interval_ms, normalize_string},
12};
13
14impl Network {
15	/// Calculates the recommended minimum number of past blocks to maintain for this network.
16	///
17	/// This function computes a safe minimum value based on three factors:
18	/// 1. The number of blocks that occur during one cron interval (`blocks_per_cron`)
19	/// 2. The required confirmation blocks for the network
20	/// 3. An additional buffer block (+1)
21	///
22	/// The formula used is: `(cron_interval_ms / block_time_ms) + confirmation_blocks + 1`
23	///
24	/// # Returns
25	/// * `u64` - The recommended minimum number of past blocks to maintain
26	///
27	/// # Note
28	/// If the cron schedule parsing fails, the blocks_per_cron component will be 0,
29	/// resulting in a minimum recommendation of `confirmation_blocks + 1`
30	pub fn get_recommended_past_blocks(&self) -> u64 {
31		let cron_interval_ms = get_cron_interval_ms(&self.cron_schedule).unwrap_or(0) as u64;
32		let blocks_per_cron = cron_interval_ms / self.block_time_ms;
33		blocks_per_cron + self.confirmation_blocks + 1
34	}
35}
36
37#[async_trait]
38impl ConfigLoader for Network {
39	/// Resolve all secrets in the network configuration
40	async fn resolve_secrets(&self) -> Result<Self, ConfigError> {
41		dotenvy::dotenv().ok();
42		let mut network = self.clone();
43
44		for rpc_url in &mut network.rpc_urls {
45			let resolved_url = rpc_url.url.resolve().await.map_err(|e| {
46				ConfigError::parse_error(
47					format!("failed to resolve RPC URL: {}", e),
48					Some(Box::new(e)),
49					None,
50				)
51			})?;
52			rpc_url.url = SecretValue::Plain(resolved_url);
53		}
54		Ok(network)
55	}
56
57	/// Load all network configurations from a directory
58	///
59	/// Reads and parses all JSON files in the specified directory (or default
60	/// config directory) as network configurations.
61	async fn load_all<T>(path: Option<&Path>) -> Result<T, ConfigError>
62	where
63		T: FromIterator<(String, Self)>,
64	{
65		let network_dir = path.unwrap_or(Path::new("config/networks"));
66		let mut pairs = Vec::new();
67
68		if !network_dir.exists() {
69			return Err(ConfigError::file_error(
70				"networks directory not found",
71				None,
72				Some(HashMap::from([(
73					"path".to_string(),
74					network_dir.display().to_string(),
75				)])),
76			));
77		}
78
79		for entry in std::fs::read_dir(network_dir).map_err(|e| {
80			ConfigError::file_error(
81				format!("failed to read networks directory: {}", e),
82				Some(Box::new(e)),
83				Some(HashMap::from([(
84					"path".to_string(),
85					network_dir.display().to_string(),
86				)])),
87			)
88		})? {
89			let entry = entry.map_err(|e| {
90				ConfigError::file_error(
91					format!("failed to read directory entry: {}", e),
92					Some(Box::new(e)),
93					Some(HashMap::from([(
94						"path".to_string(),
95						network_dir.display().to_string(),
96					)])),
97				)
98			})?;
99			let path = entry.path();
100
101			if !Self::is_json_file(&path) {
102				continue;
103			}
104
105			let name = path
106				.file_stem()
107				.and_then(|s| s.to_str())
108				.unwrap_or("unknown")
109				.to_string();
110
111			let network = Self::load_from_path(&path).await?;
112
113			let existing_networks: Vec<&Network> =
114				pairs.iter().map(|(_, network)| network).collect();
115			// Check network name uniqueness before pushing
116			Self::validate_uniqueness(&existing_networks, &network, &path.display().to_string())?;
117
118			pairs.push((name, network));
119		}
120
121		Ok(T::from_iter(pairs))
122	}
123
124	/// Load a network configuration from a specific file
125	///
126	/// Reads and parses a single JSON file as a network configuration.
127	async fn load_from_path(path: &std::path::Path) -> Result<Self, ConfigError> {
128		let file = std::fs::File::open(path).map_err(|e| {
129			ConfigError::file_error(
130				format!("failed to open network config file: {}", e),
131				Some(Box::new(e)),
132				Some(HashMap::from([(
133					"path".to_string(),
134					path.display().to_string(),
135				)])),
136			)
137		})?;
138		let mut config: Network = serde_json::from_reader(file).map_err(|e| {
139			ConfigError::parse_error(
140				format!("failed to parse network config: {}", e),
141				Some(Box::new(e)),
142				Some(HashMap::from([(
143					"path".to_string(),
144					path.display().to_string(),
145				)])),
146			)
147		})?;
148
149		// Resolve secrets before validating
150		config = config.resolve_secrets().await?;
151
152		// Validate the config after loading
153		config.validate()?;
154
155		Ok(config)
156	}
157
158	/// Validate the network configuration
159	///
160	/// Ensures that:
161	/// - The network has a valid name and slug
162	/// - At least one RPC URL is specified
163	/// - Required chain-specific parameters are present
164	/// - Block time and confirmation values are reasonable
165	fn validate(&self) -> Result<(), ConfigError> {
166		// Validate network name
167		if self.name.is_empty() {
168			return Err(ConfigError::validation_error(
169				"Network name is required",
170				None,
171				None,
172			));
173		}
174
175		// Validate network_type
176		match self.network_type {
177			BlockChainType::EVM | BlockChainType::Stellar => {}
178			_ => {
179				return Err(ConfigError::validation_error(
180					"Invalid network_type",
181					None,
182					None,
183				));
184			}
185		}
186
187		// Validate slug
188		if !self
189			.slug
190			.chars()
191			.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
192		{
193			return Err(ConfigError::validation_error(
194				"Slug must contain only lowercase letters, numbers, and underscores",
195				None,
196				None,
197			));
198		}
199
200		// Validate RPC URL types
201		let supported_types = ["rpc"];
202		if !self
203			.rpc_urls
204			.iter()
205			.all(|rpc_url| supported_types.contains(&rpc_url.type_.as_str()))
206		{
207			return Err(ConfigError::validation_error(
208				format!(
209					"RPC URL type must be one of: {}",
210					supported_types.join(", ")
211				),
212				None,
213				None,
214			));
215		}
216
217		// Validate RPC URLs format
218		if !self.rpc_urls.iter().all(|rpc_url| {
219			rpc_url.url.starts_with("http://") || rpc_url.url.starts_with("https://")
220		}) {
221			return Err(ConfigError::validation_error(
222				"All RPC URLs must start with http:// or https://",
223				None,
224				None,
225			));
226		}
227
228		// Validate RPC URL weights
229		if !self.rpc_urls.iter().all(|rpc_url| rpc_url.weight <= 100) {
230			return Err(ConfigError::validation_error(
231				"All RPC URL weights must be between 0 and 100",
232				None,
233				None,
234			));
235		}
236
237		// Validate block time
238		if self.block_time_ms < 100 {
239			return Err(ConfigError::validation_error(
240				"Block time must be at least 100ms",
241				None,
242				None,
243			));
244		}
245
246		// Validate confirmation blocks
247		if self.confirmation_blocks == 0 {
248			return Err(ConfigError::validation_error(
249				"Confirmation blocks must be greater than 0",
250				None,
251				None,
252			));
253		}
254
255		// Validate cron_schedule
256		if self.cron_schedule.is_empty() {
257			return Err(ConfigError::validation_error(
258				"Cron schedule must be provided",
259				None,
260				None,
261			));
262		}
263
264		// Add cron schedule format validation
265		if let Err(e) = cron::Schedule::from_str(&self.cron_schedule) {
266			return Err(ConfigError::validation_error(e.to_string(), None, None));
267		}
268
269		// Validate max_past_blocks
270		if let Some(max_blocks) = self.max_past_blocks {
271			if max_blocks == 0 {
272				return Err(ConfigError::validation_error(
273					"max_past_blocks must be greater than 0",
274					None,
275					None,
276				));
277			}
278
279			let recommended_blocks = self.get_recommended_past_blocks();
280
281			if max_blocks < recommended_blocks {
282				tracing::warn!(
283					"Network '{}' max_past_blocks ({}) below recommended {} \
284					 (cron_interval/block_time + confirmations + 1)",
285					self.slug,
286					max_blocks,
287					recommended_blocks
288				);
289			}
290		}
291
292		// Log a warning if the network uses an insecure protocol
293		self.validate_protocol();
294
295		Ok(())
296	}
297
298	/// Validate the safety of the protocol used in the network
299	///
300	/// Returns if safe, or logs a warning message if unsafe.
301	fn validate_protocol(&self) {
302		for rpc_url in &self.rpc_urls {
303			if rpc_url.url.starts_with("http://") {
304				tracing::warn!(
305					"Network '{}' uses an insecure RPC URL: {}",
306					self.slug,
307					rpc_url.url.as_str()
308				);
309			}
310			// Additional check for websocket connections
311			if rpc_url.url.starts_with("ws://") {
312				tracing::warn!(
313					"Network '{}' uses an insecure WebSocket URL: {}",
314					self.slug,
315					rpc_url.url.as_str()
316				);
317			}
318		}
319	}
320
321	fn validate_uniqueness(
322		instances: &[&Self],
323		current_instance: &Self,
324		file_path: &str,
325	) -> Result<(), ConfigError> {
326		let fields = [
327			("name", &current_instance.name),
328			("slug", &current_instance.slug),
329		];
330
331		for (field_name, field_value) in fields {
332			if instances.iter().any(|existing_network| {
333				let existing_value = match field_name {
334					"name" => &existing_network.name,
335					"slug" => &existing_network.slug,
336					_ => unreachable!(),
337				};
338				normalize_string(existing_value) == normalize_string(field_value)
339			}) {
340				return Err(ConfigError::validation_error(
341					format!("Duplicate network {} found: '{}'", field_name, field_value),
342					None,
343					Some(HashMap::from([
344						(format!("network_{}", field_name), field_value.to_string()),
345						("path".to_string(), file_path.to_string()),
346					])),
347				));
348			}
349		}
350		Ok(())
351	}
352}
353
354#[cfg(test)]
355mod tests {
356	use super::*;
357	use crate::utils::tests::builders::network::NetworkBuilder;
358	use std::fs;
359	use tempfile::TempDir;
360	use tracing_test::traced_test;
361
362	// Replace create_valid_network() with NetworkBuilder usage
363	fn create_valid_network() -> Network {
364		NetworkBuilder::new()
365			.name("Test Network")
366			.slug("test_network")
367			.network_type(BlockChainType::EVM)
368			.chain_id(1)
369			.store_blocks(true)
370			.rpc_url("https://test.network")
371			.block_time_ms(1000)
372			.confirmation_blocks(1)
373			.cron_schedule("0 */5 * * * *")
374			.max_past_blocks(10)
375			.build()
376	}
377
378	#[test]
379	fn test_get_recommended_past_blocks() {
380		let network = NetworkBuilder::new()
381			.block_time_ms(1000) // 1 second
382			.confirmation_blocks(2)
383			.cron_schedule("0 */5 * * * *") // every 5 minutes
384			.build();
385
386		let cron_interval_ms = get_cron_interval_ms(&network.cron_schedule).unwrap() as u64; // 300.000 (5 minutes in ms)
387		let blocks_per_cron = cron_interval_ms / network.block_time_ms; // 300.000 / 1000 = 300
388		let recommended_past_blocks = blocks_per_cron + network.confirmation_blocks + 1; // 300 + 2 + 1 = 303
389
390		assert_eq!(
391			network.get_recommended_past_blocks(),
392			recommended_past_blocks
393		);
394	}
395
396	#[test]
397	fn test_validate_valid_network() {
398		let network = create_valid_network();
399		assert!(network.validate().is_ok());
400	}
401
402	#[test]
403	fn test_validate_empty_name() {
404		let network = NetworkBuilder::new().name("").build();
405		assert!(matches!(
406			network.validate(),
407			Err(ConfigError::ValidationError(_))
408		));
409	}
410
411	#[test]
412	fn test_validate_invalid_slug() {
413		let network = NetworkBuilder::new().slug("Invalid-Slug").build();
414		assert!(matches!(
415			network.validate(),
416			Err(ConfigError::ValidationError(_))
417		));
418	}
419
420	#[test]
421	fn test_validate_invalid_rpc_url_type() {
422		let mut network = create_valid_network();
423		network.rpc_urls[0].type_ = "invalid".to_string();
424		assert!(matches!(
425			network.validate(),
426			Err(ConfigError::ValidationError(_))
427		));
428	}
429
430	#[test]
431	fn test_validate_invalid_rpc_url_format() {
432		let network = NetworkBuilder::new().rpc_url("invalid-url").build();
433		assert!(matches!(
434			network.validate(),
435			Err(ConfigError::ValidationError(_))
436		));
437	}
438
439	#[test]
440	fn test_validate_invalid_rpc_weight() {
441		let mut network = create_valid_network();
442		network.rpc_urls[0].weight = 101;
443		assert!(matches!(
444			network.validate(),
445			Err(ConfigError::ValidationError(_))
446		));
447	}
448
449	#[test]
450	fn test_validate_invalid_block_time() {
451		let network = NetworkBuilder::new().block_time_ms(50).build();
452		assert!(matches!(
453			network.validate(),
454			Err(ConfigError::ValidationError(_))
455		));
456	}
457
458	#[test]
459	fn test_validate_zero_confirmation_blocks() {
460		let network = NetworkBuilder::new().confirmation_blocks(0).build();
461		assert!(matches!(
462			network.validate(),
463			Err(ConfigError::ValidationError(_))
464		));
465	}
466
467	#[test]
468	fn test_validate_invalid_cron_schedule() {
469		let network = NetworkBuilder::new().cron_schedule("invalid cron").build();
470		assert!(matches!(
471			network.validate(),
472			Err(ConfigError::ValidationError(_))
473		));
474	}
475
476	#[test]
477	fn test_validate_zero_max_past_blocks() {
478		let network = NetworkBuilder::new().max_past_blocks(0).build();
479		assert!(matches!(
480			network.validate(),
481			Err(ConfigError::ValidationError(_))
482		));
483	}
484
485	#[test]
486	fn test_validate_empty_cron_schedule() {
487		let network = NetworkBuilder::new().cron_schedule("").build();
488		assert!(matches!(
489			network.validate(),
490			Err(ConfigError::ValidationError(_))
491		));
492	}
493
494	#[tokio::test]
495	async fn test_invalid_load_from_path() {
496		let path = Path::new("config/networks/invalid.json");
497		assert!(matches!(
498			Network::load_from_path(path).await,
499			Err(ConfigError::FileError(_))
500		));
501	}
502
503	#[tokio::test]
504	async fn test_invalid_config_from_load_from_path() {
505		use std::io::Write;
506		use tempfile::NamedTempFile;
507
508		let mut temp_file = NamedTempFile::new().unwrap();
509		write!(temp_file, "{{\"invalid\": \"json").unwrap();
510
511		let path = temp_file.path();
512
513		assert!(matches!(
514			Network::load_from_path(path).await,
515			Err(ConfigError::ParseError(_))
516		));
517	}
518
519	#[tokio::test]
520	async fn test_load_all_directory_not_found() {
521		let non_existent_path = Path::new("non_existent_directory");
522
523		let result: Result<HashMap<String, Network>, ConfigError> =
524			Network::load_all(Some(non_existent_path)).await;
525		assert!(matches!(result, Err(ConfigError::FileError(_))));
526
527		if let Err(ConfigError::FileError(err)) = result {
528			assert!(err.message.contains("networks directory not found"));
529		}
530	}
531
532	#[test]
533	#[traced_test]
534	fn test_validate_protocol_insecure_rpc() {
535		let network = NetworkBuilder::new()
536			.name("Test Network")
537			.slug("test_network")
538			.network_type(BlockChainType::EVM)
539			.chain_id(1)
540			.store_blocks(true)
541			.add_rpc_url("http://test.network", "rpc", 100)
542			.add_rpc_url("ws://test.network", "rpc", 100)
543			.build();
544
545		network.validate_protocol();
546		assert!(logs_contain(
547			"uses an insecure RPC URL: http://test.network"
548		));
549		assert!(logs_contain(
550			"uses an insecure WebSocket URL: ws://test.network"
551		));
552	}
553
554	#[test]
555	#[traced_test]
556	fn test_validate_protocol_secure_rpc() {
557		let network = NetworkBuilder::new()
558			.name("Test Network")
559			.slug("test_network")
560			.network_type(BlockChainType::EVM)
561			.chain_id(1)
562			.store_blocks(true)
563			.add_rpc_url("https://test.network", "rpc", 100)
564			.add_rpc_url("wss://test.network", "rpc", 100)
565			.build();
566
567		network.validate_protocol();
568		assert!(!logs_contain("uses an insecure RPC URL"));
569		assert!(!logs_contain("uses an insecure WebSocket URL"));
570	}
571
572	#[test]
573	#[traced_test]
574	fn test_validate_protocol_mixed_security() {
575		let network = NetworkBuilder::new()
576			.name("Test Network")
577			.slug("test_network")
578			.network_type(BlockChainType::EVM)
579			.chain_id(1)
580			.store_blocks(true)
581			.add_rpc_url("https://secure.network", "rpc", 100)
582			.add_rpc_url("http://insecure.network", "rpc", 50)
583			.add_rpc_url("wss://secure.ws.network", "rpc", 25)
584			.add_rpc_url("ws://insecure.ws.network", "rpc", 25)
585			.build();
586
587		network.validate_protocol();
588		assert!(logs_contain(
589			"uses an insecure RPC URL: http://insecure.network"
590		));
591		assert!(logs_contain(
592			"uses an insecure WebSocket URL: ws://insecure.ws.network"
593		));
594		assert!(!logs_contain("https://secure.network"));
595		assert!(!logs_contain("wss://secure.ws.network"));
596	}
597
598	#[tokio::test]
599	async fn test_load_all_duplicate_network_name() {
600		let temp_dir = TempDir::new().unwrap();
601		let file_path_1 = temp_dir.path().join("duplicate_network.json");
602		let file_path_2 = temp_dir.path().join("duplicate_network_2.json");
603
604		let network_config_1 = r#"{
605			"name": " Testnetwork",
606			"slug": "test_network",
607			"network_type": "EVM",
608			"rpc_urls": [
609				{
610					"type_": "rpc",
611					"url": {
612						"type": "plain",
613						"value": "https://eth.drpc.org"
614					},
615					"weight": 100
616				}
617			],
618			"chain_id": 1,
619			"block_time_ms": 1000,
620			"confirmation_blocks": 1,
621			"cron_schedule": "0 */5 * * * *",
622			"max_past_blocks": 10,
623			"store_blocks": true
624		}"#;
625
626		let network_config_2 = r#"{
627			"name": "TestNetwork",
628			"slug": "test_network",
629			"network_type": "EVM",
630			"rpc_urls": [
631				{
632					"type_": "rpc",
633					"url": {
634						"type": "plain",
635						"value": "https://eth.drpc.org"
636					},
637					"weight": 100
638				}
639			],
640			"chain_id": 1,
641			"block_time_ms": 1000,
642			"confirmation_blocks": 1,
643			"cron_schedule": "0 */5 * * * *",
644			"max_past_blocks": 10,
645			"store_blocks": true
646		}"#;
647
648		fs::write(&file_path_1, network_config_1).unwrap();
649		fs::write(&file_path_2, network_config_2).unwrap();
650
651		let result: Result<HashMap<String, Network>, ConfigError> =
652			Network::load_all(Some(temp_dir.path())).await;
653
654		assert!(result.is_err());
655		if let Err(ConfigError::ValidationError(err)) = result {
656			assert!(err.message.contains("Duplicate network name found"));
657		}
658	}
659
660	#[tokio::test]
661	async fn test_load_all_duplicate_network_slug() {
662		let temp_dir = TempDir::new().unwrap();
663		let file_path_1 = temp_dir.path().join("duplicate_network.json");
664		let file_path_2 = temp_dir.path().join("duplicate_network_2.json");
665
666		let network_config_1 = r#"{
667			"name": "Test Network",
668			"slug": "test_network",
669			"network_type": "EVM",
670			"rpc_urls": [
671				{
672					"type_": "rpc",
673					"url": {
674						"type": "plain",
675						"value": "https://eth.drpc.org"
676					},
677					"weight": 100
678				}
679			],
680			"chain_id": 1,
681			"block_time_ms": 1000,
682			"confirmation_blocks": 1,
683			"cron_schedule": "0 */5 * * * *",
684			"max_past_blocks": 10,
685			"store_blocks": true
686		}"#;
687
688		let network_config_2 = r#"{
689			"name": "Test Network 2",
690			"slug": "test_network",
691			"network_type": "EVM",
692			"rpc_urls": [
693				{
694					"type_": "rpc",
695					"url": {
696						"type": "plain",
697						"value": "https://eth.drpc.org"
698					},
699					"weight": 100
700				}
701			],
702			"chain_id": 1,
703			"block_time_ms": 1000,
704			"confirmation_blocks": 1,
705			"cron_schedule": "0 */5 * * * *",
706			"max_past_blocks": 10,
707			"store_blocks": true
708		}"#;
709
710		fs::write(&file_path_1, network_config_1).unwrap();
711		fs::write(&file_path_2, network_config_2).unwrap();
712
713		let result: Result<HashMap<String, Network>, ConfigError> =
714			Network::load_all(Some(temp_dir.path())).await;
715
716		assert!(result.is_err());
717		if let Err(ConfigError::ValidationError(err)) = result {
718			assert!(err.message.contains("Duplicate network slug found"));
719		}
720	}
721}