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