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