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