1use crate::{
25 config::ConfigFileError,
26 models::{
27 relayer::{RelayerFileConfig, RelayersFileConfig},
28 signer::{SignerFileConfig, SignersFileConfig},
29 NotificationConfig, NotificationConfigs,
30 },
31};
32use serde::{Deserialize, Serialize};
33use std::{
34 collections::HashSet,
35 fs::{self},
36};
37
38mod plugin;
39pub use plugin::*;
40
41pub mod network;
42pub use network::{
43 EvmNetworkConfig, GasPriceCacheConfig, NetworkConfigCommon, NetworkFileConfig,
44 NetworksFileConfig, SolanaNetworkConfig, StellarNetworkConfig,
45};
46
47#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)]
48#[serde(rename_all = "lowercase")]
49pub enum ConfigFileNetworkType {
50 Evm,
51 Stellar,
52 Solana,
53}
54
55#[derive(Debug, Serialize, Deserialize, Clone)]
56pub struct Config {
57 pub relayers: Vec<RelayerFileConfig>,
58 pub signers: Vec<SignerFileConfig>,
59 pub notifications: Vec<NotificationConfig>,
60 pub networks: NetworksFileConfig,
61 pub plugins: Option<Vec<PluginFileConfig>>,
62}
63
64impl Config {
65 pub fn validate(&self) -> Result<(), ConfigFileError> {
74 self.validate_networks()?;
75 self.validate_relayers(&self.networks)?;
76 self.validate_signers()?;
77 self.validate_notifications()?;
78 self.validate_plugins()?;
79
80 self.validate_relayer_signer_refs()?;
81 self.validate_relayer_notification_refs()?;
82
83 Ok(())
84 }
85
86 fn validate_relayer_signer_refs(&self) -> Result<(), ConfigFileError> {
95 let signer_ids: HashSet<_> = self.signers.iter().map(|s| &s.id).collect();
96
97 for relayer in &self.relayers {
98 if !signer_ids.contains(&relayer.signer_id) {
99 return Err(ConfigFileError::InvalidReference(format!(
100 "Relayer '{}' references non-existent signer '{}'",
101 relayer.id, relayer.signer_id
102 )));
103 }
104 }
105
106 Ok(())
107 }
108
109 fn validate_relayer_notification_refs(&self) -> Result<(), ConfigFileError> {
117 let notification_ids: HashSet<_> = self.notifications.iter().map(|s| &s.id).collect();
118
119 for relayer in &self.relayers {
120 if let Some(notification_id) = &relayer.notification_id {
121 if !notification_ids.contains(notification_id) {
122 return Err(ConfigFileError::InvalidReference(format!(
123 "Relayer '{}' references non-existent notification '{}'",
124 relayer.id, notification_id
125 )));
126 }
127 }
128 }
129
130 Ok(())
131 }
132
133 fn validate_relayers(&self, networks: &NetworksFileConfig) -> Result<(), ConfigFileError> {
135 RelayersFileConfig::new(self.relayers.clone()).validate(networks)
136 }
137
138 fn validate_signers(&self) -> Result<(), ConfigFileError> {
140 SignersFileConfig::new(self.signers.clone()).validate()
141 }
142
143 fn validate_notifications(&self) -> Result<(), ConfigFileError> {
145 NotificationConfigs::new(self.notifications.clone()).validate()
146 }
147
148 fn validate_networks(&self) -> Result<(), ConfigFileError> {
150 if self.networks.is_empty() {
151 return Ok(()); }
153
154 self.networks.validate()
155 }
156
157 fn validate_plugins(&self) -> Result<(), ConfigFileError> {
159 if let Some(plugins) = &self.plugins {
160 PluginsFileConfig::new(plugins.clone()).validate()
161 } else {
162 Ok(())
163 }
164 }
165}
166
167pub fn load_config(config_file_path: &str) -> Result<Config, ConfigFileError> {
179 let config_str = fs::read_to_string(config_file_path)?;
180 let config: Config = serde_json::from_str(&config_str)?;
181 config.validate()?;
182 Ok(config)
183}
184
185#[cfg(test)]
186mod tests {
187 use crate::models::{
188 signer::{LocalSignerFileConfig, SignerFileConfig, SignerFileConfigEnum},
189 NotificationType, PlainOrEnvValue, SecretString,
190 };
191 use std::path::Path;
192
193 use super::*;
194
195 fn create_valid_config() -> Config {
196 Config {
197 relayers: vec![RelayerFileConfig {
198 id: "test-1".to_string(),
199 name: "Test Relayer".to_string(),
200 network: "test-network".to_string(),
201 paused: false,
202 network_type: ConfigFileNetworkType::Evm,
203 policies: None,
204 signer_id: "test-1".to_string(),
205 notification_id: Some("test-1".to_string()),
206 custom_rpc_urls: None,
207 }],
208 signers: vec![SignerFileConfig {
209 id: "test-1".to_string(),
210 config: SignerFileConfigEnum::Local(LocalSignerFileConfig {
211 path: "tests/utils/test_keys/unit-test-local-signer.json".to_string(),
212 passphrase: PlainOrEnvValue::Plain {
213 value: SecretString::new("test"),
214 },
215 }),
216 }],
217 notifications: vec![NotificationConfig {
218 id: "test-1".to_string(),
219 r#type: NotificationType::Webhook,
220 url: "https://api.example.com/notifications".to_string(),
221 signing_key: None,
222 }],
223 networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
224 common: NetworkConfigCommon {
225 network: "test-network".to_string(),
226 from: None,
227 rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]),
228 explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
229 average_blocktime_ms: Some(12000),
230 is_testnet: Some(true),
231 tags: Some(vec!["test".to_string()]),
232 },
233 chain_id: Some(31337),
234 required_confirmations: Some(1),
235 features: None,
236 symbol: Some("ETH".to_string()),
237 gas_price_cache: None,
238 })])
239 .expect("Failed to create NetworksFileConfig for test"),
240 plugins: Some(vec![PluginFileConfig {
241 id: "test-1".to_string(),
242 path: "/app/plugins/test-plugin.ts".to_string(),
243 timeout: None,
244 emit_logs: false,
245 emit_traces: false,
246 }]),
247 }
248 }
249
250 #[test]
251 fn test_valid_config_validation() {
252 let config = create_valid_config();
253 assert!(config.validate().is_ok());
254 }
255
256 #[test]
257 fn test_empty_relayers() {
258 let config = Config {
259 relayers: Vec::new(),
260 signers: Vec::new(),
261 notifications: Vec::new(),
262 networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
263 common: NetworkConfigCommon {
264 network: "test-network".to_string(),
265 from: None,
266 rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]),
267 explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
268 average_blocktime_ms: Some(12000),
269 is_testnet: Some(true),
270 tags: Some(vec!["test".to_string()]),
271 },
272 chain_id: Some(31337),
273 required_confirmations: Some(1),
274 features: None,
275 symbol: Some("ETH".to_string()),
276 gas_price_cache: None,
277 })])
278 .unwrap(),
279 plugins: Some(vec![]),
280 };
281 assert!(config.validate().is_ok());
282 }
283
284 #[test]
285 fn test_empty_signers() {
286 let config = Config {
287 relayers: Vec::new(),
288 signers: Vec::new(),
289 notifications: Vec::new(),
290 networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
291 common: NetworkConfigCommon {
292 network: "test-network".to_string(),
293 from: None,
294 rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]),
295 explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
296 average_blocktime_ms: Some(12000),
297 is_testnet: Some(true),
298 tags: Some(vec!["test".to_string()]),
299 },
300 chain_id: Some(31337),
301 required_confirmations: Some(1),
302 features: None,
303 symbol: Some("ETH".to_string()),
304 gas_price_cache: None,
305 })])
306 .unwrap(),
307 plugins: Some(vec![]),
308 };
309 assert!(config.validate().is_ok());
310 }
311
312 #[test]
313 fn test_invalid_id_format() {
314 let mut config = create_valid_config();
315 config.relayers[0].id = "invalid@id".to_string();
316 assert!(matches!(
317 config.validate(),
318 Err(ConfigFileError::InvalidIdFormat(_))
319 ));
320 }
321
322 #[test]
323 fn test_id_too_long() {
324 let mut config = create_valid_config();
325 config.relayers[0].id = "a".repeat(37);
326 assert!(matches!(
327 config.validate(),
328 Err(ConfigFileError::InvalidIdLength(_))
329 ));
330 }
331
332 #[test]
333 fn test_relayers_duplicate_ids() {
334 let mut config = create_valid_config();
335 config.relayers.push(config.relayers[0].clone());
336 assert!(matches!(
337 config.validate(),
338 Err(ConfigFileError::DuplicateId(_))
339 ));
340 }
341
342 #[test]
343 fn test_signers_duplicate_ids() {
344 let mut config = create_valid_config();
345 config.signers.push(config.signers[0].clone());
346
347 assert!(matches!(
348 config.validate(),
349 Err(ConfigFileError::DuplicateId(_))
350 ));
351 }
352
353 #[test]
354 fn test_missing_name() {
355 let mut config = create_valid_config();
356 config.relayers[0].name = "".to_string();
357 assert!(matches!(
358 config.validate(),
359 Err(ConfigFileError::MissingField(_))
360 ));
361 }
362
363 #[test]
364 fn test_missing_network() {
365 let mut config = create_valid_config();
366 config.relayers[0].network = "".to_string();
367 assert!(matches!(
368 config.validate(),
369 Err(ConfigFileError::InvalidFormat(_))
370 ));
371 }
372
373 #[test]
374 fn test_invalid_signer_id_reference() {
375 let mut config = create_valid_config();
376 config.relayers[0].signer_id = "invalid@id".to_string();
377 assert!(matches!(
378 config.validate(),
379 Err(ConfigFileError::InvalidReference(_))
380 ));
381 }
382
383 #[test]
384 fn test_invalid_notification_id_reference() {
385 let mut config = create_valid_config();
386 config.relayers[0].notification_id = Some("invalid@id".to_string());
387 assert!(matches!(
388 config.validate(),
389 Err(ConfigFileError::InvalidReference(_))
390 ));
391 }
392
393 #[test]
394 fn test_config_with_networks() {
395 let mut config = create_valid_config();
396 config.relayers[0].network = "custom-evm".to_string();
397
398 let network_items = vec![serde_json::from_value(serde_json::json!({
399 "type": "evm",
400 "network": "custom-evm",
401 "required_confirmations": 1,
402 "chain_id": 1234,
403 "rpc_urls": ["https://rpc.example.com"],
404 "symbol": "ETH"
405 }))
406 .unwrap()];
407 config.networks = NetworksFileConfig::new(network_items).unwrap();
408
409 assert!(
410 config.validate().is_ok(),
411 "Error validating config: {:?}",
412 config.validate().err()
413 );
414 }
415
416 #[test]
417 fn test_config_with_invalid_networks() {
418 let mut config = create_valid_config();
419 let network_items = vec![serde_json::from_value(serde_json::json!({
420 "type": "evm",
421 "network": "invalid-network",
422 "rpc_urls": ["https://rpc.example.com"]
423 }))
424 .unwrap()];
425 config.networks = NetworksFileConfig::new(network_items.clone())
426 .expect("Should allow creation, validation happens later or should fail here");
427
428 let result = config.validate();
429 assert!(result.is_err());
430 assert!(matches!(
431 result,
432 Err(ConfigFileError::MissingField(_)) | Err(ConfigFileError::InvalidFormat(_))
433 ));
434 }
435
436 #[test]
437 fn test_config_with_duplicate_network_names() {
438 let mut config = create_valid_config();
439 let network_items = vec![
440 serde_json::from_value(serde_json::json!({
441 "type": "evm",
442 "network": "custom-evm",
443 "chain_id": 1234,
444 "rpc_urls": ["https://rpc1.example.com"]
445 }))
446 .unwrap(),
447 serde_json::from_value(serde_json::json!({
448 "type": "evm",
449 "network": "custom-evm",
450 "chain_id": 5678,
451 "rpc_urls": ["https://rpc2.example.com"]
452 }))
453 .unwrap(),
454 ];
455 let networks_config_result = NetworksFileConfig::new(network_items);
456 assert!(
457 networks_config_result.is_err(),
458 "NetworksFileConfig::new should detect duplicate IDs"
459 );
460
461 if let Ok(parsed_networks) = networks_config_result {
462 config.networks = parsed_networks;
463 let result = config.validate();
464 assert!(result.is_err());
465 assert!(matches!(result, Err(ConfigFileError::DuplicateId(_))));
466 } else if let Err(e) = networks_config_result {
467 assert!(matches!(e, ConfigFileError::DuplicateId(_)));
468 }
469 }
470
471 #[test]
472 fn test_config_with_invalid_network_inheritance() {
473 let mut config = create_valid_config();
474 let network_items = vec![serde_json::from_value(serde_json::json!({
475 "type": "evm",
476 "network": "custom-evm",
477 "from": "non-existent-network",
478 "rpc_urls": ["https://rpc.example.com"]
479 }))
480 .unwrap()];
481 let networks_config_result = NetworksFileConfig::new(network_items);
482
483 match networks_config_result {
484 Ok(parsed_networks) => {
485 config.networks = parsed_networks;
486 let validation_result = config.validate();
487 assert!(
488 validation_result.is_err(),
489 "Validation should fail due to invalid inheritance reference"
490 );
491 assert!(matches!(
492 validation_result,
493 Err(ConfigFileError::InvalidReference(_))
494 ));
495 }
496 Err(e) => {
497 assert!(
498 matches!(e, ConfigFileError::InvalidReference(_)),
499 "Expected InvalidReference from new or flatten"
500 );
501 }
502 }
503 }
504
505 #[test]
506 fn test_deserialize_config_with_evm_network() {
507 let config_str = r#"
508 {
509 "relayers": [],
510 "signers": [],
511 "notifications": [],
512 "plugins": [],
513 "networks": [
514 {
515 "type": "evm",
516 "network": "custom-evm",
517 "chain_id": 1234,
518 "required_confirmations": 1,
519 "symbol": "ETH",
520 "rpc_urls": ["https://rpc.example.com"]
521 }
522 ]
523 }
524 "#;
525 let result: Result<Config, _> = serde_json::from_str(config_str);
526 assert!(result.is_ok());
527 let config = result.unwrap();
528 assert_eq!(config.networks.len(), 1);
529
530 let network_config = config.networks.first().expect("Should have one network");
531 assert!(matches!(network_config, NetworkFileConfig::Evm(_)));
532 if let NetworkFileConfig::Evm(evm_config) = network_config {
533 assert_eq!(evm_config.common.network, "custom-evm");
534 assert_eq!(evm_config.chain_id, Some(1234));
535 }
536 }
537
538 #[test]
539 fn test_deserialize_config_with_solana_network() {
540 let config_str = r#"
541 {
542 "relayers": [],
543 "signers": [],
544 "notifications": [],
545 "plugins": [],
546 "networks": [
547 {
548 "type": "solana",
549 "network": "custom-solana",
550 "rpc_urls": ["https://rpc.solana.example.com"]
551 }
552 ]
553 }
554 "#;
555 let result: Result<Config, _> = serde_json::from_str(config_str);
556 assert!(result.is_ok());
557 let config = result.unwrap();
558 assert_eq!(config.networks.len(), 1);
559
560 let network_config = config.networks.first().expect("Should have one network");
561 assert!(matches!(network_config, NetworkFileConfig::Solana(_)));
562 if let NetworkFileConfig::Solana(sol_config) = network_config {
563 assert_eq!(sol_config.common.network, "custom-solana");
564 }
565 }
566
567 #[test]
568 fn test_deserialize_config_with_stellar_network() {
569 let config_str = r#"
570 {
571 "relayers": [],
572 "signers": [],
573 "notifications": [],
574 "plugins": [],
575 "networks": [
576 {
577 "type": "stellar",
578 "network": "custom-stellar",
579 "rpc_urls": ["https://rpc.stellar.example.com"]
580 }
581 ]
582 }
583 "#;
584 let result: Result<Config, _> = serde_json::from_str(config_str);
585 assert!(result.is_ok());
586 let config = result.unwrap();
587 assert_eq!(config.networks.len(), 1);
588
589 let network_config = config.networks.first().expect("Should have one network");
590 assert!(matches!(network_config, NetworkFileConfig::Stellar(_)));
591 if let NetworkFileConfig::Stellar(stl_config) = network_config {
592 assert_eq!(stl_config.common.network, "custom-stellar");
593 }
594 }
595
596 #[test]
597 fn test_deserialize_config_with_mixed_networks() {
598 let config_str = r#"
599 {
600 "relayers": [],
601 "signers": [],
602 "notifications": [],
603 "plugins": [],
604 "networks": [
605 {
606 "type": "evm",
607 "network": "custom-evm",
608 "chain_id": 1234,
609 "required_confirmations": 1,
610 "symbol": "ETH",
611 "rpc_urls": ["https://rpc.example.com"]
612 },
613 {
614 "type": "solana",
615 "network": "custom-solana",
616 "rpc_urls": ["https://rpc.solana.example.com"]
617 }
618 ]
619 }
620 "#;
621 let result: Result<Config, _> = serde_json::from_str(config_str);
622 assert!(result.is_ok());
623 let config = result.unwrap();
624 assert_eq!(config.networks.len(), 2);
625 }
626
627 #[test]
628 #[should_panic(
629 expected = "NetworksFileConfig cannot be empty - networks must contain at least one network configuration"
630 )]
631 fn test_deserialize_config_with_empty_networks_array() {
632 let config_str = r#"
633 {
634 "relayers": [],
635 "signers": [],
636 "notifications": [],
637 "networks": []
638 }
639 "#;
640 let _result: Config = serde_json::from_str(config_str).unwrap();
641 }
642
643 #[test]
644 fn test_deserialize_config_without_networks_field() {
645 let config_str = r#"
646 {
647 "relayers": [],
648 "signers": [],
649 "notifications": []
650 }
651 "#;
652 let result: Result<Config, _> = serde_json::from_str(config_str);
653 assert!(result.is_ok());
654 }
655
656 use std::fs::File;
657 use std::io::Write;
658 use tempfile::tempdir;
659
660 fn setup_network_file(dir_path: &Path, file_name: &str, content: &str) {
661 let file_path = dir_path.join(file_name);
662 let mut file = File::create(&file_path).expect("Failed to create temp network file");
663 writeln!(file, "{}", content).expect("Failed to write to temp network file");
664 }
665
666 #[test]
667 fn test_deserialize_config_with_networks_from_directory() {
668 let dir = tempdir().expect("Failed to create temp dir");
669 let network_dir_path = dir.path();
670
671 setup_network_file(
672 network_dir_path,
673 "evm_net.json",
674 r#"{"networks": [{"type": "evm", "network": "custom-evm-file", "required_confirmations": 1, "symbol": "ETH", "chain_id": 5678, "rpc_urls": ["https://rpc.file-evm.com"]}]}"#,
675 );
676 setup_network_file(
677 network_dir_path,
678 "sol_net.json",
679 r#"{"networks": [{"type": "solana", "network": "custom-solana-file", "rpc_urls": ["https://rpc.file-solana.com"]}]}"#,
680 );
681
682 let config_json = serde_json::json!({
683 "relayers": [],
684 "signers": [],
685 "notifications": [],
686 "plugins": [],
687 "networks": network_dir_path.to_str().expect("Path should be valid UTF-8")
688 });
689 let config_str =
690 serde_json::to_string(&config_json).expect("Failed to serialize test config to string");
691
692 let result: Result<Config, _> = serde_json::from_str(&config_str);
693 assert!(result.is_ok(), "Deserialization failed: {:?}", result.err());
694
695 if let Ok(config) = result {
696 assert_eq!(
697 config.networks.len(),
698 2,
699 "Incorrect number of networks loaded"
700 );
701 let has_evm = config.networks.iter().any(|n| matches!(n, NetworkFileConfig::Evm(evm) if evm.common.network == "custom-evm-file"));
702 let has_solana = config.networks.iter().any(|n| matches!(n, NetworkFileConfig::Solana(sol) if sol.common.network == "custom-solana-file"));
703 assert!(has_evm, "EVM network from file not found or incorrect");
704 assert!(
705 has_solana,
706 "Solana network from file not found or incorrect"
707 );
708 }
709 }
710
711 #[test]
712 fn test_deserialize_config_with_empty_networks_directory() {
713 let dir = tempdir().expect("Failed to create temp dir");
714 let network_dir_path = dir.path();
715
716 let config_json = serde_json::json!({
717 "relayers": [],
718 "signers": [],
719 "notifications": [],
720 "networks": network_dir_path.to_str().expect("Path should be valid UTF-8")
721 });
722 let config_str =
723 serde_json::to_string(&config_json).expect("Failed to serialize test config to string");
724
725 let result: Result<Config, _> = serde_json::from_str(&config_str);
726 assert!(
727 result.is_err(),
728 "Deserialization should fail for empty directory"
729 );
730 }
731
732 #[test]
733 fn test_deserialize_config_with_non_existent_networks_directory() {
734 let dir = tempdir().expect("Failed to create temp dir");
735 let non_existent_path = dir.path().join("non_existent_sub_dir");
736
737 let config_json = serde_json::json!({
738 "relayers": [],
739 "signers": [],
740 "notifications": [],
741 "networks": non_existent_path.to_str().expect("Path should be valid UTF-8")
742 });
743 let config_str =
744 serde_json::to_string(&config_json).expect("Failed to serialize test config to string");
745
746 let result: Result<Config, _> = serde_json::from_str(&config_str);
747 assert!(
748 result.is_err(),
749 "Deserialization should fail for non-existent directory"
750 );
751 }
752
753 #[test]
754 fn test_deserialize_config_with_networks_path_as_file() {
755 let dir = tempdir().expect("Failed to create temp dir");
756 let network_file_path = dir.path().join("im_a_file.json");
757 File::create(&network_file_path).expect("Failed to create temp file");
758
759 let config_json = serde_json::json!({
760 "relayers": [],
761 "signers": [],
762 "notifications": [],
763 "networks": network_file_path.to_str().expect("Path should be valid UTF-8")
764 });
765 let config_str =
766 serde_json::to_string(&config_json).expect("Failed to serialize test config to string");
767
768 let result: Result<Config, _> = serde_json::from_str(&config_str);
769 assert!(
770 result.is_err(),
771 "Deserialization should fail if path is a file, not a directory"
772 );
773 }
774
775 #[test]
776 fn test_deserialize_config_network_dir_with_invalid_json_file() {
777 let dir = tempdir().expect("Failed to create temp dir");
778 let network_dir_path = dir.path();
779 setup_network_file(
780 network_dir_path,
781 "invalid.json",
782 r#"{"networks": [{"type": "evm", "network": "broken""#,
783 ); let config_json = serde_json::json!({
786 "relayers": [], "signers": [], "notifications": [],
787 "networks": network_dir_path.to_str().expect("Path should be valid UTF-8")
788 });
789 let config_str = serde_json::to_string(&config_json).expect("Failed to serialize");
790
791 let result: Result<Config, _> = serde_json::from_str(&config_str);
792 assert!(
793 result.is_err(),
794 "Deserialization should fail with invalid JSON in network file"
795 );
796 }
797
798 #[test]
799 fn test_deserialize_config_network_dir_with_non_network_config_json_file() {
800 let dir = tempdir().expect("Failed to create temp dir");
801 let network_dir_path = dir.path();
802 setup_network_file(network_dir_path, "not_a_network.json", r#"{"foo": "bar"}"#); let config_json = serde_json::json!({
805 "relayers": [], "signers": [], "notifications": [],
806 "networks": network_dir_path.to_str().expect("Path should be valid UTF-8")
807 });
808 let config_str = serde_json::to_string(&config_json).expect("Failed to serialize");
809
810 let result: Result<Config, _> = serde_json::from_str(&config_str);
811 assert!(
812 result.is_err(),
813 "Deserialization should fail if file is not a valid NetworkFileConfig"
814 );
815 }
816
817 #[test]
818 fn test_deserialize_config_still_works_with_array_of_networks() {
819 let config_str = r#"
820 {
821 "relayers": [],
822 "signers": [],
823 "notifications": [],
824 "plugins": [],
825 "networks": [
826 {
827 "type": "evm",
828 "network": "custom-evm-array",
829 "chain_id": 1234,
830 "required_confirmations": 1,
831 "symbol": "ETH",
832 "rpc_urls": ["https://rpc.example.com"]
833 }
834 ]
835 }
836 "#;
837 let result: Result<Config, _> = serde_json::from_str(config_str);
838 assert!(
839 result.is_ok(),
840 "Deserialization with array failed: {:?}",
841 result.err()
842 );
843 if let Ok(config) = result {
844 assert_eq!(config.networks.len(), 1);
845
846 let network_config = config.networks.first().expect("Should have one network");
847 assert!(matches!(network_config, NetworkFileConfig::Evm(_)));
848 if let NetworkFileConfig::Evm(evm_config) = network_config {
849 assert_eq!(evm_config.common.network, "custom-evm-array");
850 }
851 }
852 }
853
854 #[test]
855 fn test_create_valid_networks_file_config_works() {
856 let networks = vec![NetworkFileConfig::Evm(EvmNetworkConfig {
857 common: NetworkConfigCommon {
858 network: "test-network".to_string(),
859 from: None,
860 rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]),
861 explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
862 average_blocktime_ms: Some(12000),
863 is_testnet: Some(true),
864 tags: Some(vec!["test".to_string()]),
865 },
866 chain_id: Some(31337),
867 required_confirmations: Some(1),
868 features: None,
869 symbol: Some("ETH".to_string()),
870 gas_price_cache: None,
871 })];
872
873 let config = NetworksFileConfig::new(networks).unwrap();
874 assert_eq!(config.len(), 1);
875 assert_eq!(config.first().unwrap().network_name(), "test-network");
876 }
877
878 fn setup_config_file(dir_path: &Path, file_name: &str, content: &str) {
879 let file_path = dir_path.join(file_name);
880 let mut file = File::create(&file_path).expect("Failed to create temp config file");
881 write!(file, "{}", content).expect("Failed to write to temp config file");
882 }
883
884 #[test]
885 fn test_load_config_success() {
886 let dir = tempdir().expect("Failed to create temp dir");
887 let config_path = dir.path().join("valid_config.json");
888
889 let config_content = serde_json::json!({
890 "relayers": [{
891 "id": "test-relayer",
892 "name": "Test Relayer",
893 "network": "test-network",
894 "paused": false,
895 "network_type": "evm",
896 "signer_id": "test-signer"
897 }],
898 "signers": [{
899 "id": "test-signer",
900 "type": "local",
901 "config": {
902 "path": "tests/utils/test_keys/unit-test-local-signer.json",
903 "passphrase": {
904 "value": "test",
905 "type": "plain"
906 }
907 }
908 }],
909 "notifications": [{
910 "id": "test-notification",
911 "type": "webhook",
912 "url": "https://api.example.com/notifications"
913 }],
914 "networks": [{
915 "type": "evm",
916 "network": "test-network",
917 "chain_id": 31337,
918 "required_confirmations": 1,
919 "symbol": "ETH",
920 "rpc_urls": ["https://rpc.test.example.com"],
921 "is_testnet": true
922 }],
923 "plugins": [{
924 "id": "plugin-id",
925 "path": "/app/plugins/plugin.ts",
926 "timeout": 12
927 }],
928 });
929
930 setup_config_file(dir.path(), "valid_config.json", &config_content.to_string());
931
932 let result = load_config(config_path.to_str().unwrap());
933 assert!(result.is_ok());
934
935 let config = result.unwrap();
936 assert_eq!(config.relayers.len(), 1);
937 assert_eq!(config.signers.len(), 1);
938 assert_eq!(config.networks.len(), 1);
939 assert_eq!(config.plugins.unwrap().len(), 1);
940 }
941
942 #[test]
943 fn test_load_config_file_not_found() {
944 let result = load_config("non_existent_file.json");
945 assert!(result.is_err());
946 assert!(matches!(result.unwrap_err(), ConfigFileError::IoError(_)));
947 }
948
949 #[test]
950 fn test_load_config_invalid_json() {
951 let dir = tempdir().expect("Failed to create temp dir");
952 let config_path = dir.path().join("invalid.json");
953
954 setup_config_file(dir.path(), "invalid.json", "{ invalid json }");
955
956 let result = load_config(config_path.to_str().unwrap());
957 assert!(result.is_err());
958 assert!(matches!(result.unwrap_err(), ConfigFileError::JsonError(_)));
959 }
960
961 #[test]
962 fn test_load_config_invalid_config_structure() {
963 let dir = tempdir().expect("Failed to create temp dir");
964 let config_path = dir.path().join("invalid_structure.json");
965
966 let invalid_config = serde_json::json!({
967 "relayers": "not_an_array",
968 "signers": [],
969 "notifications": [],
970 "networks": [{
971 "type": "evm",
972 "network": "test-network",
973 "chain_id": 31337,
974 "required_confirmations": 1,
975 "symbol": "ETH",
976 "rpc_urls": ["https://rpc.test.example.com"]
977 }]
978 });
979
980 setup_config_file(
981 dir.path(),
982 "invalid_structure.json",
983 &invalid_config.to_string(),
984 );
985
986 let result = load_config(config_path.to_str().unwrap());
987 assert!(result.is_err());
988 assert!(matches!(result.unwrap_err(), ConfigFileError::JsonError(_)));
989 }
990
991 #[test]
992 fn test_load_config_with_unicode_content() {
993 let dir = tempdir().expect("Failed to create temp dir");
994 let config_path = dir.path().join("unicode_config.json");
995
996 let config_content = serde_json::json!({
998 "relayers": [{
999 "id": "test-relayer-unicode",
1000 "name": "Test Relayer 测试",
1001 "network": "test-network-unicode",
1002 "paused": false,
1003 "network_type": "evm",
1004 "signer_id": "test-signer-unicode"
1005 }],
1006 "signers": [{
1007 "id": "test-signer-unicode",
1008 "type": "local",
1009 "config": {
1010 "path": "tests/utils/test_keys/unit-test-local-signer.json",
1011 "passphrase": {
1012 "value": "test",
1013 "type": "plain"
1014 }
1015 }
1016 }],
1017 "notifications": [{
1018 "id": "test-notification-unicode",
1019 "type": "webhook",
1020 "url": "https://api.example.com/notifications"
1021 }],
1022 "networks": [{
1023 "type": "evm",
1024 "network": "test-network-unicode",
1025 "chain_id": 31337,
1026 "required_confirmations": 1,
1027 "symbol": "ETH",
1028 "rpc_urls": ["https://rpc.test.example.com"],
1029 "is_testnet": true
1030 }],
1031 "plugins": []
1032 });
1033
1034 setup_config_file(
1035 dir.path(),
1036 "unicode_config.json",
1037 &config_content.to_string(),
1038 );
1039
1040 let result = load_config(config_path.to_str().unwrap());
1041 assert!(result.is_ok());
1042
1043 let config = result.unwrap();
1044 assert_eq!(config.relayers[0].id, "test-relayer-unicode");
1045 assert_eq!(config.signers[0].id, "test-signer-unicode");
1046 }
1047
1048 #[test]
1049 fn test_load_config_with_empty_file() {
1050 let dir = tempdir().expect("Failed to create temp dir");
1051 let config_path = dir.path().join("empty.json");
1052
1053 setup_config_file(dir.path(), "empty.json", "");
1054
1055 let result = load_config(config_path.to_str().unwrap());
1056 assert!(result.is_err());
1057 assert!(matches!(result.unwrap_err(), ConfigFileError::JsonError(_)));
1058 }
1059
1060 #[test]
1061 fn test_config_serialization_works() {
1062 let config = create_valid_config();
1063
1064 let serialized = serde_json::to_string(&config);
1065 assert!(serialized.is_ok());
1066
1067 let serialized_str = serialized.unwrap();
1069 assert!(!serialized_str.is_empty());
1070 assert!(serialized_str.contains("relayers"));
1071 assert!(serialized_str.contains("signers"));
1072 assert!(serialized_str.contains("networks"));
1073 }
1074
1075 #[test]
1076 fn test_config_serialization_contains_expected_fields() {
1077 let config = create_valid_config();
1078
1079 let serialized = serde_json::to_string(&config);
1080 assert!(serialized.is_ok());
1081
1082 let serialized_str = serialized.unwrap();
1083
1084 assert!(serialized_str.contains("\"id\":\"test-1\""));
1086 assert!(serialized_str.contains("\"name\":\"Test Relayer\""));
1087 assert!(serialized_str.contains("\"network\":\"test-network\""));
1088 assert!(serialized_str.contains("\"type\":\"evm\""));
1089 }
1090
1091 #[test]
1092 fn test_validate_relayers_method() {
1093 let config = create_valid_config();
1094 let result = config.validate_relayers(&config.networks);
1095 assert!(result.is_ok());
1096 }
1097
1098 #[test]
1099 fn test_validate_signers_method() {
1100 let config = create_valid_config();
1101 let result = config.validate_signers();
1102 assert!(result.is_ok());
1103 }
1104
1105 #[test]
1106 fn test_validate_notifications_method() {
1107 let config = create_valid_config();
1108 let result = config.validate_notifications();
1109 assert!(result.is_ok());
1110 }
1111
1112 #[test]
1113 fn test_validate_networks_method() {
1114 let config = create_valid_config();
1115 let result = config.validate_networks();
1116 assert!(result.is_ok());
1117 }
1118
1119 #[test]
1120 fn test_validate_plugins_method() {
1121 let config = create_valid_config();
1122 let result = config.validate_plugins();
1123 assert!(result.is_ok());
1124 }
1125
1126 #[test]
1127 fn test_validate_plugins_method_with_empty_plugins() {
1128 let config = Config {
1129 relayers: vec![],
1130 signers: vec![],
1131 notifications: vec![],
1132 networks: NetworksFileConfig::new(vec![]).unwrap(),
1133 plugins: Some(vec![]),
1134 };
1135 let result = config.validate_plugins();
1136 assert!(result.is_ok());
1137 }
1138
1139 #[test]
1140 fn test_validate_plugins_method_with_invalid_plugin_extension() {
1141 let config = Config {
1142 relayers: vec![],
1143 signers: vec![],
1144 notifications: vec![],
1145 networks: NetworksFileConfig::new(vec![]).unwrap(),
1146 plugins: Some(vec![PluginFileConfig {
1147 id: "id".to_string(),
1148 path: "/app/plugins/test-plugin.js".to_string(),
1149 timeout: None,
1150 emit_logs: false,
1151 emit_traces: false,
1152 }]),
1153 };
1154 let result = config.validate_plugins();
1155 assert!(result.is_err());
1156 }
1157
1158 #[test]
1159 fn test_config_with_maximum_length_ids() {
1160 let mut config = create_valid_config();
1161 let max_length_id = "a".repeat(36); config.relayers[0].id = max_length_id.clone();
1163 config.relayers[0].signer_id = config.signers[0].id.clone();
1164
1165 let result = config.validate();
1166 assert!(result.is_ok());
1167 }
1168
1169 #[test]
1170 fn test_config_with_special_characters_in_names() {
1171 let mut config = create_valid_config();
1172 config.relayers[0].name = "Test-Relayer_123!@#$%^&*()".to_string();
1173
1174 let result = config.validate();
1175 assert!(result.is_ok());
1176 }
1177
1178 #[test]
1179 fn test_config_with_very_long_urls() {
1180 let mut config = create_valid_config();
1181 let long_url = format!(
1182 "https://very-long-domain-name-{}.example.com/api/v1/endpoint",
1183 "x".repeat(100)
1184 );
1185 config.notifications[0].url = long_url;
1186
1187 let result = config.validate();
1188 assert!(result.is_ok());
1189 }
1190
1191 #[test]
1192 fn test_config_with_only_signers_validation() {
1193 let config = Config {
1194 relayers: vec![],
1195 signers: vec![SignerFileConfig {
1196 id: "test-signer".to_string(),
1197 config: SignerFileConfigEnum::Local(LocalSignerFileConfig {
1198 path: "test-path".to_string(),
1199 passphrase: PlainOrEnvValue::Plain {
1200 value: SecretString::new("test-passphrase"),
1201 },
1202 }),
1203 }],
1204 notifications: vec![],
1205 networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
1206 common: NetworkConfigCommon {
1207 network: "test-network".to_string(),
1208 from: None,
1209 rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]),
1210 explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
1211 average_blocktime_ms: Some(12000),
1212 is_testnet: Some(true),
1213 tags: Some(vec!["test".to_string()]),
1214 },
1215 chain_id: Some(31337),
1216 required_confirmations: Some(1),
1217 features: None,
1218 symbol: Some("ETH".to_string()),
1219 gas_price_cache: None,
1220 })])
1221 .unwrap(),
1222 plugins: Some(vec![]),
1223 };
1224
1225 let result = config.validate();
1226 assert!(result.is_ok());
1227 }
1228
1229 #[test]
1230 fn test_config_with_only_notifications() {
1231 let config = Config {
1232 relayers: vec![],
1233 signers: vec![],
1234 notifications: vec![NotificationConfig {
1235 id: "test-notification".to_string(),
1236 r#type: NotificationType::Webhook,
1237 url: "https://api.example.com/notifications".to_string(),
1238 signing_key: None,
1239 }],
1240 networks: NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
1241 common: NetworkConfigCommon {
1242 network: "test-network".to_string(),
1243 from: None,
1244 rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]),
1245 explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
1246 average_blocktime_ms: Some(12000),
1247 is_testnet: Some(true),
1248 tags: Some(vec!["test".to_string()]),
1249 },
1250 chain_id: Some(31337),
1251 required_confirmations: Some(1),
1252 features: None,
1253 symbol: Some("ETH".to_string()),
1254 gas_price_cache: None,
1255 })])
1256 .unwrap(),
1257 plugins: Some(vec![]),
1258 };
1259
1260 let result = config.validate();
1261 assert!(result.is_ok());
1262 }
1263
1264 #[test]
1265 fn test_config_with_mixed_network_types_in_relayers() {
1266 let mut config = create_valid_config();
1267
1268 config.relayers.push(RelayerFileConfig {
1270 id: "solana-relayer".to_string(),
1271 name: "Solana Test Relayer".to_string(),
1272 network: "devnet".to_string(),
1273 paused: false,
1274 network_type: ConfigFileNetworkType::Solana,
1275 policies: None,
1276 signer_id: "test-1".to_string(),
1277 notification_id: None,
1278 custom_rpc_urls: None,
1279 });
1280
1281 config.relayers.push(RelayerFileConfig {
1283 id: "stellar-relayer".to_string(),
1284 name: "Stellar Test Relayer".to_string(),
1285 network: "testnet".to_string(),
1286 paused: true,
1287 network_type: ConfigFileNetworkType::Stellar,
1288 policies: None,
1289 signer_id: "test-1".to_string(),
1290 notification_id: Some("test-1".to_string()),
1291 custom_rpc_urls: None,
1292 });
1293
1294 let devnet_network = NetworkFileConfig::Solana(SolanaNetworkConfig {
1295 common: NetworkConfigCommon {
1296 network: "devnet".to_string(),
1297 from: None,
1298 rpc_urls: Some(vec!["https://api.devnet.solana.com".to_string()]),
1299 explorer_urls: Some(vec!["https://explorer.solana.com".to_string()]),
1300 average_blocktime_ms: Some(400),
1301 is_testnet: Some(true),
1302 tags: Some(vec!["test".to_string()]),
1303 },
1304 });
1305
1306 let testnet_network = NetworkFileConfig::Stellar(StellarNetworkConfig {
1307 common: NetworkConfigCommon {
1308 network: "testnet".to_string(),
1309 from: None,
1310 rpc_urls: Some(vec!["https://soroban-testnet.stellar.org".to_string()]),
1311 explorer_urls: Some(vec!["https://stellar.expert/explorer/testnet".to_string()]),
1312 average_blocktime_ms: Some(5000),
1313 is_testnet: Some(true),
1314 tags: Some(vec!["test".to_string()]),
1315 },
1316 passphrase: Some("Test SDF Network ; September 2015".to_string()),
1317 });
1318
1319 let mut networks = config.networks.networks;
1320 networks.push(devnet_network);
1321 networks.push(testnet_network);
1322 config.networks =
1323 NetworksFileConfig::new(networks).expect("Failed to create NetworksFileConfig");
1324
1325 let result = config.validate();
1326 assert!(result.is_ok());
1327 }
1328
1329 #[test]
1330 fn test_config_with_all_network_types() {
1331 let mut config = create_valid_config();
1332
1333 let solana_network = NetworkFileConfig::Solana(SolanaNetworkConfig {
1335 common: NetworkConfigCommon {
1336 network: "solana-test".to_string(),
1337 from: None,
1338 rpc_urls: Some(vec!["https://api.devnet.solana.com".to_string()]),
1339 explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
1340 average_blocktime_ms: Some(400),
1341 is_testnet: Some(true),
1342 tags: Some(vec!["solana".to_string()]),
1343 },
1344 });
1345
1346 let stellar_network = NetworkFileConfig::Stellar(StellarNetworkConfig {
1348 common: NetworkConfigCommon {
1349 network: "stellar-test".to_string(),
1350 from: None,
1351 rpc_urls: Some(vec!["https://horizon-testnet.stellar.org".to_string()]),
1352 explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
1353 average_blocktime_ms: Some(5000),
1354 is_testnet: Some(true),
1355 tags: Some(vec!["stellar".to_string()]),
1356 },
1357 passphrase: Some("Test Network ; September 2015".to_string()),
1358 });
1359
1360 let mut existing_networks = Vec::new();
1362 for network in config.networks.iter() {
1363 existing_networks.push(network.clone());
1364 }
1365 existing_networks.push(solana_network);
1366 existing_networks.push(stellar_network);
1367
1368 config.networks = NetworksFileConfig::new(existing_networks).unwrap();
1369
1370 let result = config.validate();
1371 assert!(result.is_ok());
1372 }
1373
1374 #[test]
1375 fn test_config_error_propagation_from_relayers() {
1376 let mut config = create_valid_config();
1377 config.relayers[0].id = "".to_string(); let result = config.validate();
1380 assert!(result.is_err());
1381 assert!(matches!(
1382 result.unwrap_err(),
1383 ConfigFileError::MissingField(_)
1384 ));
1385 }
1386
1387 #[test]
1388 fn test_config_error_propagation_from_signers() {
1389 let mut config = create_valid_config();
1390 config.signers[0].id = "".to_string(); let result = config.validate();
1393 assert!(result.is_err());
1394 assert!(matches!(
1396 result.unwrap_err(),
1397 ConfigFileError::InvalidIdLength(_)
1398 ));
1399 }
1400
1401 #[test]
1402 fn test_config_error_propagation_from_notifications() {
1403 let mut config = create_valid_config();
1404 config.notifications[0].id = "".to_string(); let result = config.validate();
1407 assert!(result.is_err());
1408
1409 let error = result.unwrap_err();
1410 assert!(matches!(error, ConfigFileError::InvalidFormat(_)));
1411 }
1412
1413 #[test]
1414 fn test_config_with_paused_relayers() {
1415 let mut config = create_valid_config();
1416 config.relayers[0].paused = true;
1417
1418 let result = config.validate();
1419 assert!(result.is_ok()); }
1421
1422 #[test]
1423 fn test_config_with_none_notification_id() {
1424 let mut config = create_valid_config();
1425 config.relayers[0].notification_id = None;
1426
1427 let result = config.validate();
1428 assert!(result.is_ok()); }
1430
1431 #[test]
1432 fn test_config_file_network_type_display() {
1433 let evm = ConfigFileNetworkType::Evm;
1434 let solana = ConfigFileNetworkType::Solana;
1435 let stellar = ConfigFileNetworkType::Stellar;
1436
1437 let evm_str = format!("{:?}", evm);
1439 let solana_str = format!("{:?}", solana);
1440 let stellar_str = format!("{:?}", stellar);
1441
1442 assert!(evm_str.contains("Evm"));
1443 assert!(solana_str.contains("Solana"));
1444 assert!(stellar_str.contains("Stellar"));
1445 }
1446
1447 #[test]
1448 fn test_config_file_plugins_validation_with_empty_plugins() {
1449 let config = Config {
1450 relayers: vec![],
1451 signers: vec![],
1452 notifications: vec![],
1453 networks: NetworksFileConfig::new(vec![]).unwrap(),
1454 plugins: None,
1455 };
1456 let result = config.validate_plugins();
1457 assert!(result.is_ok());
1458 }
1459
1460 #[test]
1461 fn test_config_file_without_plugins() {
1462 let dir = tempdir().expect("Failed to create temp dir");
1463 let config_path = dir.path().join("valid_config.json");
1464
1465 let config_content = serde_json::json!({
1466 "relayers": [{
1467 "id": "test-relayer",
1468 "name": "Test Relayer",
1469 "network": "test-network",
1470 "paused": false,
1471 "network_type": "evm",
1472 "signer_id": "test-signer"
1473 }],
1474 "signers": [{
1475 "id": "test-signer",
1476 "type": "local",
1477 "config": {
1478 "path": "tests/utils/test_keys/unit-test-local-signer.json",
1479 "passphrase": {
1480 "value": "test",
1481 "type": "plain"
1482 }
1483 }
1484 }],
1485 "notifications": [{
1486 "id": "test-notification",
1487 "type": "webhook",
1488 "url": "https://api.example.com/notifications"
1489 }],
1490 "networks": [{
1491 "type": "evm",
1492 "network": "test-network",
1493 "chain_id": 31337,
1494 "required_confirmations": 1,
1495 "symbol": "ETH",
1496 "rpc_urls": ["https://rpc.test.example.com"],
1497 "is_testnet": true
1498 }]
1499 });
1500
1501 setup_config_file(dir.path(), "valid_config.json", &config_content.to_string());
1502
1503 let result = load_config(config_path.to_str().unwrap());
1504 assert!(result.is_ok());
1505
1506 let config = result.unwrap();
1507 assert_eq!(config.relayers.len(), 1);
1508 assert_eq!(config.signers.len(), 1);
1509 assert_eq!(config.networks.len(), 1);
1510 assert!(config.plugins.is_none());
1511 }
1512}