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