openzeppelin_relayer/models/relayer/
config.rs

1//! Configuration file representation and parsing for relayers.
2//!
3//! This module handles the configuration file format for relayers, providing:
4//!
5//! - **Config Models**: Structures that match the configuration file schema
6//! - **Validation**: Config-specific validation rules and constraints
7//! - **Conversions**: Bidirectional mapping between config and domain models
8//! - **Collections**: Container types for managing multiple relayer configurations
9//!
10//! Used primarily during application startup to parse relayer settings from config files.
11//! Validation is handled by the domain model in mod.rs to ensure reusability.
12
13use super::{Relayer, RelayerNetworkPolicy, RelayerValidationError, RpcConfig};
14use crate::config::{ConfigFileError, ConfigFileNetworkType, NetworksFileConfig};
15use serde::{Deserialize, Serialize};
16use std::collections::HashSet;
17
18#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
19#[serde(rename_all = "lowercase")]
20pub enum ConfigFileRelayerNetworkPolicy {
21    Evm(ConfigFileRelayerEvmPolicy),
22    Solana(ConfigFileRelayerSolanaPolicy),
23    Stellar(ConfigFileRelayerStellarPolicy),
24}
25
26#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
27#[serde(deny_unknown_fields)]
28pub struct ConfigFileRelayerEvmPolicy {
29    pub gas_price_cap: Option<u128>,
30    pub whitelist_receivers: Option<Vec<String>>,
31    pub eip1559_pricing: Option<bool>,
32    pub private_transactions: Option<bool>,
33    pub min_balance: Option<u128>,
34    pub gas_limit_estimation: Option<bool>,
35    pub include_revert_data: Option<bool>,
36}
37
38#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
39pub struct AllowedTokenSwapConfig {
40    /// Conversion slippage percentage for token. Optional.
41    pub slippage_percentage: Option<f32>,
42    /// Minimum amount of tokens to swap. Optional.
43    pub min_amount: Option<u64>,
44    /// Maximum amount of tokens to swap. Optional.
45    pub max_amount: Option<u64>,
46    /// Minimum amount of tokens to retain after swap. Optional.
47    pub retain_min_amount: Option<u64>,
48}
49
50#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
51pub struct AllowedToken {
52    pub mint: String,
53    /// Decimals for the token. Optional.
54    pub decimals: Option<u8>,
55    /// Symbol for the token. Optional.
56    pub symbol: Option<String>,
57    /// Maximum supported token fee (in lamports) for a transaction. Optional.
58    pub max_allowed_fee: Option<u64>,
59    /// Swap configuration for the token. Optional.
60    pub swap_config: Option<AllowedTokenSwapConfig>,
61}
62
63#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
64#[serde(rename_all = "lowercase")]
65pub enum ConfigFileSolanaFeePaymentStrategy {
66    User,
67    Relayer,
68}
69
70#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
71#[serde(rename_all = "kebab-case")]
72pub enum ConfigFileRelayerSolanaSwapStrategy {
73    JupiterSwap,
74    JupiterUltra,
75}
76
77#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
78pub struct JupiterSwapOptions {
79    /// Maximum priority fee (in lamports) for a transaction. Optional.
80    pub priority_fee_max_lamports: Option<u64>,
81    /// Priority. Optional.
82    pub priority_level: Option<String>,
83
84    pub dynamic_compute_unit_limit: Option<bool>,
85}
86
87#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
88#[serde(deny_unknown_fields)]
89pub struct ConfigFileRelayerSolanaSwapConfig {
90    /// DEX strategy to use for token swaps.
91    pub strategy: Option<ConfigFileRelayerSolanaSwapStrategy>,
92
93    /// Cron schedule for executing token swap logic to keep relayer funded. Optional.
94    pub cron_schedule: Option<String>,
95
96    /// Min sol balance to execute token swap logic to keep relayer funded. Optional.
97    pub min_balance_threshold: Option<u64>,
98
99    /// Swap options for JupiterSwap strategy. Optional.
100    pub jupiter_swap_options: Option<JupiterSwapOptions>,
101}
102
103#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
104#[serde(deny_unknown_fields)]
105pub struct ConfigFileRelayerSolanaPolicy {
106    /// Determines if the relayer pays the transaction fee or the user. Optional.
107    pub fee_payment_strategy: Option<ConfigFileSolanaFeePaymentStrategy>,
108
109    /// Fee margin percentage for the relayer. Optional.
110    pub fee_margin_percentage: Option<f32>,
111
112    /// Minimum balance required for the relayer (in lamports). Optional.
113    pub min_balance: Option<u64>,
114
115    /// List of allowed tokens by their identifiers. Only these tokens are supported if provided.
116    pub allowed_tokens: Option<Vec<AllowedToken>>,
117
118    /// List of allowed programs by their identifiers. Only these programs are supported if
119    /// provided.
120    pub allowed_programs: Option<Vec<String>>,
121
122    /// List of allowed accounts by their public keys. The relayer will only operate with these
123    /// accounts if provided.
124    pub allowed_accounts: Option<Vec<String>>,
125
126    /// List of disallowed accounts by their public keys. These accounts will be explicitly
127    /// blocked.
128    pub disallowed_accounts: Option<Vec<String>>,
129
130    /// Maximum transaction size. Optional.
131    pub max_tx_data_size: Option<u16>,
132
133    /// Maximum supported signatures. Optional.
134    pub max_signatures: Option<u8>,
135
136    /// Maximum allowed fee (in lamports) for a transaction. Optional.
137    pub max_allowed_fee_lamports: Option<u64>,
138
139    /// Swap dex config to use for token swaps. Optional.
140    pub swap_config: Option<ConfigFileRelayerSolanaSwapConfig>,
141}
142
143#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
144#[serde(rename_all = "lowercase")]
145pub enum ConfigFileStellarFeePaymentStrategy {
146    User,
147    Relayer,
148}
149
150#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
151pub struct StellarAllowedTokenSwapConfig {
152    /// Conversion slippage percentage for token. Optional.
153    pub slippage_percentage: Option<f32>,
154    /// Minimum amount of tokens to swap. Optional.
155    pub min_amount: Option<u64>,
156    /// Maximum amount of tokens to swap. Optional.
157    pub max_amount: Option<u64>,
158    /// Minimum amount of tokens to retain after swap. Optional.
159    pub retain_min_amount: Option<u64>,
160}
161
162#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
163pub struct StellarAllowedToken {
164    pub asset: String,
165    /// Maximum supported token fee (in stroops) for a transaction. Optional.
166    pub max_allowed_fee: Option<u64>,
167    /// Swap configuration for the token. Optional.
168    pub swap_config: Option<StellarAllowedTokenSwapConfig>,
169}
170
171#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
172#[serde(rename_all = "kebab-case")]
173pub enum ConfigFileRelayerStellarSwapStrategy {
174    OrderBook,
175    Soroswap,
176}
177
178#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
179#[serde(deny_unknown_fields)]
180pub struct ConfigFileRelayerStellarSwapConfig {
181    /// DEX strategies to use for token swaps, in priority order.
182    /// Strategies are tried sequentially until one can handle the asset.
183    #[serde(default)]
184    pub strategies: Vec<ConfigFileRelayerStellarSwapStrategy>,
185    /// Cron schedule for executing token swap logic to keep relayer funded. Optional.
186    pub cron_schedule: Option<String>,
187    /// Min XLM balance (in stroops) to execute token swap logic to keep relayer funded. Optional.
188    pub min_balance_threshold: Option<u64>,
189}
190
191#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
192#[serde(deny_unknown_fields)]
193pub struct ConfigFileRelayerStellarPolicy {
194    pub max_fee: Option<u32>,
195    pub timeout_seconds: Option<u64>,
196    pub min_balance: Option<u64>,
197    pub concurrent_transactions: Option<bool>,
198    /// Determines if the relayer pays the transaction fee or the user. Optional.
199    /// When set to "user" with STELLAR_FEE_FORWARDER_ADDRESS env var, enables soroban gas abstraction as well.
200    pub fee_payment_strategy: Option<ConfigFileStellarFeePaymentStrategy>,
201    /// Default slippage percentage for token conversions. Optional.
202    pub slippage_percentage: Option<f32>,
203    /// Fee margin percentage for the relayer. Optional.
204    pub fee_margin_percentage: Option<f32>,
205    /// List of allowed tokens by their asset identifiers. Only these tokens are supported if provided.
206    pub allowed_tokens: Option<Vec<StellarAllowedToken>>,
207    /// Swap configuration for converting collected tokens to XLM. Optional.
208    pub swap_config: Option<ConfigFileRelayerStellarSwapConfig>,
209}
210
211#[derive(Debug, Serialize, Clone)]
212pub struct RelayerFileConfig {
213    pub id: String,
214    pub name: String,
215    pub network: String,
216    pub paused: bool,
217    #[serde(flatten)]
218    pub network_type: ConfigFileNetworkType,
219    #[serde(default)]
220    pub policies: Option<ConfigFileRelayerNetworkPolicy>,
221    pub signer_id: String,
222    #[serde(default)]
223    pub notification_id: Option<String>,
224    #[serde(default)]
225    pub custom_rpc_urls: Option<Vec<RpcConfig>>,
226}
227
228use serde::{de, Deserializer};
229use serde_json::Value;
230
231impl<'de> Deserialize<'de> for RelayerFileConfig {
232    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
233    where
234        D: Deserializer<'de>,
235    {
236        // Deserialize as a generic JSON object
237        let mut value: Value = Value::deserialize(deserializer)?;
238
239        // Extract and validate required fields
240        let id = value
241            .get("id")
242            .and_then(Value::as_str)
243            .ok_or_else(|| de::Error::missing_field("id"))?
244            .to_string();
245
246        let name = value
247            .get("name")
248            .and_then(Value::as_str)
249            .ok_or_else(|| de::Error::missing_field("name"))?
250            .to_string();
251
252        let network = value
253            .get("network")
254            .and_then(Value::as_str)
255            .ok_or_else(|| de::Error::missing_field("network"))?
256            .to_string();
257
258        let paused = value
259            .get("paused")
260            .and_then(Value::as_bool)
261            .ok_or_else(|| de::Error::missing_field("paused"))?;
262
263        // Deserialize `network_type` using `ConfigFileNetworkType`
264        let network_type: ConfigFileNetworkType = serde_json::from_value(
265            value
266                .get("network_type")
267                .cloned()
268                .ok_or_else(|| de::Error::missing_field("network_type"))?,
269        )
270        .map_err(de::Error::custom)?;
271
272        let signer_id = value
273            .get("signer_id")
274            .and_then(Value::as_str)
275            .ok_or_else(|| de::Error::missing_field("signer_id"))?
276            .to_string();
277
278        let notification_id = value
279            .get("notification_id")
280            .and_then(Value::as_str)
281            .map(|s| s.to_string());
282
283        // Handle `policies`, using `network_type` to determine how to deserialize
284        let policies = if let Some(policy_value) = value.get_mut("policies") {
285            match network_type {
286                ConfigFileNetworkType::Evm => {
287                    serde_json::from_value::<ConfigFileRelayerEvmPolicy>(policy_value.clone())
288                        .map(ConfigFileRelayerNetworkPolicy::Evm)
289                        .map(Some)
290                        .map_err(de::Error::custom)
291                }
292                ConfigFileNetworkType::Solana => {
293                    serde_json::from_value::<ConfigFileRelayerSolanaPolicy>(policy_value.clone())
294                        .map(ConfigFileRelayerNetworkPolicy::Solana)
295                        .map(Some)
296                        .map_err(de::Error::custom)
297                }
298                ConfigFileNetworkType::Stellar => {
299                    serde_json::from_value::<ConfigFileRelayerStellarPolicy>(policy_value.clone())
300                        .map(ConfigFileRelayerNetworkPolicy::Stellar)
301                        .map(Some)
302                        .map_err(de::Error::custom)
303                }
304            }
305        } else {
306            Ok(None) // `policies` is optional
307        }?;
308
309        let custom_rpc_urls = value
310            .get("custom_rpc_urls")
311            .and_then(|v| v.as_array())
312            .map(|arr| {
313                arr.iter()
314                    .filter_map(|v| {
315                        // Handle both string format (legacy) and object format (new)
316                        if let Some(url_str) = v.as_str() {
317                            // Convert string to RpcConfig with default weight
318                            Some(RpcConfig::new(url_str.to_string()))
319                        } else {
320                            // Try to parse as a RpcConfig object
321                            serde_json::from_value::<RpcConfig>(v.clone()).ok()
322                        }
323                    })
324                    .collect()
325            });
326
327        Ok(RelayerFileConfig {
328            id,
329            name,
330            network,
331            paused,
332            network_type,
333            policies,
334            signer_id,
335            notification_id,
336            custom_rpc_urls,
337        })
338    }
339}
340
341impl TryFrom<RelayerFileConfig> for Relayer {
342    type Error = ConfigFileError;
343
344    fn try_from(config: RelayerFileConfig) -> Result<Self, Self::Error> {
345        // Convert config policies to domain model policies
346        let policies = if let Some(config_policies) = config.policies {
347            Some(convert_config_policies_to_domain(config_policies)?)
348        } else {
349            None
350        };
351
352        // Create domain relayer
353        let relayer = Relayer::new(
354            config.id,
355            config.name,
356            config.network,
357            config.paused,
358            config.network_type.into(),
359            policies,
360            config.signer_id,
361            config.notification_id,
362            config.custom_rpc_urls,
363        );
364
365        // Validate using domain validation logic
366        relayer.validate().map_err(|e| match e {
367            RelayerValidationError::EmptyId => ConfigFileError::MissingField("relayer id".into()),
368            RelayerValidationError::InvalidIdFormat => ConfigFileError::InvalidIdFormat(
369                "ID must contain only letters, numbers, dashes and underscores".into(),
370            ),
371            RelayerValidationError::IdTooLong => {
372                ConfigFileError::InvalidIdLength("ID length must not exceed 36 characters".into())
373            }
374            RelayerValidationError::EmptyName => {
375                ConfigFileError::MissingField("relayer name".into())
376            }
377            RelayerValidationError::EmptyNetwork => ConfigFileError::MissingField("network".into()),
378            RelayerValidationError::InvalidPolicy(msg) => ConfigFileError::InvalidPolicy(msg),
379            RelayerValidationError::InvalidRpcUrl(msg) => {
380                ConfigFileError::InvalidFormat(format!("Invalid RPC URL: {msg}"))
381            }
382            RelayerValidationError::InvalidRpcWeight => {
383                ConfigFileError::InvalidFormat("RPC URL weight must be in range 0-100".to_string())
384            }
385            RelayerValidationError::InvalidField(msg) => ConfigFileError::InvalidFormat(msg),
386        })?;
387
388        Ok(relayer)
389    }
390}
391
392fn convert_config_policies_to_domain(
393    config_policies: ConfigFileRelayerNetworkPolicy,
394) -> Result<RelayerNetworkPolicy, ConfigFileError> {
395    match config_policies {
396        ConfigFileRelayerNetworkPolicy::Evm(evm_policy) => {
397            Ok(RelayerNetworkPolicy::Evm(super::RelayerEvmPolicy {
398                min_balance: evm_policy.min_balance,
399                gas_limit_estimation: evm_policy.gas_limit_estimation,
400                gas_price_cap: evm_policy.gas_price_cap,
401                whitelist_receivers: evm_policy.whitelist_receivers,
402                eip1559_pricing: evm_policy.eip1559_pricing,
403                private_transactions: evm_policy.private_transactions,
404                include_revert_data: evm_policy.include_revert_data,
405            }))
406        }
407        ConfigFileRelayerNetworkPolicy::Solana(solana_policy) => {
408            let swap_config = if let Some(config_swap) = solana_policy.swap_config {
409                Some(super::RelayerSolanaSwapConfig {
410                    strategy: config_swap.strategy.map(|s| match s {
411                        ConfigFileRelayerSolanaSwapStrategy::JupiterSwap => {
412                            super::SolanaSwapStrategy::JupiterSwap
413                        }
414                        ConfigFileRelayerSolanaSwapStrategy::JupiterUltra => {
415                            super::SolanaSwapStrategy::JupiterUltra
416                        }
417                    }),
418                    cron_schedule: config_swap.cron_schedule,
419                    min_balance_threshold: config_swap.min_balance_threshold,
420                    jupiter_swap_options: config_swap.jupiter_swap_options.map(|opts| {
421                        super::JupiterSwapOptions {
422                            priority_fee_max_lamports: opts.priority_fee_max_lamports,
423                            priority_level: opts.priority_level,
424                            dynamic_compute_unit_limit: opts.dynamic_compute_unit_limit,
425                        }
426                    }),
427                })
428            } else {
429                None
430            };
431
432            Ok(RelayerNetworkPolicy::Solana(super::RelayerSolanaPolicy {
433                allowed_programs: solana_policy.allowed_programs,
434                max_signatures: solana_policy.max_signatures,
435                max_tx_data_size: solana_policy.max_tx_data_size,
436                min_balance: solana_policy.min_balance,
437                allowed_tokens: solana_policy.allowed_tokens.map(|tokens| {
438                    tokens
439                        .into_iter()
440                        .map(|t| super::SolanaAllowedTokensPolicy {
441                            mint: t.mint,
442                            decimals: t.decimals,
443                            symbol: t.symbol,
444                            max_allowed_fee: t.max_allowed_fee,
445                            swap_config: t.swap_config.map(|sc| {
446                                super::SolanaAllowedTokensSwapConfig {
447                                    slippage_percentage: sc.slippage_percentage,
448                                    min_amount: sc.min_amount,
449                                    max_amount: sc.max_amount,
450                                    retain_min_amount: sc.retain_min_amount,
451                                }
452                            }),
453                        })
454                        .collect()
455                }),
456                fee_payment_strategy: solana_policy.fee_payment_strategy.map(|s| match s {
457                    ConfigFileSolanaFeePaymentStrategy::User => {
458                        super::SolanaFeePaymentStrategy::User
459                    }
460                    ConfigFileSolanaFeePaymentStrategy::Relayer => {
461                        super::SolanaFeePaymentStrategy::Relayer
462                    }
463                }),
464                fee_margin_percentage: solana_policy.fee_margin_percentage,
465                allowed_accounts: solana_policy.allowed_accounts,
466                disallowed_accounts: solana_policy.disallowed_accounts,
467                max_allowed_fee_lamports: solana_policy.max_allowed_fee_lamports,
468                swap_config,
469            }))
470        }
471        ConfigFileRelayerNetworkPolicy::Stellar(stellar_policy) => {
472            let swap_config = if let Some(config_swap) = stellar_policy.swap_config {
473                Some(super::RelayerStellarSwapConfig {
474                    strategies: config_swap
475                        .strategies
476                        .into_iter()
477                        .map(|s| match s {
478                            ConfigFileRelayerStellarSwapStrategy::OrderBook => {
479                                super::StellarSwapStrategy::OrderBook
480                            }
481                            ConfigFileRelayerStellarSwapStrategy::Soroswap => {
482                                super::StellarSwapStrategy::Soroswap
483                            }
484                        })
485                        .collect(),
486                    cron_schedule: config_swap.cron_schedule,
487                    min_balance_threshold: config_swap.min_balance_threshold,
488                })
489            } else {
490                None
491            };
492
493            Ok(RelayerNetworkPolicy::Stellar(super::RelayerStellarPolicy {
494                min_balance: stellar_policy.min_balance,
495                max_fee: stellar_policy.max_fee,
496                timeout_seconds: stellar_policy.timeout_seconds,
497                concurrent_transactions: stellar_policy.concurrent_transactions,
498                allowed_tokens: stellar_policy.allowed_tokens.map(|tokens| {
499                    tokens
500                        .into_iter()
501                        .map(|t| super::StellarAllowedTokensPolicy {
502                            asset: t.asset,
503                            metadata: None,
504                            max_allowed_fee: t.max_allowed_fee,
505                            swap_config: t.swap_config.map(|sc| {
506                                super::StellarAllowedTokensSwapConfig {
507                                    slippage_percentage: sc.slippage_percentage,
508                                    min_amount: sc.min_amount,
509                                    max_amount: sc.max_amount,
510                                    retain_min_amount: sc.retain_min_amount,
511                                }
512                            }),
513                        })
514                        .collect()
515                }),
516                fee_payment_strategy: stellar_policy.fee_payment_strategy.map(|s| match s {
517                    ConfigFileStellarFeePaymentStrategy::User => {
518                        super::StellarFeePaymentStrategy::User
519                    }
520                    ConfigFileStellarFeePaymentStrategy::Relayer => {
521                        super::StellarFeePaymentStrategy::Relayer
522                    }
523                }),
524                slippage_percentage: stellar_policy.slippage_percentage,
525                fee_margin_percentage: stellar_policy.fee_margin_percentage,
526                swap_config,
527            }))
528        }
529    }
530}
531
532#[derive(Debug, Serialize, Deserialize, Clone)]
533#[serde(deny_unknown_fields)]
534pub struct RelayersFileConfig {
535    pub relayers: Vec<RelayerFileConfig>,
536}
537
538impl RelayersFileConfig {
539    pub fn new(relayers: Vec<RelayerFileConfig>) -> Self {
540        Self { relayers }
541    }
542
543    pub fn validate(&self, networks: &NetworksFileConfig) -> Result<(), ConfigFileError> {
544        if self.relayers.is_empty() {
545            return Ok(());
546        }
547
548        let mut ids = HashSet::new();
549        for relayer_config in &self.relayers {
550            if relayer_config.network.is_empty() {
551                return Err(ConfigFileError::InvalidFormat(
552                    "relayer.network cannot be empty".into(),
553                ));
554            }
555
556            if networks
557                .get_network(relayer_config.network_type, &relayer_config.network)
558                .is_none()
559            {
560                return Err(ConfigFileError::InvalidReference(format!(
561                    "Relayer '{}' references non-existent network '{}' for type '{:?}'",
562                    relayer_config.id, relayer_config.network, relayer_config.network_type
563                )));
564            }
565
566            // Convert to domain model and validate
567            let relayer = Relayer::try_from(relayer_config.clone())?;
568            relayer.validate().map_err(|e| match e {
569                RelayerValidationError::EmptyId => {
570                    ConfigFileError::MissingField("relayer id".into())
571                }
572                RelayerValidationError::InvalidIdFormat => ConfigFileError::InvalidIdFormat(
573                    "ID must contain only letters, numbers, dashes and underscores".into(),
574                ),
575                RelayerValidationError::IdTooLong => ConfigFileError::InvalidIdLength(
576                    "ID length must not exceed 36 characters".into(),
577                ),
578                RelayerValidationError::EmptyName => {
579                    ConfigFileError::MissingField("relayer name".into())
580                }
581                RelayerValidationError::EmptyNetwork => {
582                    ConfigFileError::MissingField("network".into())
583                }
584                RelayerValidationError::InvalidPolicy(msg) => ConfigFileError::InvalidPolicy(msg),
585                RelayerValidationError::InvalidRpcUrl(msg) => {
586                    ConfigFileError::InvalidFormat(format!("Invalid RPC URL: {msg}"))
587                }
588                RelayerValidationError::InvalidRpcWeight => ConfigFileError::InvalidFormat(
589                    "RPC URL weight must be in range 0-100".to_string(),
590                ),
591                RelayerValidationError::InvalidField(msg) => ConfigFileError::InvalidFormat(msg),
592            })?;
593
594            if !ids.insert(relayer_config.id.clone()) {
595                return Err(ConfigFileError::DuplicateId(relayer_config.id.clone()));
596            }
597        }
598        Ok(())
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use crate::config::ConfigFileNetworkType;
606    use crate::models::relayer::{SolanaFeePaymentStrategy, SolanaSwapStrategy};
607    use serde_json;
608
609    fn create_test_networks_config() -> NetworksFileConfig {
610        // Create a mock networks config for validation tests
611        NetworksFileConfig::new(vec![]).unwrap()
612    }
613
614    #[test]
615    fn test_relayer_file_config_deserialization_evm() {
616        let json_input = r#"{
617            "id": "test-evm-relayer",
618            "name": "Test EVM Relayer",
619            "network": "mainnet",
620            "paused": false,
621            "network_type": "evm",
622            "signer_id": "test-signer",
623            "policies": {
624                "gas_price_cap": 100000000000,
625                "eip1559_pricing": true,
626                "min_balance": 1000000000000000000,
627                "gas_limit_estimation": false,
628                "private_transactions": null
629            },
630            "notification_id": "test-notification",
631            "custom_rpc_urls": [
632                "https://mainnet.infura.io/v3/test",
633                {"url": "https://eth.llamarpc.com", "weight": 80}
634            ]
635        }"#;
636
637        let config: RelayerFileConfig = serde_json::from_str(json_input).unwrap();
638
639        assert_eq!(config.id, "test-evm-relayer");
640        assert_eq!(config.name, "Test EVM Relayer");
641        assert_eq!(config.network, "mainnet");
642        assert!(!config.paused);
643        assert_eq!(config.network_type, ConfigFileNetworkType::Evm);
644        assert_eq!(config.signer_id, "test-signer");
645        assert_eq!(
646            config.notification_id,
647            Some("test-notification".to_string())
648        );
649
650        // Test policies
651        assert!(config.policies.is_some());
652        if let Some(ConfigFileRelayerNetworkPolicy::Evm(evm_policy)) = config.policies {
653            assert_eq!(evm_policy.gas_price_cap, Some(100000000000));
654            assert_eq!(evm_policy.eip1559_pricing, Some(true));
655            assert_eq!(evm_policy.min_balance, Some(1000000000000000000));
656            assert_eq!(evm_policy.gas_limit_estimation, Some(false));
657            assert_eq!(evm_policy.private_transactions, None);
658        } else {
659            panic!("Expected EVM policy");
660        }
661
662        // Test custom RPC URLs (both string and object formats)
663        assert!(config.custom_rpc_urls.is_some());
664        let rpc_urls = config.custom_rpc_urls.unwrap();
665        assert_eq!(rpc_urls.len(), 2);
666        assert_eq!(rpc_urls[0].url, "https://mainnet.infura.io/v3/test");
667        assert_eq!(rpc_urls[0].weight, 100); // Default weight
668        assert_eq!(rpc_urls[1].url, "https://eth.llamarpc.com");
669        assert_eq!(rpc_urls[1].weight, 80);
670    }
671
672    #[test]
673    fn test_evm_policy_include_revert_data_serde_roundtrip() {
674        let omitted: ConfigFileRelayerEvmPolicy =
675            serde_json::from_str(r#"{ "gas_limit_estimation": true }"#).unwrap();
676        assert_eq!(omitted.include_revert_data, None);
677
678        let explicit: ConfigFileRelayerEvmPolicy =
679            serde_json::from_str(r#"{ "include_revert_data": false }"#).unwrap();
680        assert_eq!(explicit.include_revert_data, Some(false));
681
682        let reparsed: ConfigFileRelayerEvmPolicy =
683            serde_json::from_str(&serde_json::to_string(&explicit).unwrap()).unwrap();
684        assert_eq!(reparsed.include_revert_data, Some(false));
685    }
686
687    #[test]
688    fn test_relayer_file_config_deserialization_solana() {
689        let json_input = r#"{
690            "id": "test-solana-relayer",
691            "name": "Test Solana Relayer",
692            "network": "mainnet",
693            "paused": true,
694            "network_type": "solana",
695            "signer_id": "test-signer",
696            "policies": {
697                "fee_payment_strategy": "relayer",
698                "min_balance": 5000000,
699                "max_signatures": 8,
700                "max_tx_data_size": 1024,
701                "fee_margin_percentage": 2.5,
702                "allowed_tokens": [
703                    {
704                        "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
705                        "decimals": 6,
706                        "symbol": "USDC",
707                        "max_allowed_fee": 100000,
708                        "swap_config": {
709                            "slippage_percentage": 0.5,
710                            "min_amount": 1000,
711                            "max_amount": 10000000
712                        }
713                    }
714                ],
715                "allowed_programs": ["11111111111111111111111111111111"],
716                "swap_config": {
717                    "strategy": "jupiter-swap",
718                    "cron_schedule": "0 0 * * *",
719                    "min_balance_threshold": 1000000,
720                    "jupiter_swap_options": {
721                        "priority_fee_max_lamports": 10000,
722                        "priority_level": "high",
723                        "dynamic_compute_unit_limit": true
724                    }
725                }
726            }
727        }"#;
728
729        let config: RelayerFileConfig = serde_json::from_str(json_input).unwrap();
730
731        assert_eq!(config.id, "test-solana-relayer");
732        assert_eq!(config.network_type, ConfigFileNetworkType::Solana);
733        assert!(config.paused);
734
735        // Test Solana policies
736        assert!(config.policies.is_some());
737        if let Some(ConfigFileRelayerNetworkPolicy::Solana(solana_policy)) = config.policies {
738            assert_eq!(
739                solana_policy.fee_payment_strategy,
740                Some(ConfigFileSolanaFeePaymentStrategy::Relayer)
741            );
742            assert_eq!(solana_policy.min_balance, Some(5000000));
743            assert_eq!(solana_policy.max_signatures, Some(8));
744            assert_eq!(solana_policy.max_tx_data_size, Some(1024));
745            assert_eq!(solana_policy.fee_margin_percentage, Some(2.5));
746
747            // Test allowed tokens
748            assert!(solana_policy.allowed_tokens.is_some());
749            let tokens = solana_policy.allowed_tokens.as_ref().unwrap();
750            assert_eq!(tokens.len(), 1);
751            assert_eq!(
752                tokens[0].mint,
753                "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
754            );
755            assert_eq!(tokens[0].decimals, Some(6));
756            assert_eq!(tokens[0].symbol, Some("USDC".to_string()));
757            assert_eq!(tokens[0].max_allowed_fee, Some(100000));
758
759            // Test swap config in token
760            assert!(tokens[0].swap_config.is_some());
761            let token_swap = tokens[0].swap_config.as_ref().unwrap();
762            assert_eq!(token_swap.slippage_percentage, Some(0.5));
763            assert_eq!(token_swap.min_amount, Some(1000));
764            assert_eq!(token_swap.max_amount, Some(10000000));
765
766            // Test main swap config
767            assert!(solana_policy.swap_config.is_some());
768            let swap_config = solana_policy.swap_config.as_ref().unwrap();
769            assert_eq!(
770                swap_config.strategy,
771                Some(ConfigFileRelayerSolanaSwapStrategy::JupiterSwap)
772            );
773            assert_eq!(swap_config.cron_schedule, Some("0 0 * * *".to_string()));
774            assert_eq!(swap_config.min_balance_threshold, Some(1000000));
775
776            // Test Jupiter options
777            assert!(swap_config.jupiter_swap_options.is_some());
778            let jupiter_opts = swap_config.jupiter_swap_options.as_ref().unwrap();
779            assert_eq!(jupiter_opts.priority_fee_max_lamports, Some(10000));
780            assert_eq!(jupiter_opts.priority_level, Some("high".to_string()));
781            assert_eq!(jupiter_opts.dynamic_compute_unit_limit, Some(true));
782        } else {
783            panic!("Expected Solana policy");
784        }
785    }
786
787    #[test]
788    fn test_relayer_file_config_deserialization_stellar() {
789        let json_input = r#"{
790            "id": "test-stellar-relayer",
791            "name": "Test Stellar Relayer",
792            "network": "mainnet",
793            "paused": false,
794            "network_type": "stellar",
795            "signer_id": "test-signer",
796            "policies": {
797                "min_balance": 20000000,
798                "max_fee": 100000,
799                "timeout_seconds": 30
800            },
801            "custom_rpc_urls": [
802                {"url": "https://stellar-node.example.com", "weight": 100}
803            ]
804        }"#;
805
806        let config: RelayerFileConfig = serde_json::from_str(json_input).unwrap();
807
808        assert_eq!(config.id, "test-stellar-relayer");
809        assert_eq!(config.network_type, ConfigFileNetworkType::Stellar);
810        assert!(!config.paused);
811
812        // Test Stellar policies
813        assert!(config.policies.is_some());
814        if let Some(ConfigFileRelayerNetworkPolicy::Stellar(stellar_policy)) = config.policies {
815            assert_eq!(stellar_policy.min_balance, Some(20000000));
816            assert_eq!(stellar_policy.max_fee, Some(100000));
817            assert_eq!(stellar_policy.timeout_seconds, Some(30));
818        } else {
819            panic!("Expected Stellar policy");
820        }
821    }
822
823    #[test]
824    fn test_relayer_file_config_deserialization_minimal() {
825        // Test minimal config without optional fields
826        let json_input = r#"{
827            "id": "minimal-relayer",
828            "name": "Minimal Relayer",
829            "network": "testnet",
830            "paused": false,
831            "network_type": "evm",
832            "signer_id": "minimal-signer"
833        }"#;
834
835        let config: RelayerFileConfig = serde_json::from_str(json_input).unwrap();
836
837        assert_eq!(config.id, "minimal-relayer");
838        assert_eq!(config.name, "Minimal Relayer");
839        assert_eq!(config.network, "testnet");
840        assert!(!config.paused);
841        assert_eq!(config.network_type, ConfigFileNetworkType::Evm);
842        assert_eq!(config.signer_id, "minimal-signer");
843        assert_eq!(config.notification_id, None);
844        assert_eq!(config.policies, None);
845        assert_eq!(config.custom_rpc_urls, None);
846    }
847
848    #[test]
849    fn test_relayer_file_config_deserialization_missing_required_field() {
850        // Test missing required field should fail
851        let json_input = r#"{
852            "name": "Test Relayer",
853            "network": "mainnet",
854            "paused": false,
855            "network_type": "evm",
856            "signer_id": "test-signer"
857        }"#;
858
859        let result = serde_json::from_str::<RelayerFileConfig>(json_input);
860        assert!(result.is_err());
861        assert!(result
862            .unwrap_err()
863            .to_string()
864            .contains("missing field `id`"));
865    }
866
867    #[test]
868    fn test_relayer_file_config_deserialization_invalid_network_type() {
869        let json_input = r#"{
870            "id": "test-relayer",
871            "name": "Test Relayer",
872            "network": "mainnet",
873            "paused": false,
874            "network_type": "invalid",
875            "signer_id": "test-signer"
876        }"#;
877
878        let result = serde_json::from_str::<RelayerFileConfig>(json_input);
879        assert!(result.is_err());
880    }
881
882    #[test]
883    fn test_relayer_file_config_deserialization_wrong_policy_for_network_type() {
884        // Test EVM network type with Solana policy should fail
885        let json_input = r#"{
886            "id": "test-relayer",
887            "name": "Test Relayer",
888            "network": "mainnet",
889            "paused": false,
890            "network_type": "evm",
891            "signer_id": "test-signer",
892            "policies": {
893                "fee_payment_strategy": "relayer"
894            }
895        }"#;
896
897        let result = serde_json::from_str::<RelayerFileConfig>(json_input);
898        assert!(result.is_err());
899    }
900
901    #[test]
902    fn test_convert_config_policies_to_domain_evm() {
903        let config_policy = ConfigFileRelayerNetworkPolicy::Evm(ConfigFileRelayerEvmPolicy {
904            include_revert_data: None,
905            gas_price_cap: Some(50000000000),
906            whitelist_receivers: Some(vec!["0x123".to_string(), "0x456".to_string()]),
907            eip1559_pricing: Some(true),
908            private_transactions: Some(false),
909            min_balance: Some(2000000000000000000),
910            gas_limit_estimation: Some(true),
911        });
912
913        let domain_policy = convert_config_policies_to_domain(config_policy).unwrap();
914
915        if let RelayerNetworkPolicy::Evm(evm_policy) = domain_policy {
916            assert_eq!(evm_policy.gas_price_cap, Some(50000000000));
917            assert_eq!(
918                evm_policy.whitelist_receivers,
919                Some(vec!["0x123".to_string(), "0x456".to_string()])
920            );
921            assert_eq!(evm_policy.eip1559_pricing, Some(true));
922            assert_eq!(evm_policy.private_transactions, Some(false));
923            assert_eq!(evm_policy.min_balance, Some(2000000000000000000));
924            assert_eq!(evm_policy.gas_limit_estimation, Some(true));
925        } else {
926            panic!("Expected EVM domain policy");
927        }
928    }
929
930    #[test]
931    fn test_convert_config_policies_to_domain_solana() {
932        let config_policy = ConfigFileRelayerNetworkPolicy::Solana(ConfigFileRelayerSolanaPolicy {
933            fee_payment_strategy: Some(ConfigFileSolanaFeePaymentStrategy::User),
934            fee_margin_percentage: Some(1.5),
935            min_balance: Some(3000000),
936            allowed_tokens: Some(vec![AllowedToken {
937                mint: "TokenMint123".to_string(),
938                decimals: Some(9),
939                symbol: Some("TOKEN".to_string()),
940                max_allowed_fee: Some(50000),
941                swap_config: Some(AllowedTokenSwapConfig {
942                    slippage_percentage: Some(1.0),
943                    min_amount: Some(100),
944                    max_amount: Some(1000000),
945                    retain_min_amount: Some(500),
946                }),
947            }]),
948            allowed_programs: Some(vec!["Program123".to_string()]),
949            allowed_accounts: Some(vec!["Account123".to_string()]),
950            disallowed_accounts: None,
951            max_tx_data_size: Some(2048),
952            max_signatures: Some(10),
953            max_allowed_fee_lamports: Some(100000),
954            swap_config: Some(ConfigFileRelayerSolanaSwapConfig {
955                strategy: Some(ConfigFileRelayerSolanaSwapStrategy::JupiterUltra),
956                cron_schedule: Some("0 */6 * * *".to_string()),
957                min_balance_threshold: Some(2000000),
958                jupiter_swap_options: Some(JupiterSwapOptions {
959                    priority_fee_max_lamports: Some(5000),
960                    priority_level: Some("medium".to_string()),
961                    dynamic_compute_unit_limit: Some(false),
962                }),
963            }),
964        });
965
966        let domain_policy = convert_config_policies_to_domain(config_policy).unwrap();
967
968        if let RelayerNetworkPolicy::Solana(solana_policy) = domain_policy {
969            assert_eq!(
970                solana_policy.fee_payment_strategy,
971                Some(SolanaFeePaymentStrategy::User)
972            );
973            assert_eq!(solana_policy.fee_margin_percentage, Some(1.5));
974            assert_eq!(solana_policy.min_balance, Some(3000000));
975            assert_eq!(solana_policy.max_tx_data_size, Some(2048));
976            assert_eq!(solana_policy.max_signatures, Some(10));
977
978            // Test allowed tokens conversion
979            assert!(solana_policy.allowed_tokens.is_some());
980            let tokens = solana_policy.allowed_tokens.unwrap();
981            assert_eq!(tokens.len(), 1);
982            assert_eq!(tokens[0].mint, "TokenMint123");
983            assert_eq!(tokens[0].decimals, Some(9));
984            assert_eq!(tokens[0].symbol, Some("TOKEN".to_string()));
985            assert_eq!(tokens[0].max_allowed_fee, Some(50000));
986
987            // Test swap config conversion
988            assert!(solana_policy.swap_config.is_some());
989            let swap_config = solana_policy.swap_config.unwrap();
990            assert_eq!(swap_config.strategy, Some(SolanaSwapStrategy::JupiterUltra));
991            assert_eq!(swap_config.cron_schedule, Some("0 */6 * * *".to_string()));
992            assert_eq!(swap_config.min_balance_threshold, Some(2000000));
993        } else {
994            panic!("Expected Solana domain policy");
995        }
996    }
997
998    #[test]
999    fn test_convert_config_policies_to_domain_stellar() {
1000        let config_policy =
1001            ConfigFileRelayerNetworkPolicy::Stellar(ConfigFileRelayerStellarPolicy {
1002                min_balance: Some(25000000),
1003                max_fee: Some(150000),
1004                timeout_seconds: Some(60),
1005                concurrent_transactions: None,
1006                allowed_tokens: None,
1007                fee_payment_strategy: Some(ConfigFileStellarFeePaymentStrategy::User),
1008                slippage_percentage: None,
1009                fee_margin_percentage: None,
1010                swap_config: None,
1011            });
1012
1013        let domain_policy = convert_config_policies_to_domain(config_policy).unwrap();
1014
1015        if let RelayerNetworkPolicy::Stellar(stellar_policy) = domain_policy {
1016            assert_eq!(stellar_policy.min_balance, Some(25000000));
1017            assert_eq!(stellar_policy.max_fee, Some(150000));
1018            assert_eq!(stellar_policy.timeout_seconds, Some(60));
1019        } else {
1020            panic!("Expected Stellar domain policy");
1021        }
1022    }
1023
1024    #[test]
1025    fn test_try_from_relayer_file_config_to_domain_evm() {
1026        let config = RelayerFileConfig {
1027            id: "test-evm".to_string(),
1028            name: "Test EVM Relayer".to_string(),
1029            network: "mainnet".to_string(),
1030            paused: false,
1031            network_type: ConfigFileNetworkType::Evm,
1032            policies: Some(ConfigFileRelayerNetworkPolicy::Evm(
1033                ConfigFileRelayerEvmPolicy {
1034                    include_revert_data: None,
1035                    gas_price_cap: Some(75000000000),
1036                    whitelist_receivers: None,
1037                    eip1559_pricing: Some(true),
1038                    private_transactions: None,
1039                    min_balance: None,
1040                    gas_limit_estimation: None,
1041                },
1042            )),
1043            signer_id: "test-signer".to_string(),
1044            notification_id: Some("test-notification".to_string()),
1045            custom_rpc_urls: None,
1046        };
1047
1048        let domain_relayer = Relayer::try_from(config).unwrap();
1049
1050        assert_eq!(domain_relayer.id, "test-evm");
1051        assert_eq!(domain_relayer.name, "Test EVM Relayer");
1052        assert_eq!(domain_relayer.network, "mainnet");
1053        assert!(!domain_relayer.paused);
1054        assert_eq!(
1055            domain_relayer.network_type,
1056            crate::models::relayer::RelayerNetworkType::Evm
1057        );
1058        assert_eq!(domain_relayer.signer_id, "test-signer");
1059        assert_eq!(
1060            domain_relayer.notification_id,
1061            Some("test-notification".to_string())
1062        );
1063
1064        // Test policy conversion
1065        assert!(domain_relayer.policies.is_some());
1066        if let Some(RelayerNetworkPolicy::Evm(evm_policy)) = domain_relayer.policies {
1067            assert_eq!(evm_policy.gas_price_cap, Some(75000000000));
1068            assert_eq!(evm_policy.eip1559_pricing, Some(true));
1069        } else {
1070            panic!("Expected EVM domain policy");
1071        }
1072    }
1073
1074    #[test]
1075    fn test_try_from_relayer_file_config_to_domain_solana() {
1076        let config = RelayerFileConfig {
1077            id: "test-solana".to_string(),
1078            name: "Test Solana Relayer".to_string(),
1079            network: "mainnet".to_string(),
1080            paused: true,
1081            network_type: ConfigFileNetworkType::Solana,
1082            policies: Some(ConfigFileRelayerNetworkPolicy::Solana(
1083                ConfigFileRelayerSolanaPolicy {
1084                    fee_payment_strategy: Some(ConfigFileSolanaFeePaymentStrategy::Relayer),
1085                    fee_margin_percentage: None,
1086                    min_balance: Some(4000000),
1087                    allowed_tokens: None,
1088                    allowed_programs: None,
1089                    allowed_accounts: None,
1090                    disallowed_accounts: None,
1091                    max_tx_data_size: None,
1092                    max_signatures: Some(7),
1093                    max_allowed_fee_lamports: None,
1094                    swap_config: None,
1095                },
1096            )),
1097            signer_id: "test-signer".to_string(),
1098            notification_id: None,
1099            custom_rpc_urls: None,
1100        };
1101
1102        let domain_relayer = Relayer::try_from(config).unwrap();
1103
1104        assert_eq!(
1105            domain_relayer.network_type,
1106            crate::models::relayer::RelayerNetworkType::Solana
1107        );
1108        assert!(domain_relayer.paused);
1109
1110        // Test policy conversion
1111        assert!(domain_relayer.policies.is_some());
1112        if let Some(RelayerNetworkPolicy::Solana(solana_policy)) = domain_relayer.policies {
1113            assert_eq!(
1114                solana_policy.fee_payment_strategy,
1115                Some(SolanaFeePaymentStrategy::Relayer)
1116            );
1117            assert_eq!(solana_policy.min_balance, Some(4000000));
1118            assert_eq!(solana_policy.max_signatures, Some(7));
1119        } else {
1120            panic!("Expected Solana domain policy");
1121        }
1122    }
1123
1124    #[test]
1125    fn test_try_from_relayer_file_config_to_domain_stellar() {
1126        let config = RelayerFileConfig {
1127            id: "test-stellar".to_string(),
1128            name: "Test Stellar Relayer".to_string(),
1129            network: "mainnet".to_string(),
1130            paused: false,
1131            network_type: ConfigFileNetworkType::Stellar,
1132            policies: Some(ConfigFileRelayerNetworkPolicy::Stellar(
1133                ConfigFileRelayerStellarPolicy {
1134                    min_balance: Some(35000000),
1135                    max_fee: Some(200000),
1136                    timeout_seconds: Some(90),
1137                    concurrent_transactions: None,
1138                    allowed_tokens: None,
1139                    fee_payment_strategy: Some(ConfigFileStellarFeePaymentStrategy::User),
1140                    slippage_percentage: None,
1141                    fee_margin_percentage: None,
1142                    swap_config: None,
1143                },
1144            )),
1145            signer_id: "test-signer".to_string(),
1146            notification_id: None,
1147            custom_rpc_urls: None,
1148        };
1149
1150        let domain_relayer = Relayer::try_from(config).unwrap();
1151
1152        assert_eq!(
1153            domain_relayer.network_type,
1154            crate::models::relayer::RelayerNetworkType::Stellar
1155        );
1156
1157        // Test policy conversion
1158        assert!(domain_relayer.policies.is_some());
1159        if let Some(RelayerNetworkPolicy::Stellar(stellar_policy)) = domain_relayer.policies {
1160            assert_eq!(stellar_policy.min_balance, Some(35000000));
1161            assert_eq!(stellar_policy.max_fee, Some(200000));
1162            assert_eq!(stellar_policy.timeout_seconds, Some(90));
1163        } else {
1164            panic!("Expected Stellar domain policy");
1165        }
1166    }
1167
1168    #[test]
1169    fn test_try_from_relayer_file_config_validation_error() {
1170        let config = RelayerFileConfig {
1171            id: "".to_string(), // Invalid: empty ID
1172            name: "Test Relayer".to_string(),
1173            network: "mainnet".to_string(),
1174            paused: false,
1175            network_type: ConfigFileNetworkType::Evm,
1176            policies: None,
1177            signer_id: "test-signer".to_string(),
1178            notification_id: None,
1179            custom_rpc_urls: None,
1180        };
1181
1182        let result = Relayer::try_from(config);
1183        assert!(result.is_err());
1184
1185        if let Err(ConfigFileError::MissingField(field)) = result {
1186            assert_eq!(field, "relayer id");
1187        } else {
1188            panic!("Expected MissingField error for empty ID");
1189        }
1190    }
1191
1192    #[test]
1193    fn test_try_from_relayer_file_config_invalid_id_format() {
1194        let config = RelayerFileConfig {
1195            id: "invalid@id".to_string(), // Invalid: contains @
1196            name: "Test Relayer".to_string(),
1197            network: "mainnet".to_string(),
1198            paused: false,
1199            network_type: ConfigFileNetworkType::Evm,
1200            policies: None,
1201            signer_id: "test-signer".to_string(),
1202            notification_id: None,
1203            custom_rpc_urls: None,
1204        };
1205
1206        let result = Relayer::try_from(config);
1207        assert!(result.is_err());
1208
1209        if let Err(ConfigFileError::InvalidIdFormat(_)) = result {
1210            // Success - expected error type
1211        } else {
1212            panic!("Expected InvalidIdFormat error");
1213        }
1214    }
1215
1216    #[test]
1217    fn test_relayers_file_config_validation_success() {
1218        let relayer_config = RelayerFileConfig {
1219            id: "test-relayer".to_string(),
1220            name: "Test Relayer".to_string(),
1221            network: "mainnet".to_string(),
1222            paused: false,
1223            network_type: ConfigFileNetworkType::Evm,
1224            policies: None,
1225            signer_id: "test-signer".to_string(),
1226            notification_id: None,
1227            custom_rpc_urls: None,
1228        };
1229
1230        let relayers_config = RelayersFileConfig::new(vec![relayer_config]);
1231        let networks_config = create_test_networks_config();
1232
1233        // Note: This will fail because we don't have the network in our mock config
1234        // But we're testing that the validation logic runs
1235        let result = relayers_config.validate(&networks_config);
1236
1237        // We expect this to fail due to network reference, but not due to empty relayers
1238        assert!(result.is_err());
1239        if let Err(ConfigFileError::InvalidReference(_)) = result {
1240            // Expected - network doesn't exist in our mock config
1241        } else {
1242            panic!("Expected InvalidReference error");
1243        }
1244    }
1245
1246    #[test]
1247    fn test_relayers_file_config_validation_duplicate_ids() {
1248        let relayer_config1 = RelayerFileConfig {
1249            id: "duplicate-id".to_string(),
1250            name: "Test Relayer 1".to_string(),
1251            network: "mainnet".to_string(),
1252            paused: false,
1253            network_type: ConfigFileNetworkType::Evm,
1254            policies: None,
1255            signer_id: "test-signer1".to_string(),
1256            notification_id: None,
1257            custom_rpc_urls: None,
1258        };
1259
1260        let relayer_config2 = RelayerFileConfig {
1261            id: "duplicate-id".to_string(), // Same ID
1262            name: "Test Relayer 2".to_string(),
1263            network: "testnet".to_string(),
1264            paused: false,
1265            network_type: ConfigFileNetworkType::Solana,
1266            policies: None,
1267            signer_id: "test-signer2".to_string(),
1268            notification_id: None,
1269            custom_rpc_urls: None,
1270        };
1271
1272        let relayers_config = RelayersFileConfig::new(vec![relayer_config1, relayer_config2]);
1273        let networks_config = create_test_networks_config();
1274
1275        let result = relayers_config.validate(&networks_config);
1276        assert!(result.is_err());
1277
1278        // The validation may fail with network reference error before reaching duplicate ID check
1279        // Let's check for either error type since both are valid validation failures
1280        match result {
1281            Err(ConfigFileError::DuplicateId(id)) => {
1282                assert_eq!(id, "duplicate-id");
1283            }
1284            Err(ConfigFileError::InvalidReference(_)) => {
1285                // Also acceptable - network doesn't exist in our mock config
1286            }
1287            Err(other) => {
1288                panic!("Expected DuplicateId or InvalidReference error, got: {other:?}");
1289            }
1290            Ok(_) => {
1291                panic!("Expected validation to fail but it succeeded");
1292            }
1293        }
1294    }
1295
1296    #[test]
1297    fn test_relayers_file_config_validation_empty_network() {
1298        let relayer_config = RelayerFileConfig {
1299            id: "test-relayer".to_string(),
1300            name: "Test Relayer".to_string(),
1301            network: "".to_string(), // Empty network
1302            paused: false,
1303            network_type: ConfigFileNetworkType::Evm,
1304            policies: None,
1305            signer_id: "test-signer".to_string(),
1306            notification_id: None,
1307            custom_rpc_urls: None,
1308        };
1309
1310        let relayers_config = RelayersFileConfig::new(vec![relayer_config]);
1311        let networks_config = create_test_networks_config();
1312
1313        let result = relayers_config.validate(&networks_config);
1314        assert!(result.is_err());
1315
1316        if let Err(ConfigFileError::InvalidFormat(msg)) = result {
1317            assert!(msg.contains("relayer.network cannot be empty"));
1318        } else {
1319            panic!("Expected InvalidFormat error for empty network");
1320        }
1321    }
1322
1323    #[test]
1324    fn test_config_file_policy_serialization() {
1325        // Test that individual policy structs can be serialized/deserialized
1326        let evm_policy = ConfigFileRelayerEvmPolicy {
1327            include_revert_data: None,
1328            gas_price_cap: Some(80000000000),
1329            whitelist_receivers: Some(vec!["0xabc".to_string()]),
1330            eip1559_pricing: Some(false),
1331            private_transactions: Some(true),
1332            min_balance: Some(500000000000000000),
1333            gas_limit_estimation: Some(true),
1334        };
1335
1336        let serialized = serde_json::to_string(&evm_policy).unwrap();
1337        let deserialized: ConfigFileRelayerEvmPolicy = serde_json::from_str(&serialized).unwrap();
1338        assert_eq!(evm_policy, deserialized);
1339
1340        let solana_policy = ConfigFileRelayerSolanaPolicy {
1341            fee_payment_strategy: Some(ConfigFileSolanaFeePaymentStrategy::User),
1342            fee_margin_percentage: Some(3.0),
1343            min_balance: Some(6000000),
1344            allowed_tokens: None,
1345            allowed_programs: Some(vec!["Program456".to_string()]),
1346            allowed_accounts: None,
1347            disallowed_accounts: Some(vec!["DisallowedAccount".to_string()]),
1348            max_tx_data_size: Some(1536),
1349            max_signatures: Some(12),
1350            max_allowed_fee_lamports: Some(200000),
1351            swap_config: None,
1352        };
1353
1354        let serialized = serde_json::to_string(&solana_policy).unwrap();
1355        let deserialized: ConfigFileRelayerSolanaPolicy =
1356            serde_json::from_str(&serialized).unwrap();
1357        assert_eq!(solana_policy, deserialized);
1358
1359        let stellar_policy = ConfigFileRelayerStellarPolicy {
1360            min_balance: Some(45000000),
1361            max_fee: Some(250000),
1362            timeout_seconds: Some(120),
1363            concurrent_transactions: None,
1364            allowed_tokens: None,
1365            fee_payment_strategy: Some(ConfigFileStellarFeePaymentStrategy::Relayer),
1366            slippage_percentage: None,
1367            fee_margin_percentage: None,
1368            swap_config: None,
1369        };
1370
1371        let serialized = serde_json::to_string(&stellar_policy).unwrap();
1372        let deserialized: ConfigFileRelayerStellarPolicy =
1373            serde_json::from_str(&serialized).unwrap();
1374        assert_eq!(stellar_policy, deserialized);
1375    }
1376}