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 => {}
178 _ => {
179 return Err(ConfigError::validation_error(
180 "Invalid network_type",
181 None,
182 None,
183 ));
184 }
185 }
186
187 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 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 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 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 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 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 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 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 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 self.validate_protocol();
294
295 Ok(())
296 }
297
298 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 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", ¤t_instance.name),
328 ("slug", ¤t_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 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) .confirmation_blocks(2)
383 .cron_schedule("0 */5 * * * *") .build();
385
386 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!(
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}