1use 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 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 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 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 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 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 config = config.resolve_secrets().await?;
151
152 config.validate()?;
154
155 Ok(config)
156 }
157
158 fn validate(&self) -> Result<(), ConfigError> {
166 if self.name.is_empty() {
168 return Err(ConfigError::validation_error(
169 "Network name is required",
170 None,
171 None,
172 ));
173 }
174
175 match self.network_type {
177 BlockChainType::EVM | BlockChainType::Stellar | BlockChainType::Midnight => {}
178 #[allow(unreachable_patterns)]
179 _ => {
180 return Err(ConfigError::validation_error(
181 "Invalid network_type",
182 None,
183 None,
184 ));
185 }
186 }
187
188 if !self
190 .slug
191 .chars()
192 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
193 {
194 return Err(ConfigError::validation_error(
195 "Slug must contain only lowercase letters, numbers, and underscores",
196 None,
197 None,
198 ));
199 }
200
201 let (supported_types, supported_protocols) = match self.network_type {
203 BlockChainType::Midnight => (vec!["ws_rpc"], vec!["wss://", "ws://"]),
204 _ => (vec!["rpc"], vec!["http://", "https://", "wss://", "ws://"]),
205 };
206
207 for rpc_url in &self.rpc_urls {
208 let type_valid = supported_types.contains(&rpc_url.type_.as_str());
209 let protocol_valid = supported_protocols
210 .iter()
211 .any(|protocol| rpc_url.url.starts_with(protocol));
212 let weight_valid = rpc_url.weight <= 100;
213
214 if !type_valid || !protocol_valid || !weight_valid {
215 return Err(ConfigError::validation_error(
216 format!(
217 "Invalid RPC URL configuration for {:?} network:\n\
218 Type: {} (must be one of: {})\n\
219 Protocol: must start with one of: {}\n\
220 Weight: {} (must be <= 100)",
221 self.network_type,
222 rpc_url.type_,
223 supported_types.join(", "),
224 supported_protocols.join(", "),
225 rpc_url.weight
226 ),
227 None,
228 None,
229 ));
230 }
231 }
232
233 if self.block_time_ms < 100 {
235 return Err(ConfigError::validation_error(
236 "Block time must be at least 100ms",
237 None,
238 None,
239 ));
240 }
241
242 if self.confirmation_blocks == 0 {
244 return Err(ConfigError::validation_error(
245 "Confirmation blocks must be greater than 0",
246 None,
247 None,
248 ));
249 }
250
251 if self.cron_schedule.is_empty() {
253 return Err(ConfigError::validation_error(
254 "Cron schedule must be provided",
255 None,
256 None,
257 ));
258 }
259
260 if let Err(e) = cron::Schedule::from_str(&self.cron_schedule) {
262 return Err(ConfigError::validation_error(e.to_string(), None, None));
263 }
264
265 if let Some(max_blocks) = self.max_past_blocks {
267 if max_blocks == 0 {
268 return Err(ConfigError::validation_error(
269 "max_past_blocks must be greater than 0",
270 None,
271 None,
272 ));
273 }
274
275 let recommended_blocks = self.get_recommended_past_blocks();
276
277 if max_blocks < recommended_blocks {
278 tracing::warn!(
279 "Network '{}' max_past_blocks ({}) below recommended {} \
280 (cron_interval/block_time + confirmations + 1)",
281 self.slug,
282 max_blocks,
283 recommended_blocks
284 );
285 }
286 }
287
288 self.validate_protocol();
290
291 Ok(())
292 }
293
294 fn validate_protocol(&self) {
298 for rpc_url in &self.rpc_urls {
299 if rpc_url.url.starts_with("http://") {
300 tracing::warn!(
301 "Network '{}' uses an insecure RPC URL: {}",
302 self.slug,
303 rpc_url.url.as_str()
304 );
305 }
306 if rpc_url.url.starts_with("ws://") {
308 tracing::warn!(
309 "Network '{}' uses an insecure WebSocket URL: {}",
310 self.slug,
311 rpc_url.url.as_str()
312 );
313 }
314 }
315 }
316
317 fn validate_uniqueness(
318 instances: &[&Self],
319 current_instance: &Self,
320 file_path: &str,
321 ) -> Result<(), ConfigError> {
322 let fields = [
323 ("name", ¤t_instance.name),
324 ("slug", ¤t_instance.slug),
325 ];
326
327 for (field_name, field_value) in fields {
328 if instances.iter().any(|existing_network| {
329 let existing_value = match field_name {
330 "name" => &existing_network.name,
331 "slug" => &existing_network.slug,
332 _ => unreachable!(),
333 };
334 normalize_string(existing_value) == normalize_string(field_value)
335 }) {
336 return Err(ConfigError::validation_error(
337 format!("Duplicate network {} found: '{}'", field_name, field_value),
338 None,
339 Some(HashMap::from([
340 (format!("network_{}", field_name), field_value.to_string()),
341 ("path".to_string(), file_path.to_string()),
342 ])),
343 ));
344 }
345 }
346 Ok(())
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::{models::SecretString, utils::tests::builders::network::NetworkBuilder};
354 use std::fs;
355 use tempfile::TempDir;
356 use tracing_test::traced_test;
357
358 fn create_valid_network() -> Network {
360 NetworkBuilder::new()
361 .name("Test Network")
362 .slug("test_network")
363 .network_type(BlockChainType::EVM)
364 .chain_id(1)
365 .store_blocks(true)
366 .rpc_url("https://test.network")
367 .block_time_ms(1000)
368 .confirmation_blocks(1)
369 .cron_schedule("0 */5 * * * *")
370 .max_past_blocks(10)
371 .build()
372 }
373
374 fn create_valid_midnight_network() -> Network {
375 NetworkBuilder::new()
376 .name("Test Midnight Network")
377 .slug("test_midnight_network")
378 .network_type(BlockChainType::Midnight)
379 .chain_id(0)
380 .store_blocks(false)
381 .add_rpc_url("wss://test.midnight.network", "ws_rpc", 100)
382 .block_time_ms(1000)
383 .confirmation_blocks(1)
384 .cron_schedule("0 */5 * * * *")
385 .max_past_blocks(10)
386 .build()
387 }
388
389 #[test]
390 fn test_get_recommended_past_blocks() {
391 let network = NetworkBuilder::new()
392 .block_time_ms(1000) .confirmation_blocks(2)
394 .cron_schedule("0 */5 * * * *") .build();
396
397 let cron_interval_ms = get_cron_interval_ms(&network.cron_schedule).unwrap() as u64; let blocks_per_cron = cron_interval_ms / network.block_time_ms; let recommended_past_blocks = blocks_per_cron + network.confirmation_blocks + 1; assert_eq!(
402 network.get_recommended_past_blocks(),
403 recommended_past_blocks
404 );
405 }
406
407 #[test]
408 fn test_validate_valid_network() {
409 let network = create_valid_network();
410 assert!(network.validate().is_ok());
411 }
412
413 #[test]
414 fn test_validate_empty_name() {
415 let network = NetworkBuilder::new().name("").build();
416 assert!(matches!(
417 network.validate(),
418 Err(ConfigError::ValidationError(_))
419 ));
420 }
421
422 #[test]
423 fn test_validate_invalid_slug() {
424 let network = NetworkBuilder::new().slug("Invalid-Slug").build();
425 assert!(matches!(
426 network.validate(),
427 Err(ConfigError::ValidationError(_))
428 ));
429 }
430
431 #[test]
432 fn test_validate_invalid_rpc_url_type() {
433 let mut network = create_valid_network();
434 network.rpc_urls[0].type_ = "invalid".to_string();
435 assert!(matches!(
436 network.validate(),
437 Err(ConfigError::ValidationError(_))
438 ));
439 }
440
441 #[test]
442 fn test_validate_invalid_rpc_url_format() {
443 let network = NetworkBuilder::new().rpc_url("invalid-url").build();
444 assert!(matches!(
445 network.validate(),
446 Err(ConfigError::ValidationError(_))
447 ));
448 }
449
450 #[test]
451 fn test_validate_invalid_rpc_weight() {
452 let mut network = create_valid_network();
453 network.rpc_urls[0].weight = 101;
454 assert!(matches!(
455 network.validate(),
456 Err(ConfigError::ValidationError(_))
457 ));
458 }
459
460 #[test]
461 fn test_validate_invalid_block_time() {
462 let network = NetworkBuilder::new().block_time_ms(50).build();
463 assert!(matches!(
464 network.validate(),
465 Err(ConfigError::ValidationError(_))
466 ));
467 }
468
469 #[test]
470 fn test_validate_zero_confirmation_blocks() {
471 let network = NetworkBuilder::new().confirmation_blocks(0).build();
472 assert!(matches!(
473 network.validate(),
474 Err(ConfigError::ValidationError(_))
475 ));
476 }
477
478 #[test]
479 fn test_validate_invalid_cron_schedule() {
480 let network = NetworkBuilder::new().cron_schedule("invalid cron").build();
481 assert!(matches!(
482 network.validate(),
483 Err(ConfigError::ValidationError(_))
484 ));
485 }
486
487 #[test]
488 fn test_validate_zero_max_past_blocks() {
489 let network = NetworkBuilder::new().max_past_blocks(0).build();
490 assert!(matches!(
491 network.validate(),
492 Err(ConfigError::ValidationError(_))
493 ));
494 }
495
496 #[test]
497 fn test_validate_empty_cron_schedule() {
498 let network = NetworkBuilder::new().cron_schedule("").build();
499 assert!(matches!(
500 network.validate(),
501 Err(ConfigError::ValidationError(_))
502 ));
503 }
504
505 #[tokio::test]
506 async fn test_invalid_load_from_path() {
507 let path = Path::new("config/networks/invalid.json");
508 assert!(matches!(
509 Network::load_from_path(path).await,
510 Err(ConfigError::FileError(_))
511 ));
512 }
513
514 #[tokio::test]
515 async fn test_invalid_config_from_load_from_path() {
516 use std::io::Write;
517 use tempfile::NamedTempFile;
518
519 let mut temp_file = NamedTempFile::new().unwrap();
520 write!(temp_file, "{{\"invalid\": \"json").unwrap();
521
522 let path = temp_file.path();
523
524 assert!(matches!(
525 Network::load_from_path(path).await,
526 Err(ConfigError::ParseError(_))
527 ));
528 }
529
530 #[tokio::test]
531 async fn test_load_all_directory_not_found() {
532 let non_existent_path = Path::new("non_existent_directory");
533
534 let result: Result<HashMap<String, Network>, ConfigError> =
535 Network::load_all(Some(non_existent_path)).await;
536 assert!(matches!(result, Err(ConfigError::FileError(_))));
537
538 if let Err(ConfigError::FileError(err)) = result {
539 assert!(err.message.contains("networks directory not found"));
540 }
541 }
542
543 #[test]
544 #[traced_test]
545 fn test_validate_protocol_insecure_rpc() {
546 let network = NetworkBuilder::new()
547 .name("Test Network")
548 .slug("test_network")
549 .network_type(BlockChainType::EVM)
550 .chain_id(1)
551 .store_blocks(true)
552 .add_rpc_url("http://test.network", "rpc", 100)
553 .add_rpc_url("ws://test.network", "rpc", 100)
554 .build();
555
556 network.validate_protocol();
557 assert!(logs_contain(
558 "uses an insecure RPC URL: http://test.network"
559 ));
560 assert!(logs_contain(
561 "uses an insecure WebSocket URL: ws://test.network"
562 ));
563 }
564
565 #[test]
566 #[traced_test]
567 fn test_validate_protocol_secure_rpc() {
568 let network = NetworkBuilder::new()
569 .name("Test Network")
570 .slug("test_network")
571 .network_type(BlockChainType::EVM)
572 .chain_id(1)
573 .store_blocks(true)
574 .add_rpc_url("https://test.network", "rpc", 100)
575 .add_rpc_url("wss://test.network", "rpc", 100)
576 .build();
577
578 network.validate_protocol();
579 assert!(!logs_contain("uses an insecure RPC URL"));
580 assert!(!logs_contain("uses an insecure WebSocket URL"));
581 }
582
583 #[test]
584 #[traced_test]
585 fn test_validate_protocol_mixed_security() {
586 let network = NetworkBuilder::new()
587 .name("Test Network")
588 .slug("test_network")
589 .network_type(BlockChainType::EVM)
590 .chain_id(1)
591 .store_blocks(true)
592 .add_rpc_url("https://secure.network", "rpc", 100)
593 .add_rpc_url("http://insecure.network", "rpc", 50)
594 .add_rpc_url("wss://secure.ws.network", "rpc", 25)
595 .add_rpc_url("ws://insecure.ws.network", "rpc", 25)
596 .build();
597
598 network.validate_protocol();
599 assert!(logs_contain(
600 "uses an insecure RPC URL: http://insecure.network"
601 ));
602 assert!(logs_contain(
603 "uses an insecure WebSocket URL: ws://insecure.ws.network"
604 ));
605 assert!(!logs_contain("https://secure.network"));
606 assert!(!logs_contain("wss://secure.ws.network"));
607 }
608
609 #[test]
610 fn test_validate_midnight_network_valid() {
611 let network = create_valid_midnight_network();
612 assert!(network.validate().is_ok());
613 }
614
615 #[test]
616 fn test_validate_midnight_network_invalid_type() {
617 let mut network = create_valid_midnight_network();
618 network.rpc_urls[0].type_ = "rpc".to_string();
619 let result = network.validate();
620 assert!(matches!(result, Err(ConfigError::ValidationError(_))));
621 if let Err(ConfigError::ValidationError(err)) = result {
622 assert!(err.message.contains("Type: rpc (must be one of: ws_rpc)"));
623 }
624 }
625
626 #[test]
627 fn test_validate_midnight_network_invalid_protocol() {
628 let mut network = create_valid_midnight_network();
629 network.rpc_urls[0].url = SecretValue::Plain(SecretString::new(
630 "https://test.midnight.network".to_string(),
631 ));
632 let result = network.validate();
633 assert!(matches!(result, Err(ConfigError::ValidationError(_))));
634 if let Err(ConfigError::ValidationError(err)) = result {
635 assert!(err
636 .message
637 .contains("Protocol: must start with one of: wss://, ws://"));
638 }
639 }
640
641 #[test]
642 fn test_validate_evm_network_valid() {
643 let network = create_valid_network();
644 assert!(network.validate().is_ok());
645 }
646
647 #[test]
648 fn test_validate_evm_network_invalid_type() {
649 let mut network = create_valid_network();
650 network.rpc_urls[0].type_ = "ws_rpc".to_string();
651 let result = network.validate();
652 assert!(matches!(result, Err(ConfigError::ValidationError(_))));
653 if let Err(ConfigError::ValidationError(err)) = result {
654 assert!(err.message.contains("Type: ws_rpc (must be one of: rpc)"));
655 }
656 }
657
658 #[test]
659 fn test_validate_evm_network_invalid_protocol() {
660 let mut network = create_valid_network();
661 network.rpc_urls[0].url =
662 SecretValue::Plain(SecretString::new("invalid://test.network".to_string()));
663 let result = network.validate();
664 assert!(matches!(result, Err(ConfigError::ValidationError(_))));
665 if let Err(ConfigError::ValidationError(err)) = result {
666 assert!(err
667 .message
668 .contains("Protocol: must start with one of: http://, https://, wss://, ws://"));
669 }
670 }
671
672 #[tokio::test]
673 async fn test_load_all_duplicate_network_name() {
674 let temp_dir = TempDir::new().unwrap();
675 let file_path_1 = temp_dir.path().join("duplicate_network.json");
676 let file_path_2 = temp_dir.path().join("duplicate_network_2.json");
677
678 let network_config_1 = r#"{
679 "name": " Testnetwork",
680 "slug": "test_network",
681 "network_type": "EVM",
682 "rpc_urls": [
683 {
684 "type_": "rpc",
685 "url": {
686 "type": "plain",
687 "value": "https://eth.drpc.org"
688 },
689 "weight": 100
690 }
691 ],
692 "chain_id": 1,
693 "block_time_ms": 1000,
694 "confirmation_blocks": 1,
695 "cron_schedule": "0 */5 * * * *",
696 "max_past_blocks": 10,
697 "store_blocks": true
698 }"#;
699
700 let network_config_2 = r#"{
701 "name": "TestNetwork",
702 "slug": "test_network",
703 "network_type": "EVM",
704 "rpc_urls": [
705 {
706 "type_": "rpc",
707 "url": {
708 "type": "plain",
709 "value": "https://eth.drpc.org"
710 },
711 "weight": 100
712 }
713 ],
714 "chain_id": 1,
715 "block_time_ms": 1000,
716 "confirmation_blocks": 1,
717 "cron_schedule": "0 */5 * * * *",
718 "max_past_blocks": 10,
719 "store_blocks": true
720 }"#;
721
722 fs::write(&file_path_1, network_config_1).unwrap();
723 fs::write(&file_path_2, network_config_2).unwrap();
724
725 let result: Result<HashMap<String, Network>, ConfigError> =
726 Network::load_all(Some(temp_dir.path())).await;
727
728 assert!(result.is_err());
729 if let Err(ConfigError::ValidationError(err)) = result {
730 assert!(err.message.contains("Duplicate network name found"));
731 }
732 }
733
734 #[tokio::test]
735 async fn test_load_all_duplicate_network_slug() {
736 let temp_dir = TempDir::new().unwrap();
737 let file_path_1 = temp_dir.path().join("duplicate_network.json");
738 let file_path_2 = temp_dir.path().join("duplicate_network_2.json");
739
740 let network_config_1 = r#"{
741 "name": "Test Network",
742 "slug": "test_network",
743 "network_type": "EVM",
744 "rpc_urls": [
745 {
746 "type_": "rpc",
747 "url": {
748 "type": "plain",
749 "value": "https://eth.drpc.org"
750 },
751 "weight": 100
752 }
753 ],
754 "chain_id": 1,
755 "block_time_ms": 1000,
756 "confirmation_blocks": 1,
757 "cron_schedule": "0 */5 * * * *",
758 "max_past_blocks": 10,
759 "store_blocks": true
760 }"#;
761
762 let network_config_2 = r#"{
763 "name": "Test Network 2",
764 "slug": "test_network",
765 "network_type": "EVM",
766 "rpc_urls": [
767 {
768 "type_": "rpc",
769 "url": {
770 "type": "plain",
771 "value": "https://eth.drpc.org"
772 },
773 "weight": 100
774 }
775 ],
776 "chain_id": 1,
777 "block_time_ms": 1000,
778 "confirmation_blocks": 1,
779 "cron_schedule": "0 */5 * * * *",
780 "max_past_blocks": 10,
781 "store_blocks": true
782 }"#;
783
784 fs::write(&file_path_1, network_config_1).unwrap();
785 fs::write(&file_path_2, network_config_2).unwrap();
786
787 let result: Result<HashMap<String, Network>, ConfigError> =
788 Network::load_all(Some(temp_dir.path())).await;
789
790 assert!(result.is_err());
791 if let Err(ConfigError::ValidationError(err)) = result {
792 assert!(err.message.contains("Duplicate network slug found"));
793 }
794 }
795}