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