1use crate::{
7 models::{config::error::ConfigError, ConfigLoader, Monitor, SecretValue},
8 services::trigger::validate_script_config,
9 utils::normalize_string,
10};
11use async_trait::async_trait;
12use futures::TryStreamExt;
13use std::{collections::HashMap, fs, path::Path};
14
15#[async_trait]
16impl ConfigLoader for Monitor {
17 async fn resolve_secrets(&self) -> Result<Self, ConfigError> {
19 dotenvy::dotenv().ok();
20 let mut monitor = self.clone();
21
22 for chain_configuration in &mut monitor.chain_configurations {
23 if let Some(midnight) = &mut chain_configuration.midnight {
25 midnight.viewing_keys = midnight
26 .viewing_keys
27 .iter()
28 .map(|key| async {
29 key.resolve().await.map(SecretValue::Plain).map_err(|e| {
30 ConfigError::parse_error(
31 format!("failed to resolve viewing key: {}", e),
32 Some(Box::new(e)),
33 None,
34 )
35 })
36 })
37 .collect::<futures::stream::FuturesUnordered<_>>()
38 .try_collect()
39 .await?;
40 }
41 }
42 Ok(monitor)
43 }
44
45 async fn load_all<T>(path: Option<&Path>) -> Result<T, ConfigError>
50 where
51 T: FromIterator<(String, Self)>,
52 {
53 let monitor_dir = path.unwrap_or(Path::new("config/monitors"));
54 let mut pairs = Vec::new();
55
56 if !monitor_dir.exists() {
57 return Err(ConfigError::file_error(
58 "monitors directory not found",
59 None,
60 Some(HashMap::from([(
61 "path".to_string(),
62 monitor_dir.display().to_string(),
63 )])),
64 ));
65 }
66
67 for entry in fs::read_dir(monitor_dir).map_err(|e| {
68 ConfigError::file_error(
69 format!("failed to read monitors directory: {}", e),
70 Some(Box::new(e)),
71 Some(HashMap::from([(
72 "path".to_string(),
73 monitor_dir.display().to_string(),
74 )])),
75 )
76 })? {
77 let entry = entry.map_err(|e| {
78 ConfigError::file_error(
79 format!("failed to read directory entry: {}", e),
80 Some(Box::new(e)),
81 Some(HashMap::from([(
82 "path".to_string(),
83 monitor_dir.display().to_string(),
84 )])),
85 )
86 })?;
87 let path = entry.path();
88
89 if !Self::is_json_file(&path) {
90 continue;
91 }
92
93 let name = path
94 .file_stem()
95 .and_then(|s| s.to_str())
96 .unwrap_or("unknown")
97 .to_string();
98
99 let monitor = Self::load_from_path(&path).await?;
100
101 let existing_monitors: Vec<&Monitor> =
102 pairs.iter().map(|(_, monitor)| monitor).collect();
103 Self::validate_uniqueness(&existing_monitors, &monitor, &path.display().to_string())?;
105
106 pairs.push((name, monitor));
107 }
108
109 Ok(T::from_iter(pairs))
110 }
111
112 async fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
116 let file = std::fs::File::open(path).map_err(|e| {
117 ConfigError::file_error(
118 format!("failed to open monitor config file: {}", e),
119 Some(Box::new(e)),
120 Some(HashMap::from([(
121 "path".to_string(),
122 path.display().to_string(),
123 )])),
124 )
125 })?;
126 let mut config: Monitor = serde_json::from_reader(file).map_err(|e| {
127 ConfigError::parse_error(
128 format!("failed to parse monitor config: {}", e),
129 Some(Box::new(e)),
130 Some(HashMap::from([(
131 "path".to_string(),
132 path.display().to_string(),
133 )])),
134 )
135 })?;
136
137 config = config.resolve_secrets().await?;
139
140 config.validate().map_err(|e| {
142 ConfigError::validation_error(
143 format!("monitor validation failed: {}", e),
144 Some(Box::new(e)),
145 Some(HashMap::from([
146 ("path".to_string(), path.display().to_string()),
147 ("monitor_name".to_string(), config.name.clone()),
148 ])),
149 )
150 })?;
151
152 Ok(config)
153 }
154
155 fn validate(&self) -> Result<(), ConfigError> {
157 if self.name.is_empty() {
159 return Err(ConfigError::validation_error(
160 "Monitor name is required",
161 None,
162 None,
163 ));
164 }
165
166 if self.networks.is_empty() {
168 return Err(ConfigError::validation_error(
169 "At least one network must be specified",
170 None,
171 None,
172 ));
173 }
174
175 for func in &self.match_conditions.functions {
177 if !func.signature.contains('(') || !func.signature.contains(')') {
178 return Err(ConfigError::validation_error(
179 format!("Invalid function signature format: {}", func.signature),
180 None,
181 None,
182 ));
183 }
184 }
185
186 for event in &self.match_conditions.events {
188 if !event.signature.contains('(') || !event.signature.contains(')') {
189 return Err(ConfigError::validation_error(
190 format!("Invalid event signature format: {}", event.signature),
191 None,
192 None,
193 ));
194 }
195 }
196
197 for trigger_condition in &self.trigger_conditions {
199 validate_script_config(
200 &trigger_condition.script_path,
201 &trigger_condition.language,
202 &trigger_condition.timeout_ms,
203 )?;
204 }
205
206 self.validate_protocol();
208
209 Ok(())
210 }
211
212 fn validate_protocol(&self) {
216 #[cfg(unix)]
218 for condition in &self.trigger_conditions {
219 use std::os::unix::fs::PermissionsExt;
220 if let Ok(metadata) = std::fs::metadata(&condition.script_path) {
221 let permissions = metadata.permissions();
222 let mode = permissions.mode();
223 if mode & 0o022 != 0 {
224 tracing::warn!(
225 "Monitor '{}' trigger conditions script file has overly permissive write permissions: {}. The recommended permissions are `644` (`rw-r--r--`)",
226 self.name,
227 condition.script_path
228 );
229 }
230 }
231 }
232 }
233
234 fn validate_uniqueness(
235 instances: &[&Self],
236 current_instance: &Self,
237 file_path: &str,
238 ) -> Result<(), ConfigError> {
239 if instances.iter().any(|existing_monitor| {
241 normalize_string(&existing_monitor.name) == normalize_string(¤t_instance.name)
242 }) {
243 Err(ConfigError::validation_error(
244 format!("Duplicate monitor name found: '{}'", current_instance.name),
245 None,
246 Some(HashMap::from([
247 (
248 "monitor_name".to_string(),
249 current_instance.name.to_string(),
250 ),
251 ("path".to_string(), file_path.to_string()),
252 ])),
253 ))
254 } else {
255 Ok(())
256 }
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use crate::{
264 models::core::{ScriptLanguage, TransactionStatus},
265 utils::tests::builders::evm::monitor::MonitorBuilder,
266 };
267 use std::collections::HashMap;
268 use tempfile::TempDir;
269 use tracing_test::traced_test;
270
271 #[tokio::test]
272 async fn test_load_valid_monitor() {
273 let temp_dir = TempDir::new().unwrap();
274 let file_path = temp_dir.path().join("valid_monitor.json");
275
276 let valid_config = r#"{
277 "name": "TestMonitor",
278 "networks": ["ethereum_mainnet"],
279 "paused": false,
280 "addresses": [
281 {
282 "address": "0x0000000000000000000000000000000000000000",
283 "contract_spec": null
284 }
285 ],
286 "match_conditions": {
287 "functions": [
288 {"signature": "transfer(address,uint256)"}
289 ],
290 "events": [
291 {"signature": "Transfer(address,address,uint256)"}
292 ],
293 "transactions": [
294 {
295 "status": "Success",
296 "expression": null
297 }
298 ]
299 },
300 "trigger_conditions": [],
301 "triggers": ["trigger1", "trigger2"]
302 }"#;
303
304 fs::write(&file_path, valid_config).unwrap();
305
306 let result = Monitor::load_from_path(&file_path).await;
307 assert!(result.is_ok());
308
309 let monitor = result.unwrap();
310 assert_eq!(monitor.name, "TestMonitor");
311 }
312
313 #[tokio::test]
314 async fn test_load_invalid_monitor() {
315 let temp_dir = TempDir::new().unwrap();
316 let file_path = temp_dir.path().join("invalid_monitor.json");
317
318 let invalid_config = r#"{
319 "name": "",
320 "description": "Invalid monitor configuration",
321 "match_conditions": {
322 "functions": [
323 {"signature": "invalid_signature"}
324 ],
325 "events": []
326 }
327 }"#;
328
329 fs::write(&file_path, invalid_config).unwrap();
330
331 let result = Monitor::load_from_path(&file_path).await;
332 assert!(result.is_err());
333 }
334
335 #[tokio::test]
336 async fn test_load_all_monitors() {
337 let temp_dir = TempDir::new().unwrap();
338
339 let valid_config_1 = r#"{
340 "name": "TestMonitor1",
341 "networks": ["ethereum_mainnet"],
342 "paused": false,
343 "addresses": [
344 {
345 "address": "0x0000000000000000000000000000000000000000",
346 "contract_spec": null
347 }
348 ],
349 "match_conditions": {
350 "functions": [
351 {"signature": "transfer(address,uint256)"}
352 ],
353 "events": [
354 {"signature": "Transfer(address,address,uint256)"}
355 ],
356 "transactions": [
357 {
358 "status": "Success",
359 "expression": null
360 }
361 ]
362 },
363 "trigger_conditions": [],
364 "triggers": ["trigger1", "trigger2"]
365 }"#;
366
367 let valid_config_2 = r#"{
368 "name": "TestMonitor2",
369 "networks": ["ethereum_mainnet"],
370 "paused": false,
371 "addresses": [
372 {
373 "address": "0x0000000000000000000000000000000000000000",
374 "contract_spec": null
375 }
376 ],
377 "match_conditions": {
378 "functions": [
379 {"signature": "transfer(address,uint256)"}
380 ],
381 "events": [
382 {"signature": "Transfer(address,address,uint256)"}
383 ],
384 "transactions": [
385 {
386 "status": "Success",
387 "expression": null
388 }
389 ]
390 },
391 "trigger_conditions": [],
392 "triggers": ["trigger1", "trigger2"]
393 }"#;
394
395 fs::write(temp_dir.path().join("monitor1.json"), valid_config_1).unwrap();
396 fs::write(temp_dir.path().join("monitor2.json"), valid_config_2).unwrap();
397
398 let result: Result<HashMap<String, Monitor>, _> =
399 Monitor::load_all(Some(temp_dir.path())).await;
400 assert!(result.is_ok());
401
402 let monitors = result.unwrap();
403 assert_eq!(monitors.len(), 2);
404 assert!(monitors.contains_key("monitor1"));
405 assert!(monitors.contains_key("monitor2"));
406 }
407
408 #[test]
409 fn test_validate_monitor() {
410 let valid_monitor = MonitorBuilder::new()
411 .name("TestMonitor")
412 .networks(vec!["ethereum_mainnet".to_string()])
413 .address("0x0000000000000000000000000000000000000000")
414 .function("transfer(address,uint256)", None)
415 .event("Transfer(address,address,uint256)", None)
416 .transaction(TransactionStatus::Success, None)
417 .triggers(vec!["trigger1".to_string()])
418 .build();
419
420 assert!(valid_monitor.validate().is_ok());
421
422 let invalid_monitor = MonitorBuilder::new().name("").build();
423
424 assert!(invalid_monitor.validate().is_err());
425 }
426
427 #[test]
428 fn test_validate_monitor_with_trigger_conditions() {
429 let temp_dir = TempDir::new().unwrap();
431 let script_path = temp_dir.path().join("test_script.py");
432 fs::write(&script_path, "print('test')").unwrap();
433
434 let original_dir = std::env::current_dir().unwrap();
436 std::env::set_current_dir(temp_dir.path()).unwrap();
437
438 let valid_monitor = MonitorBuilder::new()
440 .name("TestMonitor")
441 .networks(vec!["ethereum_mainnet".to_string()])
442 .address("0x0000000000000000000000000000000000000000")
443 .function("transfer(address,uint256)", None)
444 .event("Transfer(address,address,uint256)", None)
445 .transaction(TransactionStatus::Success, None)
446 .trigger_condition("test_script.py", 1000, ScriptLanguage::Python, None)
447 .build();
448
449 assert!(valid_monitor.validate().is_ok());
450
451 std::env::set_current_dir(original_dir).unwrap();
453 }
454
455 #[test]
456 fn test_validate_monitor_with_invalid_script_path() {
457 let invalid_monitor = MonitorBuilder::new()
458 .name("TestMonitor")
459 .networks(vec!["ethereum_mainnet".to_string()])
460 .trigger_condition("non_existent_script.py", 1000, ScriptLanguage::Python, None)
461 .build();
462
463 assert!(invalid_monitor.validate().is_err());
464 }
465
466 #[test]
467 fn test_validate_monitor_with_timeout_zero() {
468 let temp_dir = TempDir::new().unwrap();
470 let script_path = temp_dir.path().join("test_script.py");
471 fs::write(&script_path, "print('test')").unwrap();
472
473 let original_dir = std::env::current_dir().unwrap();
475 std::env::set_current_dir(temp_dir.path()).unwrap();
476
477 let invalid_monitor = MonitorBuilder::new()
478 .name("TestMonitor")
479 .networks(vec!["ethereum_mainnet".to_string()])
480 .trigger_condition("test_script.py", 0, ScriptLanguage::Python, None)
481 .build();
482
483 assert!(invalid_monitor.validate().is_err());
484
485 std::env::set_current_dir(original_dir).unwrap();
487 temp_dir.close().unwrap();
489 }
490
491 #[test]
492 fn test_validate_monitor_with_different_script_languages() {
493 let temp_dir = TempDir::new().unwrap();
495 let temp_path = temp_dir.path().to_owned();
496
497 let python_script = temp_path.join("test_script.py");
498 let js_script = temp_path.join("test_script.js");
499 let bash_script = temp_path.join("test_script.sh");
500
501 fs::write(&python_script, "print('test')").unwrap();
502 fs::write(&js_script, "console.log('test')").unwrap();
503 fs::write(&bash_script, "echo 'test'").unwrap();
504
505 let test_cases = vec![
507 (ScriptLanguage::Python, python_script),
508 (ScriptLanguage::JavaScript, js_script),
509 (ScriptLanguage::Bash, bash_script),
510 ];
511
512 for (language, script_path) in test_cases {
513 let language_clone = language.clone();
514 let script_path_clone = script_path.clone();
515
516 let monitor = MonitorBuilder::new()
517 .name("TestMonitor")
518 .networks(vec!["ethereum_mainnet".to_string()])
519 .trigger_condition(
520 &script_path_clone.to_string_lossy(),
521 1000,
522 language_clone,
523 None,
524 )
525 .build();
526
527 assert!(monitor.validate().is_ok());
528
529 let wrong_path = temp_path.join("test_script.wrong");
531 fs::write(&wrong_path, "test content").unwrap();
532
533 let monitor_wrong_ext = MonitorBuilder::new()
534 .name("TestMonitor")
535 .networks(vec!["ethereum_mainnet".to_string()])
536 .trigger_condition(
537 &wrong_path.to_string_lossy(),
538 monitor.trigger_conditions[0].timeout_ms,
539 language,
540 monitor.trigger_conditions[0].arguments.clone(),
541 )
542 .build();
543
544 assert!(monitor_wrong_ext.validate().is_err());
545 }
546
547 }
549 #[tokio::test]
550 async fn test_invalid_load_from_path() {
551 let path = Path::new("config/monitors/invalid.json");
552 assert!(matches!(
553 Monitor::load_from_path(path).await,
554 Err(ConfigError::FileError(_))
555 ));
556 }
557
558 #[tokio::test]
559 async fn test_invalid_config_from_load_from_path() {
560 use std::io::Write;
561 use tempfile::NamedTempFile;
562
563 let mut temp_file = NamedTempFile::new().unwrap();
564 write!(temp_file, "{{\"invalid\": \"json").unwrap();
565
566 let path = temp_file.path();
567
568 assert!(matches!(
569 Monitor::load_from_path(path).await,
570 Err(ConfigError::ParseError(_))
571 ));
572 }
573
574 #[tokio::test]
575 async fn test_load_all_directory_not_found() {
576 let non_existent_path = Path::new("non_existent_directory");
577
578 let result: Result<HashMap<String, Monitor>, ConfigError> =
580 Monitor::load_all(Some(non_existent_path)).await;
581 assert!(matches!(result, Err(ConfigError::FileError(_))));
582
583 if let Err(ConfigError::FileError(err)) = result {
584 assert!(err.message.contains("monitors directory not found"));
585 }
586 }
587
588 #[cfg(unix)]
589 #[test]
590 #[traced_test]
591 fn test_validate_protocol_script_permissions() {
592 use std::fs::File;
593 use std::os::unix::fs::PermissionsExt;
594 use tempfile::TempDir;
595
596 let temp_dir = TempDir::new().unwrap();
597 let script_path = temp_dir.path().join("test_script.sh");
598 File::create(&script_path).unwrap();
599
600 let metadata = std::fs::metadata(&script_path).unwrap();
602 let mut permissions = metadata.permissions();
603 permissions.set_mode(0o777);
604 std::fs::set_permissions(&script_path, permissions).unwrap();
605
606 let monitor = MonitorBuilder::new()
607 .name("TestMonitor")
608 .networks(vec!["ethereum_mainnet".to_string()])
609 .trigger_condition(
610 script_path.to_str().unwrap(),
611 1000,
612 ScriptLanguage::Bash,
613 None,
614 )
615 .build();
616
617 monitor.validate_protocol();
618 assert!(logs_contain(
619 "script file has overly permissive write permissions"
620 ));
621 }
622
623 #[tokio::test]
624 async fn test_load_all_monitors_duplicate_name() {
625 let temp_dir = TempDir::new().unwrap();
626
627 let valid_config_1 = r#"{
628 "name": "TestMonitor",
629 "networks": ["ethereum_mainnet"],
630 "paused": false,
631 "addresses": [
632 {
633 "address": "0x0000000000000000000000000000000000000000",
634 "contract_spec": null
635 }
636 ],
637 "match_conditions": {
638 "functions": [
639 {"signature": "transfer(address,uint256)"}
640 ],
641 "events": [
642 {"signature": "Transfer(address,address,uint256)"}
643 ],
644 "transactions": [
645 {
646 "status": "Success",
647 "expression": null
648 }
649 ]
650 },
651 "trigger_conditions": [],
652 "triggers": ["trigger1", "trigger2"]
653 }"#;
654
655 let valid_config_2 = r#"{
656 "name": "Testmonitor",
657 "networks": ["ethereum_mainnet"],
658 "paused": false,
659 "addresses": [
660 {
661 "address": "0x0000000000000000000000000000000000000000",
662 "contract_spec": null
663 }
664 ],
665 "match_conditions": {
666 "functions": [
667 {"signature": "transfer(address,uint256)"}
668 ],
669 "events": [
670 {"signature": "Transfer(address,address,uint256)"}
671 ],
672 "transactions": [
673 {
674 "status": "Success",
675 "expression": null
676 }
677 ]
678 },
679 "trigger_conditions": [],
680 "triggers": ["trigger1", "trigger2"]
681 }"#;
682
683 fs::write(temp_dir.path().join("monitor1.json"), valid_config_1).unwrap();
684 fs::write(temp_dir.path().join("monitor2.json"), valid_config_2).unwrap();
685
686 let result: Result<HashMap<String, Monitor>, _> =
687 Monitor::load_all(Some(temp_dir.path())).await;
688
689 assert!(result.is_err());
690 if let Err(ConfigError::ValidationError(err)) = result {
691 assert!(err.message.contains("Duplicate monitor name found"));
692 }
693 }
694}