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