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