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