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