1use super::{ConfigFileError, ConfigFileNetworkType, NetworksFileConfig};
9use crate::models::RpcConfig;
10use apalis_cron::Schedule;
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13use std::collections::HashSet;
14use std::str::FromStr;
15
16#[derive(Debug, Serialize, Deserialize, Clone)]
17#[serde(rename_all = "lowercase")]
18pub enum ConfigFileRelayerNetworkPolicy {
19 Evm(ConfigFileRelayerEvmPolicy),
20 Solana(ConfigFileRelayerSolanaPolicy),
21 Stellar(ConfigFileRelayerStellarPolicy),
22}
23
24#[derive(Debug, Serialize, Deserialize, Clone)]
25#[serde(deny_unknown_fields)]
26pub struct ConfigFileRelayerEvmPolicy {
27 pub gas_price_cap: Option<u128>,
28 pub whitelist_receivers: Option<Vec<String>>,
29 pub eip1559_pricing: Option<bool>,
30 pub private_transactions: Option<bool>,
31 pub min_balance: Option<u128>,
32}
33
34#[derive(Debug, Serialize, Deserialize, Clone)]
35pub struct AllowedTokenSwapConfig {
36 pub slippage_percentage: Option<f32>,
38 pub min_amount: Option<u64>,
40 pub max_amount: Option<u64>,
42 pub retain_min_amount: Option<u64>,
44}
45
46#[derive(Debug, Serialize, Deserialize, Clone)]
47pub struct AllowedToken {
48 pub mint: String,
49 pub max_allowed_fee: Option<u64>,
51 pub swap_config: Option<AllowedTokenSwapConfig>,
53}
54
55#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
56#[serde(rename_all = "lowercase")]
57pub enum ConfigFileRelayerSolanaFeePaymentStrategy {
58 User,
59 Relayer,
60}
61
62#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
63#[serde(rename_all = "kebab-case")]
64pub enum ConfigFileRelayerSolanaSwapStrategy {
65 JupiterSwap,
66 JupiterUltra,
67}
68
69#[derive(Debug, Serialize, Deserialize, Clone)]
70pub struct JupiterSwapOptions {
71 pub priority_fee_max_lamports: Option<u64>,
73 pub priority_level: Option<String>,
75
76 pub dynamic_compute_unit_limit: Option<bool>,
77}
78
79#[derive(Debug, Serialize, Deserialize, Clone)]
80#[serde(deny_unknown_fields)]
81pub struct ConfigFileRelayerSolanaSwapPolicy {
82 pub strategy: Option<ConfigFileRelayerSolanaSwapStrategy>,
84
85 pub cron_schedule: Option<String>,
87
88 pub min_balance_threshold: Option<u64>,
90
91 pub jupiter_swap_options: Option<JupiterSwapOptions>,
93}
94
95#[derive(Debug, Serialize, Deserialize, Clone)]
96#[serde(deny_unknown_fields)]
97pub struct ConfigFileRelayerSolanaPolicy {
98 pub fee_payment_strategy: Option<ConfigFileRelayerSolanaFeePaymentStrategy>,
100
101 pub fee_margin_percentage: Option<f32>,
103
104 pub min_balance: Option<u64>,
106
107 pub allowed_tokens: Option<Vec<AllowedToken>>,
109
110 pub allowed_programs: Option<Vec<String>>,
113
114 pub allowed_accounts: Option<Vec<String>>,
117
118 pub disallowed_accounts: Option<Vec<String>>,
121
122 pub max_tx_data_size: Option<u16>,
124
125 pub max_signatures: Option<u8>,
127
128 pub max_allowed_fee_lamports: Option<u64>,
130
131 pub swap_config: Option<ConfigFileRelayerSolanaSwapPolicy>,
133}
134
135#[derive(Debug, Serialize, Deserialize, Clone)]
136#[serde(deny_unknown_fields)]
137pub struct ConfigFileRelayerStellarPolicy {
138 pub max_fee: Option<u32>,
139 pub timeout_seconds: Option<u64>,
140 pub min_balance: Option<u64>,
141}
142
143#[derive(Debug, Serialize, Clone)]
144pub struct RelayerFileConfig {
145 pub id: String,
146 pub name: String,
147 pub network: String,
148 pub paused: bool,
149 #[serde(flatten)]
150 pub network_type: ConfigFileNetworkType,
151 #[serde(default)]
152 pub policies: Option<ConfigFileRelayerNetworkPolicy>,
153 pub signer_id: String,
154 #[serde(default)]
155 pub notification_id: Option<String>,
156 #[serde(default)]
157 pub custom_rpc_urls: Option<Vec<RpcConfig>>,
158}
159use serde::{de, Deserializer};
160use serde_json::Value;
161
162impl<'de> Deserialize<'de> for RelayerFileConfig {
163 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
164 where
165 D: Deserializer<'de>,
166 {
167 let mut value: Value = Value::deserialize(deserializer)?;
169
170 let id = value
172 .get("id")
173 .and_then(Value::as_str)
174 .ok_or_else(|| de::Error::missing_field("id"))?
175 .to_string();
176
177 let name = value
178 .get("name")
179 .and_then(Value::as_str)
180 .ok_or_else(|| de::Error::missing_field("name"))?
181 .to_string();
182
183 let network = value
184 .get("network")
185 .and_then(Value::as_str)
186 .ok_or_else(|| de::Error::missing_field("network"))?
187 .to_string();
188
189 let paused = value
190 .get("paused")
191 .and_then(Value::as_bool)
192 .ok_or_else(|| de::Error::missing_field("paused"))?;
193
194 let network_type: ConfigFileNetworkType = serde_json::from_value(
196 value
197 .get("network_type")
198 .cloned()
199 .ok_or_else(|| de::Error::missing_field("network_type"))?,
200 )
201 .map_err(de::Error::custom)?;
202
203 let signer_id = value
204 .get("signer_id")
205 .and_then(Value::as_str)
206 .ok_or_else(|| de::Error::missing_field("signer_id"))?
207 .to_string();
208
209 let notification_id = value
210 .get("notification_id")
211 .and_then(Value::as_str)
212 .map(|s| s.to_string());
213
214 let policies = if let Some(policy_value) = value.get_mut("policies") {
216 match network_type {
217 ConfigFileNetworkType::Evm => {
218 serde_json::from_value::<ConfigFileRelayerEvmPolicy>(policy_value.clone())
219 .map(ConfigFileRelayerNetworkPolicy::Evm)
220 .map(Some)
221 .map_err(de::Error::custom)
222 }
223 ConfigFileNetworkType::Solana => {
224 serde_json::from_value::<ConfigFileRelayerSolanaPolicy>(policy_value.clone())
225 .map(ConfigFileRelayerNetworkPolicy::Solana)
226 .map(Some)
227 .map_err(de::Error::custom)
228 }
229 ConfigFileNetworkType::Stellar => {
230 serde_json::from_value::<ConfigFileRelayerStellarPolicy>(policy_value.clone())
231 .map(ConfigFileRelayerNetworkPolicy::Stellar)
232 .map(Some)
233 .map_err(de::Error::custom)
234 }
235 }
236 } else {
237 Ok(None) }?;
239
240 let custom_rpc_urls = value
241 .get("custom_rpc_urls")
242 .and_then(|v| v.as_array())
243 .map(|arr| {
244 arr.iter()
245 .filter_map(|v| {
246 if let Some(url_str) = v.as_str() {
248 Some(RpcConfig::new(url_str.to_string()))
250 } else {
251 serde_json::from_value::<RpcConfig>(v.clone()).ok()
253 }
254 })
255 .collect()
256 });
257
258 Ok(RelayerFileConfig {
259 id,
260 name,
261 network,
262 paused,
263 network_type,
264 policies,
265 signer_id,
266 notification_id,
267 custom_rpc_urls,
268 })
269 }
270}
271
272impl RelayerFileConfig {
273 const MAX_ID_LENGTH: usize = 36;
274
275 fn validate_solana_pub_keys(&self, keys: &Option<Vec<String>>) -> Result<(), ConfigFileError> {
276 if let Some(keys) = keys {
277 let solana_pub_key_regex =
278 Regex::new(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$").map_err(|e| {
279 ConfigFileError::InternalError(format!("Regex compilation error: {}", e))
280 })?;
281 for key in keys {
282 if !solana_pub_key_regex.is_match(key) {
283 return Err(ConfigFileError::InvalidPolicy(
284 "Value must contain only letters, numbers, dashes and underscores".into(),
285 ));
286 }
287 }
288 }
289 Ok(())
290 }
291
292 fn validate_solana_fee_margin_percentage(
293 &self,
294 fee_margin_percentage: Option<f32>,
295 ) -> Result<(), ConfigFileError> {
296 if let Some(value) = fee_margin_percentage {
297 if value < 0f32 {
298 return Err(ConfigFileError::InvalidPolicy(
299 "Negative values are not accepted".into(),
300 ));
301 }
302 }
303 Ok(())
304 }
305
306 fn validate_solana_swap_config(
307 &self,
308 policy: &ConfigFileRelayerSolanaPolicy,
309 network: &str,
310 ) -> Result<(), ConfigFileError> {
311 let swap_config = match &policy.swap_config {
312 Some(config) => config,
313 None => return Ok(()),
314 };
315
316 if let Some(fee_payment_strategy) = &policy.fee_payment_strategy {
317 match fee_payment_strategy {
318 ConfigFileRelayerSolanaFeePaymentStrategy::User => {}
319 ConfigFileRelayerSolanaFeePaymentStrategy::Relayer => {
320 return Err(ConfigFileError::InvalidPolicy(
321 "Swap config only supported for user fee payment strategy".into(),
322 ));
323 }
324 }
325 }
326
327 if let Some(strategy) = &swap_config.strategy {
328 match strategy {
329 ConfigFileRelayerSolanaSwapStrategy::JupiterSwap => {
330 if network != "mainnet-beta" {
331 return Err(ConfigFileError::InvalidPolicy(
332 "JupiterSwap strategy is only supported on mainnet-beta".into(),
333 ));
334 }
335 }
336 ConfigFileRelayerSolanaSwapStrategy::JupiterUltra => {
337 if network != "mainnet-beta" {
338 return Err(ConfigFileError::InvalidPolicy(
339 "JupiterUltra strategy is only supported on mainnet-beta".into(),
340 ));
341 }
342 }
343 }
344 }
345
346 if let Some(cron_schedule) = &swap_config.cron_schedule {
347 if cron_schedule.is_empty() {
348 return Err(ConfigFileError::InvalidPolicy(
349 "Empty cron schedule is not accepted".into(),
350 ));
351 }
352 }
353
354 if let Some(schedule) = &swap_config.cron_schedule {
355 Schedule::from_str(schedule).map_err(|_| {
356 ConfigFileError::InvalidPolicy("Invalid cron schedule format".into())
357 })?;
358 }
359
360 if let Some(strategy) = &swap_config.jupiter_swap_options {
361 if swap_config.strategy != Some(ConfigFileRelayerSolanaSwapStrategy::JupiterSwap) {
363 return Err(ConfigFileError::InvalidPolicy(
364 "JupiterSwap options are only valid for JupiterSwap strategy".into(),
365 ));
366 }
367 if let Some(max_lamports) = strategy.priority_fee_max_lamports {
368 if max_lamports == 0 {
369 return Err(ConfigFileError::InvalidPolicy(
370 "Max lamports must be greater than 0".into(),
371 ));
372 }
373 }
374 if let Some(priority_level) = &strategy.priority_level {
375 if priority_level.is_empty() {
376 return Err(ConfigFileError::InvalidPolicy(
377 "Priority level cannot be empty".into(),
378 ));
379 }
380 let valid_levels = ["medium", "high", "veryHigh"];
381 if !valid_levels.contains(&priority_level.as_str()) {
382 return Err(ConfigFileError::InvalidPolicy(
383 "Priority level must be one of: medium, high, veryHigh".into(),
384 ));
385 }
386 }
387
388 if strategy.priority_level.is_some() && strategy.priority_fee_max_lamports.is_none() {
389 return Err(ConfigFileError::InvalidPolicy(
390 "Priority Fee Max lamports must be set if priority level is set".into(),
391 ));
392 }
393 if strategy.priority_fee_max_lamports.is_some() && strategy.priority_level.is_none() {
394 return Err(ConfigFileError::InvalidPolicy(
395 "Priority level must be set if priority fee max lamports is set".into(),
396 ));
397 }
398 }
399
400 Ok(())
401 }
402
403 fn validate_policies(&self) -> Result<(), ConfigFileError> {
404 match self.network_type {
405 ConfigFileNetworkType::Solana => {
406 if let Some(ConfigFileRelayerNetworkPolicy::Solana(policy)) = &self.policies {
407 self.validate_solana_pub_keys(&policy.allowed_accounts)?;
408 self.validate_solana_pub_keys(&policy.disallowed_accounts)?;
409 let allowed_token_keys = policy.allowed_tokens.as_ref().map(|tokens| {
410 tokens
411 .iter()
412 .map(|token| token.mint.clone())
413 .collect::<Vec<String>>()
414 });
415 self.validate_solana_pub_keys(&allowed_token_keys)?;
416 self.validate_solana_pub_keys(&policy.allowed_programs)?;
417 self.validate_solana_fee_margin_percentage(policy.fee_margin_percentage)?;
418 self.validate_solana_swap_config(policy, &self.network)?;
419 if policy.allowed_accounts.is_some() && policy.disallowed_accounts.is_some() {
421 return Err(ConfigFileError::InvalidPolicy(
422 "allowed_accounts and disallowed_accounts cannot be both present"
423 .into(),
424 ));
425 }
426 }
427 }
428 ConfigFileNetworkType::Evm => {}
429 ConfigFileNetworkType::Stellar => {}
430 }
431 Ok(())
432 }
433
434 fn validate_custom_rpc_urls(&self) -> Result<(), ConfigFileError> {
435 if let Some(configs) = &self.custom_rpc_urls {
436 for config in configs {
437 reqwest::Url::parse(&config.url).map_err(|_| {
438 ConfigFileError::InvalidFormat(format!("Invalid RPC URL: {}", config.url))
439 })?;
440
441 if config.weight > 100 {
442 return Err(ConfigFileError::InvalidFormat(
443 "RPC URL weight must be in range 0-100".to_string(),
444 ));
445 }
446 }
447 }
448 Ok(())
449 }
450
451 pub fn validate(&self) -> Result<(), ConfigFileError> {
453 if self.id.is_empty() {
454 return Err(ConfigFileError::MissingField("relayer id".into()));
455 }
456 let id_regex = Regex::new(r"^[a-zA-Z0-9-_]+$").map_err(|e| {
457 ConfigFileError::InternalError(format!("Regex compilation error: {}", e))
458 })?;
459 if !id_regex.is_match(&self.id) {
460 return Err(ConfigFileError::InvalidIdFormat(
461 "ID must contain only letters, numbers, dashes and underscores".into(),
462 ));
463 }
464
465 if self.id.len() > Self::MAX_ID_LENGTH {
466 return Err(ConfigFileError::InvalidIdLength(format!(
467 "ID length must not exceed {} characters",
468 Self::MAX_ID_LENGTH
469 )));
470 }
471 if self.name.is_empty() {
472 return Err(ConfigFileError::MissingField("relayer name".into()));
473 }
474 if self.network.is_empty() {
475 return Err(ConfigFileError::MissingField("network".into()));
476 }
477
478 self.validate_policies()?;
479 self.validate_custom_rpc_urls()?;
480 Ok(())
481 }
482}
483
484#[derive(Debug, Serialize, Deserialize, Clone)]
485#[serde(deny_unknown_fields)]
486pub struct RelayersFileConfig {
487 pub relayers: Vec<RelayerFileConfig>,
488}
489
490impl RelayersFileConfig {
491 pub fn new(relayers: Vec<RelayerFileConfig>) -> Self {
492 Self { relayers }
493 }
494
495 pub fn validate(&self, networks: &NetworksFileConfig) -> Result<(), ConfigFileError> {
496 if self.relayers.is_empty() {
497 return Err(ConfigFileError::MissingField("relayers".into()));
498 }
499
500 let mut ids = HashSet::new();
501 for relayer in &self.relayers {
502 if relayer.network.is_empty() {
503 return Err(ConfigFileError::InvalidFormat(
504 "relayer.network cannot be empty".into(),
505 ));
506 }
507
508 if networks
509 .get_network(relayer.network_type, &relayer.network)
510 .is_none()
511 {
512 return Err(ConfigFileError::InvalidReference(format!(
513 "Relayer '{}' references non-existent network '{}' for type '{:?}'",
514 relayer.id, relayer.network, relayer.network_type
515 )));
516 }
517 relayer.validate()?;
518 if !ids.insert(relayer.id.clone()) {
519 return Err(ConfigFileError::DuplicateId(relayer.id.clone()));
520 }
521 }
522 Ok(())
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use crate::config::{EvmNetworkConfig, NetworkConfigCommon, NetworkFileConfig};
529 use crate::constants::DEFAULT_RPC_WEIGHT;
530
531 use super::*;
532 use serde_json::json;
533
534 #[test]
535 fn test_solana_policy_duplicate_entries() {
536 let config = json!({
537 "id": "solana-relayer",
538 "name": "Solana Mainnet Relayer",
539 "network": "mainnet",
540 "network_type": "solana",
541 "signer_id": "solana-signer",
542 "paused": false,
543 "policies": {
544 "allowed_accounts": ["EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"],
545 "disallowed_accounts": ["EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"],
546 }
547 });
548
549 let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap();
550
551 let err = relayer.validate_policies().unwrap_err();
552
553 assert_eq!(
554 err.to_string(),
555 "Invalid policy: allowed_accounts and disallowed_accounts cannot be both present"
556 );
557 }
558
559 #[test]
560 fn test_solana_policy_format() {
561 let config = json!({
562 "id": "solana-relayer",
563 "name": "Solana Mainnet Relayer",
564 "network": "mainnet",
565 "network_type": "solana",
566 "signer_id": "solana-signer",
567 "paused": false,
568 "policies": {
569 "min_balance": 100,
570 "allowed_tokens": [ {"mint": "token1"}, {"mint": "token2"}],
571 }
572 });
573
574 let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap();
575
576 let err = relayer.validate_policies().unwrap_err();
577
578 assert_eq!(
579 err.to_string(),
580 "Invalid policy: Value must contain only letters, numbers, dashes and underscores"
581 );
582 }
583
584 #[test]
585 fn test_valid_evm_relayer() {
586 let config = json!({
587 "id": "test-relayer",
588 "name": "Test Relayer",
589 "network": "mainnet",
590 "network_type": "evm",
591 "signer_id": "test-signer",
592 "paused": false,
593 "policies": {
594 "gas_price_cap": 100,
595 "whitelist_receivers": ["0x1234"],
596 "eip1559_pricing": true
597 }
598 });
599
600 let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap();
601 assert!(relayer.validate().is_ok());
602 assert_eq!(relayer.id, "test-relayer");
603 assert_eq!(relayer.network_type, ConfigFileNetworkType::Evm);
604 }
605
606 #[test]
607 fn test_valid_solana_relayer() {
608 let config = json!({
609 "id": "solana-relayer",
610 "name": "Solana Mainnet Relayer",
611 "network": "mainnet-beta",
612 "network_type": "solana",
613 "signer_id": "solana-signer",
614 "paused": false,
615 "policies": {
616 "min_balance": 100,
617 "disallowed_accounts": ["HCKHoE2jyk1qfAwpHQghvYH3cEfT8euCygBzF9AV6bhY"],
618 }
619 });
620
621 let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap();
622 assert!(relayer.validate().is_ok());
623 assert_eq!(relayer.id, "solana-relayer");
624 assert_eq!(relayer.network_type, ConfigFileNetworkType::Solana);
625 }
626
627 #[test]
628 fn test_valid_stellar_relayer() {
629 let config = json!({
630 "id": "stellar-relayer",
631 "name": "Stellar Public Relayer",
632 "network": "mainnet",
633 "network_type": "stellar",
634 "signer_id": "stellar_signer",
635 "paused": false,
636 "policies": {
637 "max_fee": 100,
638 "timeout_seconds": 10,
639 "min_balance": 100
640 }
641 });
642
643 let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap();
644 assert!(relayer.validate().is_ok());
645 assert_eq!(relayer.id, "stellar-relayer");
646 assert_eq!(relayer.network_type, ConfigFileNetworkType::Stellar);
647 }
648
649 #[test]
650 fn test_invalid_network_type() {
651 let config = json!({
652 "id": "test-relayer",
653 "network_type": "invalid",
654 "signer_id": "test-signer"
655 });
656
657 let result = serde_json::from_value::<RelayerFileConfig>(config);
658 assert!(result.is_err());
659 }
660
661 #[test]
662 #[should_panic(expected = "missing field `name`")]
663 fn test_missing_required_fields() {
664 let config = json!({
665 "id": "test-relayer"
666 });
667
668 let _relayer: RelayerFileConfig = serde_json::from_value(config).unwrap();
669 }
670
671 #[test]
672 fn test_valid_custom_rpc_urls() {
673 let config = json!({
674 "id": "test-relayer",
675 "name": "Test Relayer",
676 "network": "mainnet",
677 "network_type": "evm",
678 "signer_id": "test-signer",
679 "paused": false,
680 "custom_rpc_urls": [
681 { "url": "https://api.example.com/rpc", "weight": 2 },
682 { "url": "https://rpc.example.com" }
683 ]
684 });
685
686 let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap();
687 assert!(relayer.validate().is_ok());
688
689 let rpc_urls = relayer.custom_rpc_urls.unwrap();
690 assert_eq!(rpc_urls.len(), 2);
691 assert_eq!(rpc_urls[0].url, "https://api.example.com/rpc");
692 assert_eq!(rpc_urls[0].weight, 2_u8);
693 assert_eq!(rpc_urls[1].url, "https://rpc.example.com");
694 assert_eq!(rpc_urls[1].weight, DEFAULT_RPC_WEIGHT);
695 assert_eq!(rpc_urls[1].get_weight(), DEFAULT_RPC_WEIGHT);
696 }
697
698 #[test]
699 fn test_valid_custom_rpc_urls_string_format() {
700 let config = json!({
701 "id": "test-relayer",
702 "name": "Test Relayer",
703 "network": "mainnet",
704 "network_type": "evm",
705 "signer_id": "test-signer",
706 "paused": false,
707 "custom_rpc_urls": [
708 "https://api.example.com/rpc",
709 "https://rpc.example.com"
710 ]
711 });
712
713 let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap();
714 assert!(relayer.validate().is_ok());
715
716 let rpc_urls = relayer.custom_rpc_urls.unwrap();
717 assert_eq!(rpc_urls.len(), 2);
718 assert_eq!(rpc_urls[0].url, "https://api.example.com/rpc");
719 assert_eq!(rpc_urls[0].weight, DEFAULT_RPC_WEIGHT);
720 assert_eq!(rpc_urls[0].get_weight(), DEFAULT_RPC_WEIGHT);
721 assert_eq!(rpc_urls[1].url, "https://rpc.example.com");
722 assert_eq!(rpc_urls[1].weight, DEFAULT_RPC_WEIGHT);
723 assert_eq!(rpc_urls[1].get_weight(), DEFAULT_RPC_WEIGHT);
724 }
725
726 #[test]
727 fn test_invalid_custom_rpc_urls() {
728 let config = json!({
729 "id": "test-relayer",
730 "name": "Test Relayer",
731 "network": "mainnet",
732 "network_type": "evm",
733 "signer_id": "test-signer",
734 "paused": false,
735 "custom_rpc_urls": [
736 { "url": "not-a-url", "weight": 1 },
737 { "url": "https://api.example.com/rpc", "weight": 2 }
738 ]
739 });
740
741 let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap();
742 let result = relayer.validate();
743 assert!(result.is_err());
744 if let Err(ConfigFileError::InvalidFormat(msg)) = result {
745 assert!(msg.contains("Invalid RPC URL"));
746 } else {
747 panic!("Expected ConfigFileError::InvalidFormat");
748 }
749 }
750
751 #[test]
752 fn test_invalid_custom_rpc_urls_weight() {
753 let config = json!({
754 "id": "test-relayer",
755 "name": "Test Relayer",
756 "network": "mainnet",
757 "network_type": "evm",
758 "signer_id": "test-signer",
759 "paused": false,
760 "custom_rpc_urls": [
761 { "url": "https://api.example.com/rpc", "weight": 200 }
762 ]
763 });
764
765 let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap();
766 let result = relayer.validate();
767 assert!(result.is_err());
768 }
769
770 #[test]
771 fn test_empty_custom_rpc_urls() {
772 let config = json!({
773 "id": "test-relayer",
774 "name": "Test Relayer",
775 "network": "mainnet",
776 "network_type": "evm",
777 "signer_id": "test-signer",
778 "paused": false,
779 "custom_rpc_urls": []
780 });
781
782 let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap();
783 assert!(relayer.validate().is_ok());
784 }
785
786 #[test]
787 fn test_no_custom_rpc_urls() {
788 let config = json!({
789 "id": "test-relayer",
790 "name": "Test Relayer",
791 "network": "mainnet",
792 "network_type": "evm",
793 "signer_id": "test-signer",
794 "paused": false
795 });
796
797 let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap();
798 assert!(relayer.validate().is_ok());
799 }
800
801 fn make_relayer_config_with_solana_swap_config(
803 swap_config: serde_json::Value,
804 ) -> serde_json::Value {
805 json!({
806 "id": "test-relayer",
807 "name": "Test Relayer",
808 "network": "mainnet-beta",
809 "network_type": "solana",
810 "signer_id": "test-signer",
811 "paused": false,
812 "policies": {
813 "fee_payment_strategy": "user",
814 "swap_config": swap_config
815 }
816 })
817 }
818
819 #[test]
820 fn invalid_jupiter_swap_options_without_strategy() {
821 let swap_cfg = json!({
822 "cron_schedule": "0 * * * * *",
823 "min_balance_threshold": 1,
824 "jupiter_swap_options": {
825 "priority_level": "high",
826 "priority_fee_max_lamports": 1000,
827 "dynamic_compute_unit_limit": true
828 }
829 });
830 let cfg = make_relayer_config_with_solana_swap_config(swap_cfg);
831 let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap();
832 let err = relayer.validate().unwrap_err();
833 assert_eq!(
834 err.to_string(),
835 "Invalid policy: JupiterSwap options are only valid for JupiterSwap strategy"
836 );
837 }
838
839 #[test]
840 fn invalid_priority_fee_zero() {
841 let swap_cfg = json!({
842 "strategy": "jupiter-swap",
843 "cron_schedule": "0 * * * * *",
844 "min_balance_threshold": 1,
845 "jupiter_swap_options": {
846 "priority_level": "medium",
847 "priority_fee_max_lamports": 0,
848 "dynamic_compute_unit_limit": false
849 }
850 });
851 let cfg = make_relayer_config_with_solana_swap_config(swap_cfg);
852 let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap();
853 let err = relayer.validate().unwrap_err();
854 assert_eq!(
855 err.to_string(),
856 "Invalid policy: Max lamports must be greater than 0"
857 );
858 }
859
860 #[test]
861 fn invalid_empty_priority_level() {
862 let swap_cfg = json!({
863 "strategy": "jupiter-swap",
864 "cron_schedule": "0 * * * * *",
865 "min_balance_threshold": 1,
866 "jupiter_swap_options": {
867 "priority_level": "",
868 "priority_fee_max_lamports": 100,
869 "dynamic_compute_unit_limit": false
870 }
871 });
872 let cfg = make_relayer_config_with_solana_swap_config(swap_cfg);
873 let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap();
874 let err = relayer.validate().unwrap_err();
875 assert_eq!(
876 err.to_string(),
877 "Invalid policy: Priority level cannot be empty"
878 );
879 }
880
881 #[test]
882 fn invalid_priority_level_value() {
883 let swap_cfg = json!({
884 "strategy": "jupiter-swap",
885 "cron_schedule": "0 * * * * *",
886 "min_balance_threshold": 1,
887 "jupiter_swap_options": {
888 "priority_level": "urgent",
889 "priority_fee_max_lamports": 100,
890 "dynamic_compute_unit_limit": true
891 }
892 });
893 let cfg = make_relayer_config_with_solana_swap_config(swap_cfg);
894 let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap();
895 let err = relayer.validate().unwrap_err();
896 assert_eq!(
897 err.to_string(),
898 "Invalid policy: Priority level must be one of: medium, high, veryHigh"
899 );
900 }
901
902 #[test]
903 fn valid_jupiter_swap_config() {
904 let swap_cfg = json!({
905 "strategy": "jupiter-swap",
906 "cron_schedule": "0 * * * * *",
907 "min_balance_threshold": 10,
908 "jupiter_swap_options": {
909 "priority_level": "medium",
910 "priority_fee_max_lamports": 2000,
911 "dynamic_compute_unit_limit": true
912 }
913 });
914 let cfg = make_relayer_config_with_solana_swap_config(swap_cfg);
915 let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap();
916 assert!(relayer.validate().is_ok());
917 }
918
919 #[test]
920 fn valid_jupiter_ultra_config() {
921 let swap_cfg = json!({
922 "strategy": "jupiter-ultra",
923 "cron_schedule": "0 * * * * *",
924 "min_balance_threshold": 10,
925 });
926 let cfg = make_relayer_config_with_solana_swap_config(swap_cfg);
927 let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap();
928 assert!(relayer.validate().is_ok());
929 }
930
931 #[test]
932 fn invalid_jupiter_swap_options_value_for_ultra() {
933 let swap_cfg = json!({
934 "strategy": "jupiter-ultra",
935 "cron_schedule": "0 * * * * *",
936 "min_balance_threshold": 10,
937 "jupiter_swap_options": {
938 "priority_level": "medium",
939 "priority_fee_max_lamports": 2000,
940 "dynamic_compute_unit_limit": true
941 }
942 });
943 let cfg = make_relayer_config_with_solana_swap_config(swap_cfg);
944 let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap();
945 let err = relayer.validate().unwrap_err();
946 assert_eq!(
947 err.to_string(),
948 "Invalid policy: JupiterSwap options are only valid for JupiterSwap strategy"
949 );
950 }
951
952 #[test]
953 fn invalid_swap_config_empty_cron() {
954 let swap_cfg = json!({
955 "strategy": "jupiter-ultra",
956 "cron_schedule": "",
957 "min_balance_threshold": 10,
958 });
959 let cfg = make_relayer_config_with_solana_swap_config(swap_cfg);
960 let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap();
961 let err = relayer.validate().unwrap_err();
962 assert_eq!(
963 err.to_string(),
964 "Invalid policy: Empty cron schedule is not accepted"
965 );
966 }
967
968 #[test]
969 fn invalid_swap_config_invalid_cron() {
970 let swap_cfg = json!({
971 "strategy": "jupiter-ultra",
972 "cron_schedule": "* 1 *",
973 "min_balance_threshold": 10,
974 });
975 let cfg = make_relayer_config_with_solana_swap_config(swap_cfg);
976 let relayer: RelayerFileConfig = serde_json::from_value(cfg).unwrap();
977 let err = relayer.validate().unwrap_err();
978 assert_eq!(
979 err.to_string(),
980 "Invalid policy: Invalid cron schedule format"
981 );
982 }
983
984 #[test]
985 fn invalid_swap_config_invalid_network_jupiter_swap() {
986 let config = json!({
987 "id": "test-relayer",
988 "name": "Test Relayer",
989 "network": "devnet",
990 "network_type": "solana",
991 "signer_id": "test-signer",
992 "paused": false,
993 "policies": {
994 "fee_payment_strategy": "user",
995 "swap_config": {
996 "strategy": "jupiter-swap",
997 "cron_schedule": "* 1 *",
998 "min_balance_threshold": 10,
999 }
1000 }
1001 });
1002 let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap();
1003 let err = relayer.validate().unwrap_err();
1004 assert_eq!(
1005 err.to_string(),
1006 "Invalid policy: JupiterSwap strategy is only supported on mainnet-beta"
1007 );
1008 }
1009
1010 #[test]
1011 fn invalid_swap_config_invalid_network_jupiter_ultra() {
1012 let config = json!({
1013 "id": "test-relayer",
1014 "name": "Test Relayer",
1015 "network": "devnet",
1016 "network_type": "solana",
1017 "signer_id": "test-signer",
1018 "paused": false,
1019 "policies": {
1020 "fee_payment_strategy": "user",
1021 "swap_config": {
1022 "strategy": "jupiter-ultra",
1023 "cron_schedule": "* 1 *",
1024 "min_balance_threshold": 10,
1025 }
1026 }
1027 });
1028 let relayer: RelayerFileConfig = serde_json::from_value(config).unwrap();
1029 let err = relayer.validate().unwrap_err();
1030 assert_eq!(
1031 err.to_string(),
1032 "Invalid policy: JupiterUltra strategy is only supported on mainnet-beta"
1033 );
1034 }
1035
1036 #[test]
1037 fn test_relayer_with_non_existent_network_fails_validation() {
1038 let relayers = vec![RelayerFileConfig {
1039 id: "test-relayer".to_string(),
1040 name: "Test Relayer".to_string(),
1041 network: "non-existent-network".to_string(),
1042 paused: false,
1043 network_type: ConfigFileNetworkType::Evm,
1044 policies: None,
1045 signer_id: "test-signer".to_string(),
1046 notification_id: None,
1047 custom_rpc_urls: None,
1048 }];
1049
1050 let networks = NetworksFileConfig::new(vec![NetworkFileConfig::Evm(EvmNetworkConfig {
1051 common: NetworkConfigCommon {
1052 network: "existing-network".to_string(),
1053 from: None,
1054 rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]),
1055 explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]),
1056 average_blocktime_ms: Some(12000),
1057 is_testnet: Some(true),
1058 tags: Some(vec!["test".to_string()]),
1059 },
1060 chain_id: Some(31337),
1061 required_confirmations: Some(1),
1062 features: None,
1063 symbol: Some("ETH".to_string()),
1064 })])
1065 .expect("Failed to create NetworksFileConfig for test");
1066
1067 let relayers_config = RelayersFileConfig::new(relayers);
1068 let result = relayers_config.validate(&networks);
1069
1070 assert!(result.is_err());
1071 if let Err(ConfigFileError::InvalidReference(msg)) = result {
1072 assert!(msg.contains("non-existent network 'non-existent-network'"));
1073 assert!(msg.contains("Relayer 'test-relayer'"));
1074 } else {
1075 panic!("Expected InvalidReference error, got: {:?}", result);
1076 }
1077 }
1078}