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