openzeppelin_relayer/config/config_file/
relayer.rs

1//! Configuration file definitions for relayer services.
2//!
3//! Provides configuration structures and validation for relayer settings:
4//! - Network configuration (EVM, Solana, Stellar)
5//! - Gas/fee policies
6//! - Transaction validation rules
7//! - Network endpoints
8use 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    /// Conversion slippage percentage for token. Optional.
37    pub slippage_percentage: Option<f32>,
38    /// Minimum amount of tokens to swap. Optional.
39    pub min_amount: Option<u64>,
40    /// Maximum amount of tokens to swap. Optional.
41    pub max_amount: Option<u64>,
42    /// Minimum amount of tokens to retain after swap. Optional.
43    pub retain_min_amount: Option<u64>,
44}
45
46#[derive(Debug, Serialize, Deserialize, Clone)]
47pub struct AllowedToken {
48    pub mint: String,
49    /// Maximum supported token fee (in lamports) for a transaction. Optional.
50    pub max_allowed_fee: Option<u64>,
51    /// Swap configuration for the token. Optional.
52    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    /// Maximum priority fee (in lamports) for a transaction. Optional.
72    pub priority_fee_max_lamports: Option<u64>,
73    /// Priority. Optional.
74    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    /// DEX strategy to use for token swaps.
83    pub strategy: Option<ConfigFileRelayerSolanaSwapStrategy>,
84
85    /// Cron schedule for executing token swap logic to keep relayer funded. Optional.
86    pub cron_schedule: Option<String>,
87
88    /// Min sol balance to execute token swap logic to keep relayer funded. Optional.
89    pub min_balance_threshold: Option<u64>,
90
91    /// Swap options for JupiterSwap strategy. Optional.
92    pub jupiter_swap_options: Option<JupiterSwapOptions>,
93}
94
95#[derive(Debug, Serialize, Deserialize, Clone)]
96#[serde(deny_unknown_fields)]
97pub struct ConfigFileRelayerSolanaPolicy {
98    /// Determines if the relayer pays the transaction fee or the user. Optional.
99    pub fee_payment_strategy: Option<ConfigFileRelayerSolanaFeePaymentStrategy>,
100
101    /// Fee margin percentage for the relayer. Optional.
102    pub fee_margin_percentage: Option<f32>,
103
104    /// Minimum balance required for the relayer (in lamports). Optional.
105    pub min_balance: Option<u64>,
106
107    /// List of allowed tokens by their identifiers. Only these tokens are supported if provided.
108    pub allowed_tokens: Option<Vec<AllowedToken>>,
109
110    /// List of allowed programs by their identifiers. Only these programs are supported if
111    /// provided.
112    pub allowed_programs: Option<Vec<String>>,
113
114    /// List of allowed accounts by their public keys. The relayer will only operate with these
115    /// accounts if provided.
116    pub allowed_accounts: Option<Vec<String>>,
117
118    /// List of disallowed accounts by their public keys. These accounts will be explicitly
119    /// blocked.
120    pub disallowed_accounts: Option<Vec<String>>,
121
122    /// Maximum transaction size. Optional.
123    pub max_tx_data_size: Option<u16>,
124
125    /// Maximum supported signatures. Optional.
126    pub max_signatures: Option<u8>,
127
128    /// Maximum allowed fee (in lamports) for a transaction. Optional.
129    pub max_allowed_fee_lamports: Option<u64>,
130
131    /// Swap dex config to use for token swaps. Optional.
132    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        // Deserialize as a generic JSON object
168        let mut value: Value = Value::deserialize(deserializer)?;
169
170        // Extract and validate required fields
171        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        // Deserialize `network_type` using `ConfigFileNetworkType`
195        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        // Handle `policies`, using `network_type` to determine how to deserialize
215        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) // `policies` is optional
238        }?;
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                        // Handle both string format (legacy) and object format (new)
247                        if let Some(url_str) = v.as_str() {
248                            // Convert string to RpcConfig with default weight
249                            Some(RpcConfig::new(url_str.to_string()))
250                        } else {
251                            // Try to parse as a RpcConfig object
252                            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            // strategy must be jupiter_swap
362            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                    // check if both allowed_accounts and disallowed_accounts are present
420                    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    // TODO add validation that multiple relayers on same network cannot use same signer
452    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    /// Helper to build a minimal RelayerFileConfig JSON for Solana with given swap_config
802    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}