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