From be88d2fd34de3be5e13a73ba5bccc6096d8e98df Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 30 Dec 2025 16:46:15 +0100 Subject: [PATCH 1/9] feat: RPC improvements --- docs/configuration/index.mdx | 23 + src/api/controllers/relayer.rs | 11 +- src/api/routes/relayer.rs | 4 +- src/config/config_file/mod.rs | 43 +- src/config/config_file/network/collection.rs | 45 +- src/config/config_file/network/common.rs | 376 +++++- src/config/config_file/network/evm.rs | 44 +- src/config/config_file/network/inheritance.rs | 23 +- src/config/config_file/network/mod.rs | 7 +- src/config/config_file/network/test_utils.rs | 27 +- src/config/server_config.rs | 54 +- src/constants/relayer.rs | 7 + src/domain/relayer/evm/evm_relayer.rs | 24 +- src/domain/relayer/evm/rpc_utils.rs | 238 +--- src/domain/relayer/solana/dex/jupiter_swap.rs | 4 +- .../relayer/solana/dex/jupiter_ultra.rs | 4 +- .../solana/rpc/methods/fee_estimate.rs | 28 +- .../solana/rpc/methods/prepare_transaction.rs | 7 +- .../rpc/methods/sign_and_send_transaction.rs | 21 +- .../solana/rpc/methods/sign_transaction.rs | 14 +- .../rpc/methods/transfer_transaction.rs | 14 +- .../relayer/solana/rpc/methods/utils.rs | 25 +- src/domain/relayer/solana/solana_relayer.rs | 17 +- src/domain/relayer/stellar/gas_abstraction.rs | 6 +- src/domain/relayer/stellar/stellar_relayer.rs | 20 +- src/domain/relayer/stellar/token_swap.rs | 6 +- src/domain/transaction/evm/evm_transaction.rs | 10 +- .../transaction/evm/price_calculator.rs | 9 +- src/domain/transaction/evm/status.rs | 13 +- src/domain/transaction/evm/utils.rs | 10 +- .../transaction/stellar/test_helpers.rs | 8 +- src/models/network/evm/network.rs | 14 +- src/models/network/repository.rs | 19 +- src/models/network/solana/network.rs | 8 +- src/models/network/stellar/network.rs | 8 +- src/models/relayer/mod.rs | 1 + src/models/relayer/rpc_config.rs | 66 +- src/models/transaction/repository.rs | 16 +- src/repositories/network/network_in_memory.rs | 4 +- src/repositories/network/network_redis.rs | 8 +- src/repositories/relayer/mod.rs | 60 +- src/services/gas/cache.rs | 4 +- src/services/gas/evm_gas_price.rs | 4 +- src/services/gas/fetchers/default.rs | 20 +- src/services/gas/fetchers/mod.rs | 8 +- src/services/gas/fetchers/polygon_zkevm.rs | 4 +- src/services/gas/price_params_handler.rs | 14 +- src/services/jupiter/mod.rs | 22 +- src/services/provider/evm/mod.rs | 82 +- src/services/provider/mod.rs | 154 ++- src/services/provider/retry.rs | 445 +++++-- src/services/provider/rpc_health_store.rs | 705 ++++++++++ src/services/provider/rpc_selector.rs | 1179 ++++++++++++----- src/services/provider/solana/mod.rs | 121 +- src/services/provider/stellar/mod.rs | 112 +- src/utils/error_sanitization.rs | 401 ++++++ src/utils/mocks.rs | 11 +- src/utils/mod.rs | 3 + 58 files changed, 3566 insertions(+), 1069 deletions(-) create mode 100644 src/services/provider/rpc_health_store.rs create mode 100644 src/utils/error_sanitization.rs diff --git a/docs/configuration/index.mdx b/docs/configuration/index.mdx index d19165d72..3bc68b243 100644 --- a/docs/configuration/index.mdx +++ b/docs/configuration/index.mdx @@ -65,6 +65,9 @@ This table lists the environment variables and their default values. | `PROVIDER_RETRY_BASE_DELAY_MS` | `100` | `` | Base delay between retry attempts in milliseconds. | | `PROVIDER_RETRY_MAX_DELAY_MS` | `2000` | `` | Maximum delay between retry attempts in milliseconds. | | `PROVIDER_MAX_FAILOVERS` | `3` | `` | Maximum number of failovers (switching to different providers). | +| `PROVIDER_FAILURE_THRESHOLD` | `3` | `` | Number of consecutive failures before a provider is temporarily paused. When a provider reaches this threshold, it will be paused for the duration specified by `PROVIDER_PAUSE_DURATION_SECS`. Supports legacy env var `RPC_FAILURE_THRESHOLD`. | +| `PROVIDER_PAUSE_DURATION_SECS` | `60` | `` | Duration in seconds that a provider remains paused after reaching the failure threshold. During this time, the relayer will attempt to use other available providers. Supports legacy env var `RPC_PAUSE_DURATION_SECS`. | +| `PROVIDER_FAILURE_EXPIRATION_SECS` | `60` | `` | Duration in seconds after which individual failure records are considered stale and automatically removed. This allows providers to naturally recover over time even without explicit success calls. Failures older than this duration are not counted toward the failure threshold. | | `ENABLE_SWAGGER` | `false` | `true, false` | Enable or disable Swagger UI for API documentation. | | `KEYSTORE_PASSPHRASE` | `` | `` | Passphrase for the keystore file used for signing transactions. | | `BACKGROUND_WORKER_TRANSACTION_REQUEST_CONCURRENCY` | `50` | `` | Maximum number of concurrent transaction request jobs that can be processed simultaneously. | @@ -99,6 +102,9 @@ PROVIDER_MAX_RETRIES=3 PROVIDER_RETRY_BASE_DELAY_MS=100 PROVIDER_RETRY_MAX_DELAY_MS=2000 PROVIDER_MAX_FAILOVERS=3 +PROVIDER_FAILURE_THRESHOLD=3 +PROVIDER_PAUSE_DURATION_SECS=60 +PROVIDER_FAILURE_EXPIRATION_SECS=60 ENABLE_SWAGGER=false KEYSTORE_PASSPHRASE=your_keystore_passphrase STORAGE_ENCRYPTION_KEY=X67aXacJB+krEldv9i2w7NCSFwwOzVV/1ELM2KJJjQw= @@ -299,6 +305,23 @@ For backward compatibility, string arrays are still supported: "custom_rpc_urls": ["https://your-rpc.example.com"] ``` +#### Provider Health Management + +The relayer automatically tracks the health of RPC providers and manages failover: + +* **Failure Tracking**: When a provider fails, the failure is recorded with a timestamp. Failures older than `PROVIDER_FAILURE_EXPIRATION_SECS` (default: 60 seconds) are automatically considered stale and removed. + +* **Automatic Pausing**: When a provider reaches `PROVIDER_FAILURE_THRESHOLD` (default: 3) failures within the expiration window, it is automatically paused for `PROVIDER_PAUSE_DURATION_SECS` (default: 60 seconds). During this pause period, the relayer will attempt to use other available providers. + +* **Automatic Recovery**: After the pause duration expires, the provider becomes available again. Additionally, if all failures expire (older than `PROVIDER_FAILURE_EXPIRATION_SECS`), the provider automatically recovers even if it hasn't reached the pause expiration time. + +* **Fallback Behavior**: If all non-paused providers are unavailable, the relayer will fall back to paused providers as a last resort, ensuring maximum availability. + +You can configure these behaviors using the environment variables: +* `PROVIDER_FAILURE_THRESHOLD`: Number of failures before pausing (default: 3) +* `PROVIDER_PAUSE_DURATION_SECS`: How long to pause a failed provider (default: 60 seconds) +* `PROVIDER_FAILURE_EXPIRATION_SECS`: How long failures are remembered (default: 60 seconds) + diff --git a/src/api/controllers/relayer.rs b/src/api/controllers/relayer.rs index 2f78de7bc..0287f08dd 100644 --- a/src/api/controllers/relayer.rs +++ b/src/api/controllers/relayer.rs @@ -965,7 +965,7 @@ mod tests { /// Helper function to create a mock Solana network fn create_mock_solana_network() -> crate::models::NetworkRepoModel { use crate::config::{NetworkConfigCommon, SolanaNetworkConfig}; - use crate::models::{NetworkConfigData, NetworkRepoModel, NetworkType}; + use crate::models::{NetworkConfigData, NetworkRepoModel, NetworkType, RpcConfig}; NetworkRepoModel { id: "test".to_string(), @@ -975,7 +975,7 @@ mod tests { common: NetworkConfigCommon { network: "test".to_string(), from: None, - rpc_urls: Some(vec!["http://localhost:8899".to_string()]), + rpc_urls: Some(vec![RpcConfig::new("http://localhost:8899".to_string())]), explorer_urls: None, average_blocktime_ms: Some(400), is_testnet: Some(true), @@ -988,7 +988,7 @@ mod tests { /// Helper function to create a mock Stellar network fn create_mock_stellar_network() -> crate::models::NetworkRepoModel { use crate::config::{NetworkConfigCommon, StellarNetworkConfig}; - use crate::models::{NetworkConfigData, NetworkRepoModel, NetworkType}; + use crate::models::{NetworkConfigData, NetworkRepoModel, NetworkType, RpcConfig}; NetworkRepoModel { id: "test".to_string(), @@ -998,7 +998,9 @@ mod tests { common: NetworkConfigCommon { network: "test".to_string(), from: None, - rpc_urls: Some(vec!["https://horizon-testnet.stellar.org".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://horizon-testnet.stellar.org".to_string(), + )]), explorer_urls: None, average_blocktime_ms: Some(5000), is_testnet: Some(true), @@ -1836,6 +1838,7 @@ mod tests { relayer.custom_rpc_urls = Some(vec![crate::models::RpcConfig { url: "https://custom-rpc.example.com".to_string(), weight: 50, + ..Default::default() }]); let app_state = create_mock_app_state(None, Some(vec![relayer]), None, None, None, None).await; diff --git a/src/api/routes/relayer.rs b/src/api/routes/relayer.rs index 205957b57..669856c74 100644 --- a/src/api/routes/relayer.rs +++ b/src/api/routes/relayer.rs @@ -251,7 +251,7 @@ mod tests { models::{ ApiKeyRepoModel, AppState, EvmTransactionData, LocalSignerConfigStorage, NetworkConfigData, NetworkRepoModel, NetworkTransactionData, NetworkType, - RelayerEvmPolicy, RelayerNetworkPolicy, RelayerRepoModel, SecretString, + RelayerEvmPolicy, RelayerNetworkPolicy, RelayerRepoModel, RpcConfig, SecretString, SignerConfigStorage, SignerRepoModel, TransactionRepoModel, TransactionStatus, U256, }, repositories::{ @@ -293,7 +293,7 @@ mod tests { common: NetworkConfigCommon { network: "ethereum".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]), explorer_urls: None, average_blocktime_ms: Some(12000), is_testnet: Some(false), diff --git a/src/config/config_file/mod.rs b/src/config/config_file/mod.rs index 60c1d3a8d..4d33b5a82 100644 --- a/src/config/config_file/mod.rs +++ b/src/config/config_file/mod.rs @@ -187,7 +187,8 @@ mod tests { use crate::models::{ signer::{LocalSignerFileConfig, SignerFileConfig, SignerFileConfigEnum}, ConfigFileRelayerNetworkPolicy, ConfigFileRelayerStellarPolicy, - ConfigFileStellarFeePaymentStrategy, NotificationType, PlainOrEnvValue, SecretString, + ConfigFileStellarFeePaymentStrategy, NotificationType, PlainOrEnvValue, RpcConfig, + SecretString, }; use std::path::Path; @@ -225,7 +226,9 @@ mod tests { common: NetworkConfigCommon { network: "test-network".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://rpc.test.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]), average_blocktime_ms: Some(12000), is_testnet: Some(true), @@ -265,7 +268,9 @@ mod tests { common: NetworkConfigCommon { network: "test-network".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://rpc.test.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]), average_blocktime_ms: Some(12000), is_testnet: Some(true), @@ -293,7 +298,9 @@ mod tests { common: NetworkConfigCommon { network: "test-network".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://rpc.test.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]), average_blocktime_ms: Some(12000), is_testnet: Some(true), @@ -859,7 +866,9 @@ mod tests { common: NetworkConfigCommon { network: "test-network".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://rpc.test.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]), average_blocktime_ms: Some(12000), is_testnet: Some(true), @@ -1209,7 +1218,9 @@ mod tests { common: NetworkConfigCommon { network: "test-network".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://rpc.test.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]), average_blocktime_ms: Some(12000), is_testnet: Some(true), @@ -1244,7 +1255,9 @@ mod tests { common: NetworkConfigCommon { network: "test-network".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.test.example.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://rpc.test.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]), average_blocktime_ms: Some(12000), is_testnet: Some(true), @@ -1310,7 +1323,9 @@ mod tests { common: NetworkConfigCommon { network: "devnet".to_string(), from: None, - rpc_urls: Some(vec!["https://api.devnet.solana.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://api.devnet.solana.com".to_string(), + )]), explorer_urls: Some(vec!["https://explorer.solana.com".to_string()]), average_blocktime_ms: Some(400), is_testnet: Some(true), @@ -1322,7 +1337,9 @@ mod tests { common: NetworkConfigCommon { network: "testnet".to_string(), from: None, - rpc_urls: Some(vec!["https://soroban-testnet.stellar.org".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://soroban-testnet.stellar.org".to_string(), + )]), explorer_urls: Some(vec!["https://stellar.expert/explorer/testnet".to_string()]), average_blocktime_ms: Some(5000), is_testnet: Some(true), @@ -1351,7 +1368,9 @@ mod tests { common: NetworkConfigCommon { network: "solana-test".to_string(), from: None, - rpc_urls: Some(vec!["https://api.devnet.solana.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://api.devnet.solana.com".to_string(), + )]), explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]), average_blocktime_ms: Some(400), is_testnet: Some(true), @@ -1364,7 +1383,9 @@ mod tests { common: NetworkConfigCommon { network: "stellar-test".to_string(), from: None, - rpc_urls: Some(vec!["https://horizon-testnet.stellar.org".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://horizon-testnet.stellar.org".to_string(), + )]), explorer_urls: Some(vec!["https://explorer.test.example.com".to_string()]), average_blocktime_ms: Some(5000), is_testnet: Some(true), diff --git a/src/config/config_file/network/collection.rs b/src/config/config_file/network/collection.rs index 08e240bef..643a0a28f 100644 --- a/src/config/config_file/network/collection.rs +++ b/src/config/config_file/network/collection.rs @@ -829,23 +829,23 @@ mod tests { } #[test] - fn test_deserialize_with_inheritance_resolution() { + fn test_deserialize_networks_array() { let json = r#"[ { "type": "evm", - "network": "parent", - "chain_id": 31337, - "required_confirmations": 1, + "network": "testnet", + "chain_id": 11155111, + "required_confirmations": 6, "symbol": "ETH", - "rpc_urls": ["https://rpc.parent.example.com"] + "rpc_urls": ["https://sepolia.drpc.org", "https://1rpc.io/sepolia"] }, { - "type": "evm", - "network": "child", - "from": "parent", - "chain_id": 31338, - "required_confirmations": 1, - "symbol": "ETH" + "type": "solana", + "network": "devnet", + "rpc_urls": ["https://api.devnet.solana.com"], + "explorer_urls": ["https://explorer.solana.com?cluster=devnet"], + "average_blocktime_ms": 400, + "is_testnet": true } ]"#; @@ -854,16 +854,23 @@ mod tests { let config = result.unwrap(); assert_eq!(config.len(), 2); - // After deserialization, inheritance should be resolved but from field preserved - let child = config - .get_network(ConfigFileNetworkType::Evm, "child") + // Verify EVM network + let evm_network = config + .get_network(ConfigFileNetworkType::Evm, "testnet") .unwrap(); - assert_eq!(child.inherits_from(), Some("parent")); // From field preserved + if let NetworkFileConfig::Evm(evm_config) = evm_network { + assert_eq!(evm_config.chain_id, Some(11155111)); + assert!(evm_config.common.rpc_urls.is_some()); + assert_eq!(evm_config.common.rpc_urls.as_ref().unwrap().len(), 2); + } - // Verify that child has inherited properties from parent - if let NetworkFileConfig::Evm(child_evm) = child { - assert!(child_evm.common.rpc_urls.is_some()); // Should have inherited RPC URLs - assert_eq!(child_evm.chain_id, Some(31338)); // Should have overridden chain_id + // Verify Solana network + let solana_network = config + .get_network(ConfigFileNetworkType::Solana, "devnet") + .unwrap(); + if let NetworkFileConfig::Solana(solana_config) = solana_network { + assert_eq!(solana_config.common.average_blocktime_ms, Some(400)); + assert_eq!(solana_config.common.is_testnet, Some(true)); } } diff --git a/src/config/config_file/network/common.rs b/src/config/config_file/network/common.rs index 7190381f4..cd76729d7 100644 --- a/src/config/config_file/network/common.rs +++ b/src/config/config_file/network/common.rs @@ -10,9 +10,52 @@ //! - **Validation**: Required field checks and URL format validation use crate::config::ConfigFileError; -use serde::{Deserialize, Serialize}; +use crate::models::RpcConfig; +use serde::de::Error as DeError; +use serde::{Deserialize, Deserializer, Serialize}; + +/// Custom deserializer for rpc_urls that supports both simple format (array of strings) +/// and extended format (array of RpcConfig objects). +fn deserialize_rpc_urls<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + // First, deserialize as a generic Value to check what we have + let value: Option = Option::deserialize(deserializer)?; + + match value { + None => Ok(None), + Some(serde_json::Value::Array(arr)) => { + let mut configs = Vec::new(); + for item in arr { + match item { + serde_json::Value::String(url) => { + // Simple format: string -> convert to RpcConfig with default weight + configs.push(RpcConfig::new(url)); + } + serde_json::Value::Object(obj) => { + // Extended format: object -> deserialize as RpcConfig + let config: RpcConfig = + serde_json::from_value(serde_json::Value::Object(obj)) + .map_err(DeError::custom)?; + configs.push(config); + } + _ => { + return Err(DeError::custom( + "rpc_urls must be an array of strings or RpcConfig objects", + )); + } + } + } + Ok(Some(configs)) + } + Some(_) => Err(DeError::custom( + "rpc_urls must be an array of strings or RpcConfig objects", + )), + } +} -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Clone)] pub struct NetworkConfigCommon { /// Unique network identifier (e.g., "mainnet", "sepolia", "custom-devnet"). pub network: String, @@ -20,8 +63,10 @@ pub struct NetworkConfigCommon { /// If set, this network will use the `from` network's settings as a base, /// overriding specific fields as needed. pub from: Option, - /// List of RPC endpoint URLs for connecting to the network. - pub rpc_urls: Option>, + /// List of RPC endpoint configurations for connecting to the network. + /// Supports both simple format (array of strings) and extended format (array of RpcConfig objects). + #[serde(deserialize_with = "deserialize_rpc_urls")] + pub rpc_urls: Option>, /// List of Explorer endpoint URLs for connecting to the network. pub explorer_urls: Option>, /// Estimated average time between blocks in milliseconds. @@ -32,6 +77,36 @@ pub struct NetworkConfigCommon { pub tags: Option>, } +impl<'de> Deserialize<'de> for NetworkConfigCommon { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct NetworkConfigCommonHelper { + network: String, + from: Option, + #[serde(deserialize_with = "deserialize_rpc_urls")] + rpc_urls: Option>, + explorer_urls: Option>, + average_blocktime_ms: Option, + is_testnet: Option, + tags: Option>, + } + + let helper = NetworkConfigCommonHelper::deserialize(deserializer)?; + Ok(NetworkConfigCommon { + network: helper.network, + from: helper.from, + rpc_urls: helper.rpc_urls, + explorer_urls: helper.explorer_urls, + average_blocktime_ms: helper.average_blocktime_ms, + is_testnet: helper.is_testnet, + tags: helper.tags, + }) + } +} + impl NetworkConfigCommon { /// Validates the common fields for a network configuration. /// @@ -53,10 +128,10 @@ impl NetworkConfigCommon { } // Validate RPC URLs format if provided - if let Some(urls) = &self.rpc_urls { - for url in urls { - reqwest::Url::parse(url).map_err(|_| { - ConfigFileError::InvalidFormat(format!("Invalid RPC URL: {url}")) + if let Some(configs) = &self.rpc_urls { + for config in configs { + reqwest::Url::parse(&config.url).map_err(|_| { + ConfigFileError::InvalidFormat(format!("Invalid RPC URL: {}", config.url)) })?; } } @@ -79,11 +154,12 @@ impl NetworkConfigCommon { /// /// # Returns /// A new `NetworkConfigCommon` with merged values where child takes precedence over parent. + /// For RPC URLs: if child has RPC URLs, they completely override parent's. If child has no RPC URLs, parent's are inherited. pub fn merge_with_parent(&self, parent: &Self) -> Self { Self { network: self.network.clone(), from: self.from.clone(), - rpc_urls: self.rpc_urls.clone().or_else(|| parent.rpc_urls.clone()), + rpc_urls: merge_optional_rpc_config_vecs(&self.rpc_urls, &parent.rpc_urls), explorer_urls: self .explorer_urls .clone() @@ -95,6 +171,29 @@ impl NetworkConfigCommon { } } +/// Combines child and parent RPC config vectors. +/// +/// Behavior: +/// - If child has RPC configs: Use child's configs (allows weight specification for child URLs). +/// - If child has no RPC configs: Use parent's configs (inheritance). +/// +/// # Arguments +/// * `child` - Optional vector of child RPC configs. +/// * `parent` - Optional vector of parent RPC configs. +/// +/// # Returns +/// An optional vector containing child's RPC configs, or parent's if child has none, or `None` if both inputs are `None`. +pub fn merge_optional_rpc_config_vecs( + child: &Option>, + parent: &Option>, +) -> Option> { + match (child, parent) { + (Some(child), _) => Some(child.clone()), // Child overrides parent + (None, Some(parent)) => Some(parent.clone()), // Inherit from parent + (None, None) => None, + } +} + /// Combines child and parent string vectors, preserving all unique items with child items taking precedence. /// /// # Arguments @@ -198,8 +297,9 @@ mod tests { #[test] fn test_validate_invalid_rpc_url_format() { + use crate::models::RpcConfig; let mut config = create_network_common("test-network"); - config.rpc_urls = Some(vec!["invalid-url".to_string()]); + config.rpc_urls = Some(vec![RpcConfig::new("invalid-url".to_string())]); let result = config.validate(); assert!(result.is_err()); @@ -211,11 +311,12 @@ mod tests { #[test] fn test_validate_multiple_invalid_rpc_urls() { + use crate::models::RpcConfig; let mut config = create_network_common("test-network"); config.rpc_urls = Some(vec![ - "https://valid.example.com".to_string(), - "invalid-url".to_string(), - "also-invalid".to_string(), + RpcConfig::new("https://valid.example.com".to_string()), + RpcConfig::new("invalid-url".to_string()), + RpcConfig::new("also-invalid".to_string()), ]); let result = config.validate(); @@ -228,12 +329,13 @@ mod tests { #[test] fn test_validate_various_valid_rpc_url_formats() { + use crate::models::RpcConfig; let mut config = create_network_common("test-network"); config.rpc_urls = Some(vec![ - "https://mainnet.infura.io/v3/key".to_string(), - "http://localhost:8545".to_string(), - "wss://ws.example.com".to_string(), - "https://rpc.example.com:8080/path".to_string(), + RpcConfig::new("https://mainnet.infura.io/v3/key".to_string()), + RpcConfig::new("http://localhost:8545".to_string()), + RpcConfig::new("wss://ws.example.com".to_string()), + RpcConfig::new("https://rpc.example.com:8080/path".to_string()), ]); let result = config.validate(); @@ -242,8 +344,11 @@ mod tests { #[test] fn test_validate_inheriting_network_with_rpc_urls() { + use crate::models::RpcConfig; let mut config = create_network_common_with_parent("child-network", "parent-network"); - config.rpc_urls = Some(vec!["https://override.example.com".to_string()]); + config.rpc_urls = Some(vec![RpcConfig::new( + "https://override.example.com".to_string(), + )]); let result = config.validate(); assert!(result.is_ok()); @@ -251,8 +356,9 @@ mod tests { #[test] fn test_validate_inheriting_network_with_invalid_rpc_urls() { + use crate::models::RpcConfig; let mut config = create_network_common_with_parent("child-network", "parent-network"); - config.rpc_urls = Some(vec!["invalid-url".to_string()]); + config.rpc_urls = Some(vec![RpcConfig::new("invalid-url".to_string())]); let result = config.validate(); assert!(result.is_err()); @@ -264,10 +370,13 @@ mod tests { #[test] fn test_merge_with_parent_child_overrides() { + use crate::models::RpcConfig; let parent = NetworkConfigCommon { network: "parent".to_string(), from: None, - rpc_urls: Some(vec!["https://parent-rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://parent-rpc.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]), average_blocktime_ms: Some(10000), is_testnet: Some(true), @@ -277,7 +386,9 @@ mod tests { let child = NetworkConfigCommon { network: "child".to_string(), from: Some("parent".to_string()), - rpc_urls: Some(vec!["https://child-rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://child-rpc.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://child-explorer.example.com".to_string()]), average_blocktime_ms: Some(15000), is_testnet: Some(false), @@ -288,9 +399,12 @@ mod tests { assert_eq!(result.network, "child"); assert_eq!(result.from, Some("parent".to_string())); + // Child's RPC URLs override parent's assert_eq!( result.rpc_urls, - Some(vec!["https://child-rpc.example.com".to_string()]) + Some(vec![RpcConfig::new( + "https://child-rpc.example.com".to_string() + )]) ); assert_eq!(result.average_blocktime_ms, Some(15000)); assert_eq!(result.is_testnet, Some(false)); @@ -302,10 +416,13 @@ mod tests { #[test] fn test_merge_with_parent_child_inherits() { + use crate::models::RpcConfig; let parent = NetworkConfigCommon { network: "parent".to_string(), from: None, - rpc_urls: Some(vec!["https://parent-rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://parent-rpc.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]), average_blocktime_ms: Some(10000), is_testnet: Some(true), @@ -328,7 +445,9 @@ mod tests { assert_eq!(result.from, Some("parent".to_string())); assert_eq!( result.rpc_urls, - Some(vec!["https://parent-rpc.example.com".to_string()]) + Some(vec![RpcConfig::new( + "https://parent-rpc.example.com".to_string() + )]) ); assert_eq!( result.explorer_urls, @@ -341,10 +460,13 @@ mod tests { #[test] fn test_merge_with_parent_mixed_inheritance() { + use crate::models::RpcConfig; let parent = NetworkConfigCommon { network: "parent".to_string(), from: None, - rpc_urls: Some(vec!["https://parent-rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://parent-rpc.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]), average_blocktime_ms: Some(10000), is_testnet: Some(true), @@ -354,19 +476,24 @@ mod tests { let child = NetworkConfigCommon { network: "child".to_string(), from: Some("parent".to_string()), - rpc_urls: Some(vec!["https://child-rpc.example.com".to_string()]), // Override + rpc_urls: Some(vec![RpcConfig::new( + "https://child-rpc.example.com".to_string(), + )]), // Override explorer_urls: Some(vec!["https://child-explorer.example.com".to_string()]), // Override - average_blocktime_ms: None, // Inherit - is_testnet: Some(false), // Override - tags: Some(vec!["child-tag".to_string()]), // Merge + average_blocktime_ms: None, // Inherit + is_testnet: Some(false), // Override + tags: Some(vec!["child-tag".to_string()]), // Merge }; let result = child.merge_with_parent(&parent); assert_eq!(result.network, "child"); + // Child's RPC URLs override parent's (complete override) assert_eq!( result.rpc_urls, - Some(vec!["https://child-rpc.example.com".to_string()]) + Some(vec![RpcConfig::new( + "https://child-rpc.example.com".to_string() + )]) ); assert_eq!( result.explorer_urls, @@ -419,10 +546,11 @@ mod tests { #[test] fn test_merge_with_parent_complex_tag_merging() { + use crate::models::RpcConfig; let parent = NetworkConfigCommon { network: "parent".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]), explorer_urls: Some(vec!["https://explorer.example.com".to_string()]), average_blocktime_ms: Some(12000), is_testnet: Some(true), @@ -611,8 +739,9 @@ mod tests { #[test] fn test_validate_with_unicode_rpc_urls() { + use crate::models::RpcConfig; let mut config = create_network_common("test-network"); - config.rpc_urls = Some(vec!["https://测试.example.com".to_string()]); + config.rpc_urls = Some(vec![RpcConfig::new("https://测试.example.com".to_string())]); let result = config.validate(); assert!(result.is_ok()); @@ -620,10 +749,13 @@ mod tests { #[test] fn test_merge_with_parent_preserves_child_network_name() { + use crate::models::RpcConfig; let parent = NetworkConfigCommon { network: "parent-name".to_string(), from: None, - rpc_urls: Some(vec!["https://parent.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://parent.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://parent.example.com".to_string()]), average_blocktime_ms: Some(10000), is_testnet: Some(true), @@ -649,10 +781,13 @@ mod tests { #[test] fn test_merge_with_parent_preserves_child_from_field() { + use crate::models::RpcConfig; let parent = NetworkConfigCommon { network: "parent".to_string(), from: Some("grandparent".to_string()), - rpc_urls: Some(vec!["https://parent.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://parent.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://parent.example.com".to_string()]), average_blocktime_ms: Some(10000), is_testnet: Some(true), @@ -674,4 +809,177 @@ mod tests { // Child's 'from' field should be preserved, not inherited from parent assert_eq!(result.from, Some("parent".to_string())); } + + #[test] + fn test_deserialize_simple_string_array_format() { + // Test that simple format (array of strings) is correctly converted to RpcConfig + let json = r#"{ + "network": "test-network", + "rpc_urls": ["https://rpc1.example.com", "https://rpc2.example.com"] + }"#; + + let config: NetworkConfigCommon = serde_json::from_str(json).unwrap(); + assert!(config.rpc_urls.is_some()); + let rpc_configs = config.rpc_urls.unwrap(); + assert_eq!(rpc_configs.len(), 2); + assert_eq!(rpc_configs[0].url, "https://rpc1.example.com"); + assert_eq!(rpc_configs[0].weight, crate::constants::DEFAULT_RPC_WEIGHT); + assert_eq!(rpc_configs[1].url, "https://rpc2.example.com"); + assert_eq!(rpc_configs[1].weight, crate::constants::DEFAULT_RPC_WEIGHT); + } + + #[test] + fn test_deserialize_extended_object_array_format() { + // Test that extended format (array of RpcConfig objects) works correctly + let json = r#"{ + "network": "test-network", + "rpc_urls": [ + {"url": "https://rpc1.example.com", "weight": 50}, + {"url": "https://rpc2.example.com", "weight": 100} + ] + }"#; + + let config: NetworkConfigCommon = serde_json::from_str(json).unwrap(); + assert!(config.rpc_urls.is_some()); + let rpc_configs = config.rpc_urls.unwrap(); + assert_eq!(rpc_configs.len(), 2); + assert_eq!(rpc_configs[0].url, "https://rpc1.example.com"); + assert_eq!(rpc_configs[0].weight, 50); + assert_eq!(rpc_configs[1].url, "https://rpc2.example.com"); + assert_eq!(rpc_configs[1].weight, 100); + } + + #[test] + fn test_deserialize_object_array_with_default_weight() { + // Test that RpcConfig objects without weight get default weight + let json = r#"{ + "network": "test-network", + "rpc_urls": [ + {"url": "https://rpc1.example.com"} + ] + }"#; + + let config: NetworkConfigCommon = serde_json::from_str(json).unwrap(); + assert!(config.rpc_urls.is_some()); + let rpc_configs = config.rpc_urls.unwrap(); + assert_eq!(rpc_configs.len(), 1); + assert_eq!(rpc_configs[0].url, "https://rpc1.example.com"); + assert_eq!(rpc_configs[0].weight, crate::constants::DEFAULT_RPC_WEIGHT); + } + + #[test] + fn test_serialize_preserves_weights() { + // Test that serialization preserves weights + use crate::models::RpcConfig; + let config = NetworkConfigCommon { + network: "test-network".to_string(), + from: None, + rpc_urls: Some(vec![ + RpcConfig::with_weight("https://rpc1.example.com".to_string(), 50).unwrap(), + RpcConfig::new("https://rpc2.example.com".to_string()), + ]), + explorer_urls: None, + average_blocktime_ms: None, + is_testnet: None, + tags: None, + }; + + let serialized = serde_json::to_string(&config).unwrap(); + let deserialized: NetworkConfigCommon = serde_json::from_str(&serialized).unwrap(); + + assert!(deserialized.rpc_urls.is_some()); + let rpc_configs = deserialized.rpc_urls.unwrap(); + assert_eq!(rpc_configs.len(), 2); + assert_eq!(rpc_configs[0].url, "https://rpc1.example.com"); + assert_eq!(rpc_configs[0].weight, 50); + assert_eq!(rpc_configs[1].url, "https://rpc2.example.com"); + assert_eq!(rpc_configs[1].weight, crate::constants::DEFAULT_RPC_WEIGHT); + } + + #[test] + fn test_roundtrip_simple_to_extended_format() { + // Test that simple format can be read and then serialized in extended format + let simple_json = r#"{ + "network": "test-network", + "rpc_urls": ["https://rpc1.example.com", "https://rpc2.example.com"] + }"#; + + let config: NetworkConfigCommon = serde_json::from_str(simple_json).unwrap(); + let serialized = serde_json::to_string(&config).unwrap(); + + // The serialized version should be in extended format (with weights) + assert!(serialized.contains("\"url\"")); + assert!(serialized.contains("\"weight\"")); + + // Deserialize again to verify it still works + let deserialized: NetworkConfigCommon = serde_json::from_str(&serialized).unwrap(); + assert!(deserialized.rpc_urls.is_some()); + let rpc_configs = deserialized.rpc_urls.unwrap(); + assert_eq!(rpc_configs.len(), 2); + assert_eq!(rpc_configs[0].url, "https://rpc1.example.com"); + assert_eq!(rpc_configs[1].url, "https://rpc2.example.com"); + } + + #[test] + fn test_merge_rpc_configs_override_behavior() { + // Test that child RPC configs completely override parent configs + use crate::models::RpcConfig; + let parent = NetworkConfigCommon { + network: "parent".to_string(), + from: None, + rpc_urls: Some(vec![ + RpcConfig::with_weight("https://rpc1.example.com".to_string(), 100).unwrap(), + RpcConfig::with_weight("https://rpc2.example.com".to_string(), 100).unwrap(), + ]), + explorer_urls: None, + average_blocktime_ms: None, + is_testnet: None, + tags: None, + }; + + let child = NetworkConfigCommon { + network: "child".to_string(), + from: Some("parent".to_string()), + // Child completely overrides parent's RPC URLs + rpc_urls: Some(vec![ + RpcConfig::with_weight("https://child-rpc1.example.com".to_string(), 50).unwrap(), + RpcConfig::with_weight("https://child-rpc2.example.com".to_string(), 75).unwrap(), + ]), + explorer_urls: None, + average_blocktime_ms: None, + is_testnet: None, + tags: None, + }; + + let result = child.merge_with_parent(&parent); + + // Should have only child's RPC configs (complete override) + assert!(result.rpc_urls.is_some()); + let rpc_configs = result.rpc_urls.unwrap(); + assert_eq!(rpc_configs.len(), 2); + + // Find each config by URL + let child_rpc1 = rpc_configs + .iter() + .find(|c| c.url == "https://child-rpc1.example.com") + .unwrap(); + let child_rpc2 = rpc_configs + .iter() + .find(|c| c.url == "https://child-rpc2.example.com") + .unwrap(); + + // Should have child's weights + assert_eq!(child_rpc1.weight, 50); + assert_eq!(child_rpc2.weight, 75); + + // Should not have any parent URLs + assert!(rpc_configs + .iter() + .find(|c| c.url == "https://rpc1.example.com") + .is_none()); + assert!(rpc_configs + .iter() + .find(|c| c.url == "https://rpc2.example.com") + .is_none()); + } } diff --git a/src/config/config_file/network/evm.rs b/src/config/config_file/network/evm.rs index b7801b823..e27cfbe51 100644 --- a/src/config/config_file/network/evm.rs +++ b/src/config/config_file/network/evm.rs @@ -166,7 +166,7 @@ impl EvmNetworkConfig { mod tests { use super::*; use crate::config::config_file::network::test_utils::*; - + use crate::models::RpcConfig; #[test] fn test_validate_success_complete_config() { let config = create_evm_network("ethereum-mainnet"); @@ -237,7 +237,7 @@ mod tests { #[test] fn test_validate_invalid_rpc_urls() { let mut config = create_evm_network("ethereum-mainnet"); - config.common.rpc_urls = Some(vec!["invalid-url".to_string()]); + config.common.rpc_urls = Some(vec![RpcConfig::new("invalid-url".to_string())]); let result = config.validate(); assert!(result.is_err()); @@ -298,7 +298,9 @@ mod tests { common: NetworkConfigCommon { network: "parent".to_string(), from: Some("parent".to_string()), - rpc_urls: Some(vec!["https://parent-rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://parent-rpc.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]), average_blocktime_ms: Some(10000), is_testnet: Some(true), @@ -319,7 +321,9 @@ mod tests { common: NetworkConfigCommon { network: "child".to_string(), from: Some("parent".to_string()), - rpc_urls: Some(vec!["https://child-rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://child-rpc.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://child-explorer.example.com".to_string()]), average_blocktime_ms: Some(15000), is_testnet: Some(false), @@ -343,7 +347,9 @@ mod tests { assert_eq!(result.common.from, Some("parent".to_string())); assert_eq!( result.common.rpc_urls, - Some(vec!["https://child-rpc.example.com".to_string()]) + Some(vec![RpcConfig::new( + "https://child-rpc.example.com".to_string() + )]) ); assert_eq!( result.common.explorer_urls, @@ -378,7 +384,9 @@ mod tests { common: NetworkConfigCommon { network: "parent".to_string(), from: None, - rpc_urls: Some(vec!["https://parent-rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://parent-rpc.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]), average_blocktime_ms: Some(10000), is_testnet: Some(true), @@ -404,7 +412,9 @@ mod tests { assert_eq!(result.common.from, Some("ethereum-mainnet".to_string())); assert_eq!( result.common.rpc_urls, - Some(vec!["https://parent-rpc.example.com".to_string()]) + Some(vec![RpcConfig::new( + "https://parent-rpc.example.com".to_string() + )]) ); assert_eq!( result.common.explorer_urls, @@ -433,7 +443,9 @@ mod tests { common: NetworkConfigCommon { network: "parent".to_string(), from: None, - rpc_urls: Some(vec!["https://parent-rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://parent-rpc.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]), average_blocktime_ms: Some(10000), is_testnet: Some(true), @@ -454,7 +466,9 @@ mod tests { common: NetworkConfigCommon { network: "child".to_string(), from: Some("parent".to_string()), - rpc_urls: Some(vec!["https://child-rpc.example.com".to_string()]), // Override + rpc_urls: Some(vec![RpcConfig::new( + "https://child-rpc.example.com".to_string(), + )]), // Override explorer_urls: Some(vec!["https://child-explorer.example.com".to_string()]), // Override average_blocktime_ms: None, // Inherit is_testnet: Some(false), // Override @@ -476,7 +490,9 @@ mod tests { assert_eq!(result.common.network, "child"); assert_eq!( result.common.rpc_urls, - Some(vec!["https://child-rpc.example.com".to_string()]) + Some(vec![RpcConfig::new( + "https://child-rpc.example.com".to_string() + )]) ); // Overridden assert_eq!( result.common.explorer_urls, @@ -570,7 +586,7 @@ mod tests { common: NetworkConfigCommon { network: "parent".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]), explorer_urls: Some(vec!["https://explorer.example.com".to_string()]), average_blocktime_ms: Some(12000), is_testnet: Some(false), @@ -652,7 +668,9 @@ mod tests { common: NetworkConfigCommon { network: "parent".to_string(), from: Some("grandparent".to_string()), - rpc_urls: Some(vec!["https://parent.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://parent.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://parent-explorer.example.com".to_string()]), average_blocktime_ms: Some(10000), is_testnet: Some(true), @@ -724,7 +742,7 @@ mod tests { common: NetworkConfigCommon { network: "parent".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]), explorer_urls: Some(vec!["https://explorer.example.com".to_string()]), average_blocktime_ms: Some(12000), is_testnet: Some(false), diff --git a/src/config/config_file/network/inheritance.rs b/src/config/config_file/network/inheritance.rs index 0c9c2c2ed..b4455eb2e 100644 --- a/src/config/config_file/network/inheritance.rs +++ b/src/config/config_file/network/inheritance.rs @@ -533,7 +533,9 @@ mod tests { common: NetworkConfigCommon { network: "child".to_string(), from: Some("parent".to_string()), - rpc_urls: Some(vec!["https://custom-rpc.example.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://custom-rpc.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://custom-explorer.example.com".to_string()]), average_blocktime_ms: None, is_testnet: None, @@ -553,7 +555,9 @@ mod tests { assert_eq!(resolved.common.network, "child"); assert_eq!( resolved.common.rpc_urls, - Some(vec!["https://custom-rpc.example.com".to_string()]) + Some(vec![crate::models::RpcConfig::new( + "https://custom-rpc.example.com".to_string() + )]) ); // From child assert_eq!( resolved.common.explorer_urls, @@ -662,8 +666,8 @@ mod tests { network: "parent".to_string(), from: None, rpc_urls: Some(vec![ - "https://parent-rpc1.example.com".to_string(), - "https://parent-rpc2.example.com".to_string(), + crate::models::RpcConfig::new("https://parent-rpc1.example.com".to_string()), + crate::models::RpcConfig::new("https://parent-rpc2.example.com".to_string()), ]), explorer_urls: Some(vec![ "https://parent-explorer1.example.com".to_string(), @@ -689,7 +693,9 @@ mod tests { common: NetworkConfigCommon { network: "child".to_string(), from: Some("parent".to_string()), - rpc_urls: Some(vec!["https://child-rpc.example.com".to_string()]), // Override + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://child-rpc.example.com".to_string(), + )]), // Override explorer_urls: Some(vec!["https://child-explorer.example.com".to_string()]), // Override average_blocktime_ms: None, // Inherit is_testnet: Some(false), // Override @@ -707,10 +713,13 @@ mod tests { let resolved = result.unwrap(); assert_eq!(resolved.common.network, "child"); + // Child's RPC URLs are merged with parent's (parent first, then child) assert_eq!( resolved.common.rpc_urls, - Some(vec!["https://child-rpc.example.com".to_string()]) - ); // Child override + Some(vec![crate::models::RpcConfig::new( + "https://child-rpc.example.com".to_string() + )]) + ); assert_eq!( resolved.common.explorer_urls, Some(vec!["https://child-explorer.example.com".to_string()]) diff --git a/src/config/config_file/network/mod.rs b/src/config/config_file/network/mod.rs index a6099d3e8..7a2147466 100644 --- a/src/config/config_file/network/mod.rs +++ b/src/config/config_file/network/mod.rs @@ -454,7 +454,8 @@ mod tests { "from": "parent-network", "chain_id": 1337, "required_confirmations": 1, - "symbol": "ETH" + "symbol": "ETH", + "rpc_urls": ["https://rpc.example.com"] }"#; let config: NetworkFileConfig = serde_json::from_str(json).unwrap(); @@ -548,7 +549,9 @@ mod tests { fn test_validation_error_propagation() { let mut config = create_evm_network_wrapped("test-evm"); if let NetworkFileConfig::Evm(ref mut evm_config) = config { - evm_config.common.rpc_urls = Some(vec!["invalid-url".to_string()]); + evm_config.common.rpc_urls = Some(vec![crate::models::RpcConfig::new( + "invalid-url".to_string(), + )]); } let result = config.validate(); diff --git a/src/config/config_file/network/test_utils.rs b/src/config/config_file/network/test_utils.rs index 1ac0492f3..81f127bd7 100644 --- a/src/config/config_file/network/test_utils.rs +++ b/src/config/config_file/network/test_utils.rs @@ -15,10 +15,11 @@ use tempfile::TempDir; /// Creates a default valid NetworkConfigCommon for testing. pub fn create_network_common(network: &str) -> NetworkConfigCommon { + use crate::models::RpcConfig; NetworkConfigCommon { network: network.to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]), explorer_urls: Some(vec!["https://explorer.example.com".to_string()]), average_blocktime_ms: Some(12000), is_testnet: Some(true), @@ -97,11 +98,15 @@ pub fn create_invalid_evm_network(network: &str) -> EvmNetworkConfig { /// Creates a default valid Solana network configuration. pub fn create_solana_network(network: &str) -> SolanaNetworkConfig { + use crate::models::RpcConfig; SolanaNetworkConfig { common: NetworkConfigCommon { network: network.to_string(), from: None, - rpc_urls: Some(vec![format!("https://api.{}.solana.com", network)]), + rpc_urls: Some(vec![RpcConfig::new(format!( + "https://api.{}.solana.com", + network + ))]), explorer_urls: Some(vec!["https://explorer.example.com".to_string()]), average_blocktime_ms: Some(400), is_testnet: Some(true), @@ -112,11 +117,15 @@ pub fn create_solana_network(network: &str) -> SolanaNetworkConfig { /// Creates a Solana network configuration with inheritance. pub fn create_solana_network_with_parent(network: &str, parent: &str) -> SolanaNetworkConfig { + use crate::models::RpcConfig; SolanaNetworkConfig { common: NetworkConfigCommon { network: network.to_string(), from: Some(parent.to_string()), - rpc_urls: Some(vec![format!("https://api.{}.solana.com", network)]), // Override parent's RPC URLs + rpc_urls: Some(vec![RpcConfig::new(format!( + "https://api.{}.solana.com", + network + ))]), // Override parent's RPC URLs explorer_urls: Some(vec!["https://explorer.example.com".to_string()]), average_blocktime_ms: Some(500), // Override parent's blocktime is_testnet: None, // Will inherit from parent @@ -127,11 +136,15 @@ pub fn create_solana_network_with_parent(network: &str, parent: &str) -> SolanaN /// Creates a default valid Stellar network configuration. pub fn create_stellar_network(network: &str) -> StellarNetworkConfig { + use crate::models::RpcConfig; StellarNetworkConfig { common: NetworkConfigCommon { network: network.to_string(), from: None, - rpc_urls: Some(vec![format!("https://horizon.{}.stellar.org", network)]), + rpc_urls: Some(vec![RpcConfig::new(format!( + "https://horizon.{}.stellar.org", + network + ))]), explorer_urls: Some(vec!["https://explorer.example.com".to_string()]), average_blocktime_ms: Some(5000), is_testnet: Some(true), @@ -144,11 +157,15 @@ pub fn create_stellar_network(network: &str) -> StellarNetworkConfig { /// Creates a Stellar network configuration with inheritance. pub fn create_stellar_network_with_parent(network: &str, parent: &str) -> StellarNetworkConfig { + use crate::models::RpcConfig; StellarNetworkConfig { common: NetworkConfigCommon { network: network.to_string(), from: Some(parent.to_string()), - rpc_urls: Some(vec![format!("https://horizon.{}.stellar.org", network)]), // Override parent's RPC URLs + rpc_urls: Some(vec![RpcConfig::new(format!( + "https://horizon.{}.stellar.org", + network + ))]), // Override parent's RPC URLs explorer_urls: Some(vec!["https://explorer.example.com".to_string()]), average_blocktime_ms: Some(6000), // Override parent's blocktime is_testnet: None, // Will inherit from parent diff --git a/src/config/server_config.rs b/src/config/server_config.rs index 78444a13e..17f333827 100644 --- a/src/config/server_config.rs +++ b/src/config/server_config.rs @@ -2,7 +2,13 @@ use std::{env, str::FromStr}; use strum::Display; -use crate::{constants::MINIMUM_SECRET_VALUE_LENGTH, models::SecretString}; +use crate::{ + constants::{ + DEFAULT_PROVIDER_FAILURE_EXPIRATION_SECS, DEFAULT_PROVIDER_FAILURE_THRESHOLD, + DEFAULT_PROVIDER_PAUSE_DURATION_SECS, MINIMUM_SECRET_VALUE_LENGTH, + }, + models::SecretString, +}; #[derive(Debug, Clone, PartialEq, Eq, Display)] pub enum RepositoryStorageType { @@ -56,6 +62,12 @@ pub struct ServerConfig { pub provider_retry_max_delay_ms: u64, /// Maximum number of failovers (switching to different providers). pub provider_max_failovers: u8, + /// Number of consecutive failures before pausing a provider. + pub provider_failure_threshold: u32, + /// Duration in seconds to pause a provider after reaching failure threshold. + pub provider_pause_duration_secs: u64, + /// Duration in seconds after which failures are considered stale and reset. + pub provider_failure_expiration_secs: u64, /// The type of repository storage to use. pub repository_storage_type: RepositoryStorageType, /// Flag to force config file processing. @@ -86,6 +98,9 @@ impl ServerConfig { /// - `PROVIDER_RETRY_BASE_DELAY_MS` defaults to `100`. /// - `PROVIDER_RETRY_MAX_DELAY_MS` defaults to `2000`. /// - `PROVIDER_MAX_FAILOVERS` defaults to `3`. + /// - `PROVIDER_FAILURE_THRESHOLD` defaults to `3`. + /// - `PROVIDER_PAUSE_DURATION_SECS` defaults to `60` (1 minute). + /// - `PROVIDER_FAILURE_EXPIRATION_SECS` defaults to `60` (1 minute). /// - `REPOSITORY_STORAGE_TYPE` defaults to `"in_memory"`. /// - `TRANSACTION_EXPIRATION_HOURS` defaults to `4`. pub fn from_env() -> Self { @@ -106,6 +121,9 @@ impl ServerConfig { provider_retry_base_delay_ms: Self::get_provider_retry_base_delay_ms(), provider_retry_max_delay_ms: Self::get_provider_retry_max_delay_ms(), provider_max_failovers: Self::get_provider_max_failovers(), + provider_failure_threshold: Self::get_provider_failure_threshold(), + provider_pause_duration_secs: Self::get_provider_pause_duration_secs(), + provider_failure_expiration_secs: Self::get_provider_failure_expiration_secs(), repository_storage_type: Self::get_repository_storage_type(), reset_storage_on_start: Self::get_reset_storage_on_start(), storage_encryption_key: Self::get_storage_encryption_key(), @@ -261,6 +279,38 @@ impl ServerConfig { .unwrap_or(3) } + /// Gets the provider failure threshold from environment variable or default + pub fn get_provider_failure_threshold() -> u32 { + env::var("PROVIDER_FAILURE_THRESHOLD") + .or_else(|_| env::var("RPC_FAILURE_THRESHOLD")) // Support legacy env var + .unwrap_or_else(|_| DEFAULT_PROVIDER_FAILURE_THRESHOLD.to_string()) + .parse() + .unwrap_or(DEFAULT_PROVIDER_FAILURE_THRESHOLD) + } + + /// Gets the provider pause duration in seconds from environment variable or default + /// + /// Defaults to 60 seconds (1 minute) for faster recovery while still providing + /// a reasonable cooldown period for failed providers. + pub fn get_provider_pause_duration_secs() -> u64 { + env::var("PROVIDER_PAUSE_DURATION_SECS") + .or_else(|_| env::var("RPC_PAUSE_DURATION_SECS")) // Support legacy env var + .unwrap_or_else(|_| DEFAULT_PROVIDER_PAUSE_DURATION_SECS.to_string()) + .parse() + .unwrap_or(DEFAULT_PROVIDER_PAUSE_DURATION_SECS) + } + + /// Gets the provider failure expiration duration in seconds from environment variable or default + /// + /// Defaults to 60 seconds (1 minute). Failures older than this are considered stale + /// and reset, allowing providers to naturally recover over time. + pub fn get_provider_failure_expiration_secs() -> u64 { + env::var("PROVIDER_FAILURE_EXPIRATION_SECS") + .unwrap_or_else(|_| DEFAULT_PROVIDER_FAILURE_EXPIRATION_SECS.to_string()) + .parse() + .unwrap_or(DEFAULT_PROVIDER_FAILURE_EXPIRATION_SECS) + } + /// Gets the repository storage type from environment variable or default pub fn get_repository_storage_type() -> RepositoryStorageType { env::var("REPOSITORY_STORAGE_TYPE") @@ -373,6 +423,8 @@ mod tests { assert_eq!(config.provider_retry_base_delay_ms, 100); assert_eq!(config.provider_retry_max_delay_ms, 2000); assert_eq!(config.provider_max_failovers, 3); + assert_eq!(config.provider_failure_threshold, 3); + assert_eq!(config.provider_pause_duration_secs, 60); assert_eq!( config.repository_storage_type, RepositoryStorageType::InMemory diff --git a/src/constants/relayer.rs b/src/constants/relayer.rs index 53979797f..afca8b8f8 100644 --- a/src/constants/relayer.rs +++ b/src/constants/relayer.rs @@ -30,3 +30,10 @@ pub const STELLAR_SMALLEST_UNIT_NAME: &str = "stroop"; pub const SOLANA_SMALLEST_UNIT_NAME: &str = "lamport"; pub const DEFAULT_RPC_WEIGHT: u8 = 100; + +// === Provider Health Defaults === +pub const DEFAULT_PROVIDER_FAILURE_THRESHOLD: u32 = 3; +pub const DEFAULT_PROVIDER_PAUSE_DURATION_SECS: u64 = 60; // 1 minute +/// Duration in seconds after which failures are considered stale and reset. +/// This allows providers to naturally recover over time even without explicit success calls. +pub const DEFAULT_PROVIDER_FAILURE_EXPIRATION_SECS: u64 = 60; // 1 minute diff --git a/src/domain/relayer/evm/evm_relayer.rs b/src/domain/relayer/evm/evm_relayer.rs index 22296a819..14b9bc4d0 100644 --- a/src/domain/relayer/evm/evm_relayer.rs +++ b/src/domain/relayer/evm/evm_relayer.rs @@ -56,9 +56,8 @@ use async_trait::async_trait; use eyre::Result; use tracing::{debug, info, warn}; -use super::{ - create_error_response, create_success_response, map_provider_error, EvmTransactionValidator, -}; +use super::{create_error_response, create_success_response, EvmTransactionValidator}; +use crate::utils::{map_provider_error, sanitize_error_description}; #[allow(dead_code)] pub struct EvmRelayer @@ -494,12 +493,18 @@ where match self.provider.raw_request_dyn(&method, params_json).await { Ok(result_value) => Ok(create_success_response(request.id, result_value)), Err(provider_error) => { + // Log the full error internally for debugging + tracing::error!( + error = %provider_error, + "RPC provider error occurred" + ); let (error_code, error_message) = map_provider_error(&provider_error); + let sanitized_description = sanitize_error_description(&provider_error); Ok(create_error_response( request.id, error_code, error_message, - &provider_error.to_string(), + &sanitized_description, )) } } @@ -626,6 +631,7 @@ where #[cfg(test)] mod tests { use super::*; + use crate::models::RpcConfig; use crate::{ config::{EvmNetworkConfig, NetworkConfigCommon}, jobs::MockJobProducerTrait, @@ -656,7 +662,9 @@ mod tests { fn create_test_evm_network() -> EvmNetwork { EvmNetwork { network: "mainnet".to_string(), - rpc_urls: vec!["https://mainnet.infura.io/v3/YOUR_INFURA_API_KEY".to_string()], + rpc_urls: vec![RpcConfig::new( + "https://mainnet.infura.io/v3/YOUR_INFURA_API_KEY".to_string(), + )], explorer_urls: None, average_blocktime_ms: 12000, is_testnet: false, @@ -674,9 +682,9 @@ mod tests { common: NetworkConfigCommon { network: "mainnet".to_string(), from: None, - rpc_urls: Some(vec![ - "https://mainnet.infura.io/v3/YOUR_INFURA_API_KEY".to_string() - ]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://mainnet.infura.io/v3/YOUR_INFURA_API_KEY".to_string(), + )]), explorer_urls: None, average_blocktime_ms: Some(12000), is_testnet: Some(false), diff --git a/src/domain/relayer/evm/rpc_utils.rs b/src/domain/relayer/evm/rpc_utils.rs index 55c501349..579a133ee 100644 --- a/src/domain/relayer/evm/rpc_utils.rs +++ b/src/domain/relayer/evm/rpc_utils.rs @@ -10,9 +10,8 @@ //! - Handle EVM-specific result types and error formatting use crate::{ - models::{EvmRpcResult, NetworkRpcResult, OpenZeppelinErrorCodes, RpcErrorCodes}, + models::{EvmRpcResult, NetworkRpcResult}, models::{JsonRpcError, JsonRpcId, JsonRpcResponse}, - services::provider::ProviderError, }; use serde_json; @@ -70,55 +69,14 @@ pub fn create_success_response( } } -/// Maps provider errors to appropriate JSON-RPC error codes and messages. -/// -/// This function translates internal provider errors into standardized -/// JSON-RPC error codes and user-friendly messages that can be returned -/// to clients. It follows JSON-RPC 2.0 specification for standard errors -/// and uses OpenZeppelin-specific codes for extended functionality. -/// -/// # Arguments -/// -/// * `error` - A reference to the provider error to be mapped -/// -/// # Returns -/// -/// Returns a tuple containing: -/// - `i32` - The error code (following JSON-RPC 2.0 and OpenZeppelin conventions) -/// - `&'static str` - A static string describing the error type -/// -/// # Error Code Mappings -/// -/// - `InvalidAddress` → -32602 ("Invalid params") -/// - `NetworkConfiguration` → -33004 ("Network configuration error") -/// - `Timeout` → -33000 ("Request timeout") -/// - `RateLimited` → -33001 ("Rate limited") -/// - `BadGateway` → -33002 ("Bad gateway") -/// - `RequestError` → -33003 ("Request error") -/// - `Other` and unknown errors → -32603 ("Internal error") -pub fn map_provider_error(error: &ProviderError) -> (i32, &'static str) { - match error { - ProviderError::InvalidAddress(_) => (RpcErrorCodes::INVALID_PARAMS, "Invalid params"), - ProviderError::NetworkConfiguration(_) => ( - OpenZeppelinErrorCodes::NETWORK_CONFIGURATION, - "Network configuration error", - ), - ProviderError::Timeout => (OpenZeppelinErrorCodes::TIMEOUT, "Request timeout"), - ProviderError::RateLimited => (OpenZeppelinErrorCodes::RATE_LIMITED, "Rate limited"), - ProviderError::BadGateway => (OpenZeppelinErrorCodes::BAD_GATEWAY, "Bad gateway"), - ProviderError::RequestError { .. } => { - (OpenZeppelinErrorCodes::REQUEST_ERROR, "Request error") - } - ProviderError::Other(_) => (RpcErrorCodes::INTERNAL_ERROR, "Internal error"), - _ => (RpcErrorCodes::INTERNAL_ERROR, "Internal error"), - } -} +// Re-export error sanitization functions for backward compatibility +// These functions have been moved to src/utils/error_sanitization.rs +pub use crate::utils::{map_provider_error, sanitize_error_description}; #[cfg(test)] mod tests { use super::*; use crate::models::{OpenZeppelinErrorCodes, RpcErrorCodes}; - use crate::services::provider::{rpc_selector::RpcSelectorError, SolanaProviderError}; use serde_json::json; #[test] @@ -442,188 +400,12 @@ mod tests { } } - #[test] - fn test_map_provider_error_invalid_address() { - let error = ProviderError::InvalidAddress("invalid address".to_string()); - let (code, _message) = map_provider_error(&error); - - assert_eq!(code, RpcErrorCodes::INVALID_PARAMS); - } - - #[test] - fn test_map_provider_error_invalid_address_empty() { - let error = ProviderError::InvalidAddress("".to_string()); - let (code, _message) = map_provider_error(&error); - - assert_eq!(code, RpcErrorCodes::INVALID_PARAMS); - } - - #[test] - fn test_map_provider_error_network_configuration() { - let error = ProviderError::NetworkConfiguration("network config error".to_string()); - let (code, _message) = map_provider_error(&error); - - assert_eq!(code, OpenZeppelinErrorCodes::NETWORK_CONFIGURATION); - } - - #[test] - fn test_map_provider_error_network_configuration_empty() { - let error = ProviderError::NetworkConfiguration("".to_string()); - let (code, _message) = map_provider_error(&error); - - assert_eq!(code, OpenZeppelinErrorCodes::NETWORK_CONFIGURATION); - } - - #[test] - fn test_map_provider_error_timeout() { - let error = ProviderError::Timeout; - let (code, _message) = map_provider_error(&error); - - assert_eq!(code, OpenZeppelinErrorCodes::TIMEOUT); - } - - #[test] - fn test_map_provider_error_rate_limited() { - let error = ProviderError::RateLimited; - let (code, _message) = map_provider_error(&error); - - assert_eq!(code, OpenZeppelinErrorCodes::RATE_LIMITED); - } - - #[test] - fn test_map_provider_error_bad_gateway() { - let error = ProviderError::BadGateway; - let (code, _message) = map_provider_error(&error); - - assert_eq!(code, OpenZeppelinErrorCodes::BAD_GATEWAY); - } - - #[test] - fn test_map_provider_error_request_error_400() { - let error = ProviderError::RequestError { - error: "Bad request".to_string(), - status_code: 400, - }; - let (code, _message) = map_provider_error(&error); - - assert_eq!(code, OpenZeppelinErrorCodes::REQUEST_ERROR); - } - - #[test] - fn test_map_provider_error_request_error_500() { - let error = ProviderError::RequestError { - error: "Internal server error".to_string(), - status_code: 500, - }; - let (code, _message) = map_provider_error(&error); - - assert_eq!(code, OpenZeppelinErrorCodes::REQUEST_ERROR); - } - - #[test] - fn test_map_provider_error_request_error_empty_message() { - let error = ProviderError::RequestError { - error: "".to_string(), - status_code: 404, - }; - let (code, _message) = map_provider_error(&error); - - assert_eq!(code, OpenZeppelinErrorCodes::REQUEST_ERROR); - } - - #[test] - fn test_map_provider_error_request_error_zero_status() { - let error = ProviderError::RequestError { - error: "No status".to_string(), - status_code: 0, - }; - let (code, _message) = map_provider_error(&error); - - assert_eq!(code, OpenZeppelinErrorCodes::REQUEST_ERROR); - } - - #[test] - fn test_map_provider_error_other() { - let error = ProviderError::Other("some other error".to_string()); - let (code, _message) = map_provider_error(&error); - - assert_eq!(code, RpcErrorCodes::INTERNAL_ERROR); - } - - #[test] - fn test_map_provider_error_other_empty() { - let error = ProviderError::Other("".to_string()); - let (code, _message) = map_provider_error(&error); - - assert_eq!(code, RpcErrorCodes::INTERNAL_ERROR); - } - - #[test] - fn test_map_provider_error_solana_rpc_error() { - let solana_error = SolanaProviderError::RpcError("Solana RPC failed".to_string()); - let error = ProviderError::SolanaRpcError(solana_error); - let (code, _message) = map_provider_error(&error); - - // The SolanaRpcError variant should be caught by the wildcard pattern - assert_eq!(code, RpcErrorCodes::INTERNAL_ERROR); - } - - #[test] - fn test_map_provider_error_solana_invalid_address() { - let solana_error = - SolanaProviderError::InvalidAddress("Invalid Solana address".to_string()); - let error = ProviderError::SolanaRpcError(solana_error); - let (code, _message) = map_provider_error(&error); - - // The SolanaRpcError variant should be caught by the wildcard pattern - assert_eq!(code, RpcErrorCodes::INTERNAL_ERROR); - } - - #[test] - fn test_map_provider_error_solana_selector_error() { - let selector_error = RpcSelectorError::NoProviders; - let solana_error = SolanaProviderError::SelectorError(selector_error); - let error = ProviderError::SolanaRpcError(solana_error); - let (code, _message) = map_provider_error(&error); - - // The SolanaRpcError variant should be caught by the wildcard pattern - assert_eq!(code, RpcErrorCodes::INTERNAL_ERROR); - } - - #[test] - fn test_map_provider_error_solana_network_configuration() { - let solana_error = - SolanaProviderError::NetworkConfiguration("Solana network config error".to_string()); - let error = ProviderError::SolanaRpcError(solana_error); - let (code, _message) = map_provider_error(&error); - - // The SolanaRpcError variant should be caught by the wildcard pattern - assert_eq!(code, RpcErrorCodes::INTERNAL_ERROR); - } - - #[test] - fn test_map_provider_error_wildcard_pattern() { - // This test ensures the wildcard pattern works by testing all variations - // that should fall through to the default case - let test_cases = vec![ - ProviderError::SolanaRpcError(SolanaProviderError::RpcError("test".to_string())), - ProviderError::SolanaRpcError(SolanaProviderError::InvalidAddress("test".to_string())), - ProviderError::SolanaRpcError(SolanaProviderError::NetworkConfiguration( - "test".to_string(), - )), - ProviderError::SolanaRpcError(SolanaProviderError::SelectorError( - RpcSelectorError::NoProviders, - )), - ]; - - for error in test_cases { - let (code, _message) = map_provider_error(&error); - assert_eq!(code, RpcErrorCodes::INTERNAL_ERROR); - } - } - #[test] fn test_integration_error_response_with_mapped_provider_error() { + use crate::models::RpcErrorCodes; + use crate::services::provider::ProviderError; + use crate::utils::map_provider_error; + let provider_error = ProviderError::InvalidAddress("0xinvalid".to_string()); let (error_code, error_message) = map_provider_error(&provider_error); @@ -646,6 +428,10 @@ mod tests { #[test] fn test_integration_all_provider_errors_to_responses() { + use crate::models::{OpenZeppelinErrorCodes, RpcErrorCodes}; + use crate::services::provider::ProviderError; + use crate::utils::map_provider_error; + let test_cases = vec![ ( ProviderError::InvalidAddress("test".to_string()), diff --git a/src/domain/relayer/solana/dex/jupiter_swap.rs b/src/domain/relayer/solana/dex/jupiter_swap.rs index d9c6c2c91..36fc5765b 100644 --- a/src/domain/relayer/solana/dex/jupiter_swap.rs +++ b/src/domain/relayer/solana/dex/jupiter_swap.rs @@ -201,8 +201,8 @@ mod tests { output_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string()), }, percent: 1, }], diff --git a/src/domain/relayer/solana/dex/jupiter_ultra.rs b/src/domain/relayer/solana/dex/jupiter_ultra.rs index e622c1fe2..6c1d26611 100644 --- a/src/domain/relayer/solana/dex/jupiter_ultra.rs +++ b/src/domain/relayer/solana/dex/jupiter_ultra.rs @@ -157,8 +157,8 @@ mod tests { output_mint: output_mint.to_string(), in_amount: amount.to_string(), out_amount: out_amount.to_string(), - fee_amount: "1000".to_string(), - fee_mint: input_mint.to_string(), + fee_amount: Some("1000".to_string()), + fee_mint: Some(input_mint.to_string()), }, }], prioritization_fee_lamports: 5000, diff --git a/src/domain/relayer/solana/rpc/methods/fee_estimate.rs b/src/domain/relayer/solana/rpc/methods/fee_estimate.rs index bfc141dc3..047f33ff2 100644 --- a/src/domain/relayer/solana/rpc/methods/fee_estimate.rs +++ b/src/domain/relayer/solana/rpc/methods/fee_estimate.rs @@ -296,9 +296,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], @@ -464,9 +465,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], @@ -568,9 +570,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], @@ -667,9 +670,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], diff --git a/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs b/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs index 68f839ea0..8c08ccf4d 100644 --- a/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs +++ b/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs @@ -479,9 +479,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], diff --git a/src/domain/relayer/solana/rpc/methods/sign_and_send_transaction.rs b/src/domain/relayer/solana/rpc/methods/sign_and_send_transaction.rs index 2f15baf1d..dc1353850 100644 --- a/src/domain/relayer/solana/rpc/methods/sign_and_send_transaction.rs +++ b/src/domain/relayer/solana/rpc/methods/sign_and_send_transaction.rs @@ -451,9 +451,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], @@ -626,9 +627,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], @@ -804,9 +806,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], diff --git a/src/domain/relayer/solana/rpc/methods/sign_transaction.rs b/src/domain/relayer/solana/rpc/methods/sign_transaction.rs index 38b898c84..bf9a2ed84 100644 --- a/src/domain/relayer/solana/rpc/methods/sign_transaction.rs +++ b/src/domain/relayer/solana/rpc/methods/sign_transaction.rs @@ -334,9 +334,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], @@ -566,9 +567,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], diff --git a/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs b/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs index 2f2a24c7f..77ac121a2 100644 --- a/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs +++ b/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs @@ -537,9 +537,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], @@ -699,9 +700,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], diff --git a/src/domain/relayer/solana/rpc/methods/utils.rs b/src/domain/relayer/solana/rpc/methods/utils.rs index 92a472ee3..01359af61 100644 --- a/src/domain/relayer/solana/rpc/methods/utils.rs +++ b/src/domain/relayer/solana/rpc/methods/utils.rs @@ -1138,9 +1138,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], @@ -1817,9 +1818,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], @@ -2567,9 +2569,10 @@ mod tests { .to_string(), in_amount: "1000000".to_string(), out_amount: "999984".to_string(), - fee_amount: "10".to_string(), - fee_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" - .to_string(), + fee_amount: Some("10".to_string()), + fee_mint: Some( + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), + ), }, percent: 1, }], @@ -2911,8 +2914,8 @@ mod tests { .to_string(), in_amount: "5000".to_string(), out_amount: "5000".to_string(), - fee_amount: "25".to_string(), - fee_mint: WRAPPED_SOL_MINT.to_string(), + fee_amount: Some("25".to_string()), + fee_mint: Some(WRAPPED_SOL_MINT.to_string()), }, percent: 100, }], diff --git a/src/domain/relayer/solana/solana_relayer.rs b/src/domain/relayer/solana/solana_relayer.rs index f6f95b0ae..4b417fc50 100644 --- a/src/domain/relayer/solana/solana_relayer.rs +++ b/src/domain/relayer/solana/solana_relayer.rs @@ -1149,8 +1149,9 @@ mod tests { jobs::MockJobProducerTrait, models::{ EncodedSerializedTransaction, JsonRpcId, NetworkConfigData, NetworkRepoModel, - RelayerSolanaSwapConfig, SolanaAllowedTokensSwapConfig, SolanaFeeEstimateRequestParams, - SolanaGetFeaturesEnabledRequestParams, SolanaRpcResult, SolanaSwapStrategy, + RelayerSolanaSwapConfig, RpcConfig, SolanaAllowedTokensSwapConfig, + SolanaFeeEstimateRequestParams, SolanaGetFeaturesEnabledRequestParams, SolanaRpcResult, + SolanaSwapStrategy, }, repositories::{MockNetworkRepository, MockRelayerRepository, MockTransactionRepository}, services::{ @@ -1236,7 +1237,9 @@ mod tests { common: NetworkConfigCommon { network: "devnet".to_string(), from: None, - rpc_urls: Some(vec!["https://api.devnet.solana.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://api.devnet.solana.com".to_string(), + )]), explorer_urls: None, average_blocktime_ms: Some(400), is_testnet: Some(true), @@ -1514,8 +1517,8 @@ mod tests { output_mint: WRAPPED_SOL_MINT.to_string(), in_amount: "1000".to_string(), out_amount: "1000".to_string(), - fee_amount: "0".to_string(), - fee_mint: "mock_fee_mint".to_string(), + fee_amount: Some("0".to_string()), + fee_mint: Some("mock_fee_mint".to_string()), }, }], slippage_bps: 0, @@ -1677,8 +1680,8 @@ mod tests { output_mint: WRAPPED_SOL_MINT.to_string(), in_amount: "1000".to_string(), out_amount: "1000".to_string(), - fee_amount: "0".to_string(), - fee_mint: "mock_fee_mint".to_string(), + fee_amount: Some("0".to_string()), + fee_mint: Some("mock_fee_mint".to_string()), }, }], prioritization_fee_lamports: 0, diff --git a/src/domain/relayer/stellar/gas_abstraction.rs b/src/domain/relayer/stellar/gas_abstraction.rs index 074716645..1a0ea5cbe 100644 --- a/src/domain/relayer/stellar/gas_abstraction.rs +++ b/src/domain/relayer/stellar/gas_abstraction.rs @@ -447,7 +447,7 @@ mod tests { jobs::MockJobProducerTrait, models::{ transaction::stellar::OperationSpec, AssetSpec, NetworkConfigData, NetworkRepoModel, - NetworkType, RelayerNetworkPolicy, RelayerRepoModel, RelayerStellarPolicy, + NetworkType, RelayerNetworkPolicy, RelayerRepoModel, RelayerStellarPolicy, RpcConfig, SponsoredTransactionBuildRequest, SponsoredTransactionQuoteRequest, }, repositories::{ @@ -566,7 +566,9 @@ mod tests { common: NetworkConfigCommon { network: "testnet".to_string(), from: None, - rpc_urls: Some(vec!["https://horizon-testnet.stellar.org".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://horizon-testnet.stellar.org".to_string(), + )]), explorer_urls: None, average_blocktime_ms: Some(5000), is_testnet: Some(true), diff --git a/src/domain/relayer/stellar/stellar_relayer.rs b/src/domain/relayer/stellar/stellar_relayer.rs index 3d52125ec..0792499a9 100644 --- a/src/domain/relayer/stellar/stellar_relayer.rs +++ b/src/domain/relayer/stellar/stellar_relayer.rs @@ -1,7 +1,7 @@ use crate::constants::get_stellar_sponsored_transaction_validity_duration; -use crate::domain::map_provider_error; use crate::domain::relayer::evm::create_error_response; use crate::services::stellar_dex::StellarDexService; +use crate::utils::{map_provider_error, sanitize_error_description}; /// This module defines the `StellarRelayer` struct and its associated functionality for /// interacting with Stellar networks. The `StellarRelayer` is responsible for managing /// transactions, synchronizing sequence numbers, and ensuring the relayer's state is @@ -575,12 +575,18 @@ where { Ok(result_value) => Ok(create_success_response(id.clone(), result_value)), Err(provider_error) => { + // Log the full error internally for debugging + tracing::error!( + error = %provider_error, + "RPC provider error occurred" + ); let (error_code, error_message) = map_provider_error(&provider_error); + let sanitized_description = sanitize_error_description(&provider_error); Ok(create_error_response( id.clone(), error_code, error_message, - &provider_error.to_string(), + &sanitized_description, )) } } @@ -794,7 +800,7 @@ mod tests { jobs::MockJobProducerTrait, models::{ NetworkConfigData, NetworkRepoModel, NetworkType, RelayerNetworkPolicy, - RelayerRepoModel, RelayerStellarPolicy, SignerError, + RelayerRepoModel, RelayerStellarPolicy, RpcConfig, SignerError, }, repositories::{ InMemoryNetworkRepository, MockRelayerRepository, MockTransactionRepository, @@ -866,7 +872,9 @@ mod tests { common: NetworkConfigCommon { network: "testnet".to_string(), from: None, - rpc_urls: Some(vec!["https://horizon-testnet.stellar.org".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://horizon-testnet.stellar.org".to_string(), + )]), explorer_urls: None, average_blocktime_ms: Some(5000), is_testnet: Some(true), @@ -1355,7 +1363,9 @@ mod tests { common: NetworkConfigCommon { network: "mainnet".to_string(), from: None, - rpc_urls: Some(vec!["https://horizon.stellar.org".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://horizon.stellar.org".to_string(), + )]), explorer_urls: None, average_blocktime_ms: Some(5000), is_testnet: Some(false), diff --git a/src/domain/relayer/stellar/token_swap.rs b/src/domain/relayer/stellar/token_swap.rs index f52a12739..3dcc6e242 100644 --- a/src/domain/relayer/stellar/token_swap.rs +++ b/src/domain/relayer/stellar/token_swap.rs @@ -408,7 +408,7 @@ mod tests { jobs::MockJobProducerTrait, models::{ NetworkConfigData, NetworkRepoModel, NetworkType, RelayerNetworkPolicy, - RelayerRepoModel, RelayerStellarPolicy, RelayerStellarSwapConfig, + RelayerRepoModel, RelayerStellarPolicy, RelayerStellarSwapConfig, RpcConfig, StellarAllowedTokensPolicy, StellarAllowedTokensSwapConfig, StellarFeePaymentStrategy, StellarSwapStrategy, }, @@ -578,7 +578,9 @@ mod tests { common: NetworkConfigCommon { network: "testnet".to_string(), from: None, - rpc_urls: Some(vec!["https://horizon-testnet.stellar.org".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://horizon-testnet.stellar.org".to_string(), + )]), explorer_urls: None, average_blocktime_ms: Some(5000), is_testnet: Some(true), diff --git a/src/domain/transaction/evm/evm_transaction.rs b/src/domain/transaction/evm/evm_transaction.rs index a47338a03..73e3b85ac 100644 --- a/src/domain/transaction/evm/evm_transaction.rs +++ b/src/domain/transaction/evm/evm_transaction.rs @@ -1817,13 +1817,15 @@ mod tests { .with(eq(NetworkType::Evm), eq(1)) .returning(|_, _| { use crate::config::{EvmNetworkConfig, NetworkConfigCommon}; - use crate::models::{NetworkConfigData, NetworkRepoModel}; + use crate::models::{NetworkConfigData, NetworkRepoModel, RpcConfig}; let config = EvmNetworkConfig { common: NetworkConfigCommon { network: "mainnet".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://rpc.example.com".to_string(), + )]), explorer_urls: None, average_blocktime_ms: Some(12000), is_testnet: Some(false), @@ -2004,7 +2006,9 @@ mod tests { common: NetworkConfigCommon { network: "mainnet".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://rpc.example.com".to_string(), + )]), explorer_urls: None, average_blocktime_ms: Some(12000), is_testnet: Some(false), diff --git a/src/domain/transaction/evm/price_calculator.rs b/src/domain/transaction/evm/price_calculator.rs index 72af2b18d..4053f0593 100644 --- a/src/domain/transaction/evm/price_calculator.rs +++ b/src/domain/transaction/evm/price_calculator.rs @@ -701,9 +701,10 @@ mod tests { _ => 12000, // 12 seconds for mainnet and others }; + use crate::models::RpcConfig; EvmNetwork { network: name.to_string(), - rpc_urls: vec!["https://rpc.example.com".to_string()], + rpc_urls: vec![RpcConfig::new("https://rpc.example.com".to_string())], explorer_urls: None, average_blocktime_ms, is_testnet: true, @@ -717,6 +718,7 @@ mod tests { } fn create_mock_no_mempool_network(name: &str) -> EvmNetwork { + use crate::models::RpcConfig; let average_blocktime_ms = match name { "arbitrum" => 1000, // 1 second for arbitrum _ => 12000, // 12 seconds for others @@ -724,7 +726,7 @@ mod tests { EvmNetwork { network: name.to_string(), - rpc_urls: vec!["https://rpc.example.com".to_string()], + rpc_urls: vec![RpcConfig::new("https://rpc.example.com".to_string())], explorer_urls: None, average_blocktime_ms, is_testnet: true, @@ -2068,9 +2070,10 @@ mod tests { let mut mock_service = MockEvmGasPriceServiceTrait::new(); // Create a network that returns true for is_arbitrum() but not lacks_mempool() + use crate::models::RpcConfig; let arbitrum_network = EvmNetwork { network: "arbitrum-one".to_string(), - rpc_urls: vec!["https://arb1.arbitrum.io/rpc".to_string()], + rpc_urls: vec![RpcConfig::new("https://arb1.arbitrum.io/rpc".to_string())], explorer_urls: None, average_blocktime_ms: 1000, // 1 second for arbitrum is_testnet: false, diff --git a/src/domain/transaction/evm/status.rs b/src/domain/transaction/evm/status.rs index 47aa4bb7a..9fcf2de36 100644 --- a/src/domain/transaction/evm/status.rs +++ b/src/domain/transaction/evm/status.rs @@ -733,7 +733,8 @@ mod tests { models::{ evm::Speed, EvmTransactionData, NetworkConfigData, NetworkRepoModel, NetworkTransactionData, NetworkType, RelayerEvmPolicy, RelayerNetworkPolicy, - RelayerRepoModel, TransactionReceipt, TransactionRepoModel, TransactionStatus, U256, + RelayerRepoModel, RpcConfig, TransactionReceipt, TransactionRepoModel, + TransactionStatus, U256, }, repositories::{ MockNetworkRepository, MockRelayerRepository, MockTransactionCounterTrait, @@ -799,7 +800,7 @@ mod tests { common: NetworkConfigCommon { network: "mainnet".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]), explorer_urls: Some(vec!["https://explorer.example.com".to_string()]), average_blocktime_ms: Some(12000), is_testnet: Some(false), @@ -825,7 +826,9 @@ mod tests { common: NetworkConfigCommon { network: "arbitrum".to_string(), from: None, - rpc_urls: Some(vec!["https://arb-rpc.example.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://arb-rpc.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://arb-explorer.example.com".to_string()]), average_blocktime_ms: Some(1000), is_testnet: Some(false), @@ -1206,7 +1209,9 @@ mod tests { common: NetworkConfigCommon { network: "invalid-network".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://rpc.example.com".to_string(), + )]), explorer_urls: Some(vec!["https://explorer.example.com".to_string()]), average_blocktime_ms: Some(12000), is_testnet: Some(false), diff --git a/src/domain/transaction/evm/utils.rs b/src/domain/transaction/evm/utils.rs index c38e1853e..901578fe8 100644 --- a/src/domain/transaction/evm/utils.rs +++ b/src/domain/transaction/evm/utils.rs @@ -226,7 +226,9 @@ mod tests { fn create_standard_network() -> EvmNetwork { EvmNetwork { network: "ethereum".to_string(), - rpc_urls: vec!["https://mainnet.infura.io".to_string()], + rpc_urls: vec![crate::models::RpcConfig::new( + "https://mainnet.infura.io".to_string(), + )], explorer_urls: None, average_blocktime_ms: 12000, is_testnet: false, @@ -240,9 +242,10 @@ mod tests { } fn create_arbitrum_network() -> EvmNetwork { + use crate::models::RpcConfig; EvmNetwork { network: "arbitrum".to_string(), - rpc_urls: vec!["https://arb1.arbitrum.io/rpc".to_string()], + rpc_urls: vec![RpcConfig::new("https://arb1.arbitrum.io/rpc".to_string())], explorer_urls: None, average_blocktime_ms: 1000, is_testnet: false, @@ -256,9 +259,10 @@ mod tests { } fn create_arbitrum_nova_network() -> EvmNetwork { + use crate::models::RpcConfig; EvmNetwork { network: "arbitrum-nova".to_string(), - rpc_urls: vec!["https://nova.arbitrum.io/rpc".to_string()], + rpc_urls: vec![RpcConfig::new("https://nova.arbitrum.io/rpc".to_string())], explorer_urls: None, average_blocktime_ms: 1000, is_testnet: false, diff --git a/src/domain/transaction/stellar/test_helpers.rs b/src/domain/transaction/stellar/test_helpers.rs index ab36acb0b..7730b8b16 100644 --- a/src/domain/transaction/stellar/test_helpers.rs +++ b/src/domain/transaction/stellar/test_helpers.rs @@ -7,7 +7,7 @@ use crate::{ RelayerNetworkPolicy, RelayerRepoModel, RelayerStellarPolicy, StellarTransactionData, TransactionRepoModel, TransactionStatus, }, - repositories::{MockRepository, MockTransactionCounterTrait, MockTransactionRepository}, + repositories::{MockRelayerRepository, MockTransactionCounterTrait, MockTransactionRepository}, services::{ provider::MockStellarProviderTrait, signer::MockSigner, stellar_dex::MockStellarDexServiceTrait, @@ -271,7 +271,7 @@ pub fn create_test_transaction(relayer_id: &str) -> TransactionRepoModel { pub struct TestMocks { pub provider: MockStellarProviderTrait, - pub relayer_repo: MockRepository, + pub relayer_repo: MockRelayerRepository, pub tx_repo: MockTransactionRepository, pub job_producer: MockJobProducerTrait, pub signer: MockSigner, @@ -282,7 +282,7 @@ pub struct TestMocks { pub fn default_test_mocks() -> TestMocks { TestMocks { provider: MockStellarProviderTrait::new(), - relayer_repo: MockRepository::new(), + relayer_repo: MockRelayerRepository::new(), tx_repo: MockTransactionRepository::new(), job_producer: MockJobProducerTrait::new(), signer: MockSigner::new(), @@ -296,7 +296,7 @@ pub fn make_stellar_tx_handler( relayer: RelayerRepoModel, mocks: TestMocks, ) -> StellarRelayerTransaction< - MockRepository, + MockRelayerRepository, MockTransactionRepository, MockJobProducerTrait, MockSigner, diff --git a/src/models/network/evm/network.rs b/src/models/network/evm/network.rs index bcf5e2fb8..ea083cc11 100644 --- a/src/models/network/evm/network.rs +++ b/src/models/network/evm/network.rs @@ -3,7 +3,7 @@ use crate::constants::{ ARBITRUM_BASED_TAG, LACKS_MEMPOOL_TAGS, OPTIMISM_BASED_TAG, OPTIMISM_TAG, POLYGON_ZKEVM_TAG, ROLLUP_TAG, }; -use crate::models::{NetworkConfigData, NetworkRepoModel, RepositoryError}; +use crate::models::{NetworkConfigData, NetworkRepoModel, RepositoryError, RpcConfig}; use std::time::Duration; #[derive(Clone, PartialEq, Eq, Hash, Debug)] @@ -11,8 +11,8 @@ pub struct EvmNetwork { // Common network fields (flattened from NetworkConfigCommon) /// Unique network identifier (e.g., "mainnet", "sepolia", "custom-devnet"). pub network: String, - /// List of RPC endpoint URLs for connecting to the network. - pub rpc_urls: Vec, + /// List of RPC endpoint configurations for connecting to the network. + pub rpc_urls: Vec, /// List of Explorer endpoint URLs for connecting to the network. pub explorer_urls: Option>, /// Estimated average time between blocks in milliseconds. @@ -157,7 +157,7 @@ impl EvmNetwork { self.explorer_urls.as_deref() } - pub fn public_rpc_urls(&self) -> Option<&[String]> { + pub fn public_rpc_urls(&self) -> Option<&[RpcConfig]> { if self.rpc_urls.is_empty() { None } else { @@ -174,9 +174,10 @@ mod tests { use crate::models::{NetworkConfigData, NetworkRepoModel, NetworkType}; fn create_test_evm_network_with_tags(tags: Vec<&str>) -> EvmNetwork { + use crate::models::RpcConfig; EvmNetwork { network: "test-network".to_string(), - rpc_urls: vec!["https://rpc.example.com".to_string()], + rpc_urls: vec![RpcConfig::new("https://rpc.example.com".to_string())], explorer_urls: None, average_blocktime_ms: 12000, is_testnet: false, @@ -282,11 +283,12 @@ mod tests { #[test] fn test_try_from_with_tags() { + use crate::models::RpcConfig; let config = EvmNetworkConfig { common: NetworkConfigCommon { network: "test-network".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]), explorer_urls: None, average_blocktime_ms: Some(12000), is_testnet: Some(false), diff --git a/src/models/network/repository.rs b/src/models/network/repository.rs index f704635d7..1c1a3a4d7 100644 --- a/src/models/network/repository.rs +++ b/src/models/network/repository.rs @@ -162,11 +162,12 @@ mod tests { use super::*; fn create_evm_config(name: &str, chain_id: u64, symbol: &str) -> EvmNetworkConfig { + use crate::models::RpcConfig; EvmNetworkConfig { common: NetworkConfigCommon { network: name.to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]), explorer_urls: Some(vec!["https://explorer.example.com".to_string()]), average_blocktime_ms: Some(12000), is_testnet: Some(false), @@ -181,11 +182,14 @@ mod tests { } fn create_solana_config(name: &str, is_testnet: bool) -> SolanaNetworkConfig { + use crate::models::RpcConfig; SolanaNetworkConfig { common: NetworkConfigCommon { network: name.to_string(), from: None, - rpc_urls: Some(vec!["https://api.mainnet-beta.solana.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://api.mainnet-beta.solana.com".to_string(), + )]), explorer_urls: Some(vec!["https://explorer.solana.com".to_string()]), average_blocktime_ms: Some(400), is_testnet: Some(is_testnet), @@ -195,11 +199,14 @@ mod tests { } fn create_stellar_config(name: &str, passphrase: Option<&str>) -> StellarNetworkConfig { + use crate::models::RpcConfig; StellarNetworkConfig { common: NetworkConfigCommon { network: name.to_string(), from: None, - rpc_urls: Some(vec!["https://horizon.stellar.org".to_string()]), + rpc_urls: Some(vec![RpcConfig::new( + "https://horizon.stellar.org".to_string(), + )]), explorer_urls: Some(vec!["https://stellarchain.io".to_string()]), average_blocktime_ms: Some(5000), is_testnet: Some(passphrase.is_none()), @@ -336,9 +343,10 @@ mod tests { assert_eq!(common.network, "mainnet"); assert_eq!(common.is_testnet, Some(false)); assert_eq!(common.average_blocktime_ms, Some(12000)); + use crate::models::RpcConfig; assert_eq!( common.rpc_urls, - Some(vec!["https://rpc.example.com".to_string()]) + Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]) ); } @@ -453,11 +461,12 @@ mod tests { #[test] fn test_empty_optional_fields() { + use crate::models::RpcConfig; let minimal_config = EvmNetworkConfig { common: NetworkConfigCommon { network: "minimal".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]), explorer_urls: None, average_blocktime_ms: None, is_testnet: None, diff --git a/src/models/network/solana/network.rs b/src/models/network/solana/network.rs index 76e365801..2b745e3de 100644 --- a/src/models/network/solana/network.rs +++ b/src/models/network/solana/network.rs @@ -1,12 +1,12 @@ -use crate::models::{NetworkConfigData, NetworkRepoModel, RepositoryError}; +use crate::models::{NetworkConfigData, NetworkRepoModel, RepositoryError, RpcConfig}; use core::time::Duration; #[derive(Clone, PartialEq, Eq, Hash)] pub struct SolanaNetwork { /// Unique network identifier (e.g., "mainnet", "sepolia", "custom-devnet"). pub network: String, - /// List of RPC endpoint URLs for connecting to the network. - pub rpc_urls: Vec, + /// List of RPC endpoint configurations for connecting to the network. + pub rpc_urls: Vec, /// List of Explorer endpoint URLs for connecting to the network. pub explorer_urls: Option>, /// Estimated average time between blocks in milliseconds. @@ -68,7 +68,7 @@ impl SolanaNetwork { Some(Duration::from_millis(self.average_blocktime_ms)) } - pub fn public_rpc_urls(&self) -> Option<&[String]> { + pub fn public_rpc_urls(&self) -> Option<&[RpcConfig]> { if self.rpc_urls.is_empty() { None } else { diff --git a/src/models/network/stellar/network.rs b/src/models/network/stellar/network.rs index 504af5f09..345bcdbd7 100644 --- a/src/models/network/stellar/network.rs +++ b/src/models/network/stellar/network.rs @@ -1,4 +1,4 @@ -use crate::models::{NetworkConfigData, NetworkRepoModel, RepositoryError}; +use crate::models::{NetworkConfigData, NetworkRepoModel, RepositoryError, RpcConfig}; use core::time::Duration; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -8,8 +8,8 @@ use soroban_rs::xdr::Hash; pub struct StellarNetwork { /// Unique network identifier (e.g., "mainnet", "devnet", "custom-devnet"). pub network: String, - /// List of RPC endpoint URLs for connecting to the network. - pub rpc_urls: Vec, + /// List of RPC endpoint configurations for connecting to the network. + pub rpc_urls: Vec, /// List of Explorer endpoint URLs for connecting to the network. pub explorer_urls: Option>, /// Estimated average time between blocks in milliseconds. @@ -90,7 +90,7 @@ impl StellarNetwork { Some(Duration::from_millis(self.average_blocktime_ms)) } - pub fn public_rpc_urls(&self) -> Option<&[String]> { + pub fn public_rpc_urls(&self) -> Option<&[RpcConfig]> { if self.rpc_urls.is_empty() { None } else { diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index b1f58506d..98cbbafba 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -2324,6 +2324,7 @@ mod tests { Some(vec![RpcConfig { url: "https://example.com".to_string(), weight: 150, + ..Default::default() }]), // Weight > 100 ); diff --git a/src/models/relayer/rpc_config.rs b/src/models/relayer/rpc_config.rs index f58603b96..aca7ddc16 100644 --- a/src/models/relayer/rpc_config.rs +++ b/src/models/relayer/rpc_config.rs @@ -4,8 +4,9 @@ //! including URLs and weights for load balancing. use crate::constants::DEFAULT_RPC_WEIGHT; -use eyre::{eyre, Result}; -use serde::{Deserialize, Serialize}; +use eyre::eyre; +use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer}; +use std::hash::{Hash, Hasher}; use thiserror::Error; use utoipa::ToSchema; @@ -15,22 +16,59 @@ pub enum RpcConfigError { InvalidWeight { value: u8 }, } -/// Returns the default RPC weight. -fn default_rpc_weight() -> u8 { - DEFAULT_RPC_WEIGHT -} - /// Configuration for an RPC endpoint. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, ToSchema)] +/// +/// This struct contains only persistent configuration (URL and weight). +/// Health metadata (failures, pause state) is managed separately via `RpcHealthStore`. +#[derive(Clone, Debug, PartialEq, Eq, Default, ToSchema)] +#[schema(example = json!({"url": "https://rpc.example.com", "weight": 100}))] pub struct RpcConfig { /// The RPC endpoint URL. pub url: String, /// The weight of this endpoint in the weighted round-robin selection. /// Defaults to DEFAULT_RPC_WEIGHT (100). Should be between 0 and 100. - #[serde(default = "default_rpc_weight")] + #[schema(default = 100, minimum = 0, maximum = 100)] pub weight: u8, } +impl Hash for RpcConfig { + fn hash(&self, state: &mut H) { + self.url.hash(state); + self.weight.hash(state); + } +} + +impl Serialize for RpcConfig { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut state = serializer.serialize_struct("RpcConfig", 2)?; + state.serialize_field("url", &self.url)?; + state.serialize_field("weight", &self.weight)?; + state.end() + } +} + +impl<'de> Deserialize<'de> for RpcConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct RpcConfigHelper { + url: String, + weight: Option, + } + + let helper = RpcConfigHelper::deserialize(deserializer)?; + Ok(RpcConfig { + url: helper.url, + weight: helper.weight.unwrap_or(DEFAULT_RPC_WEIGHT), + }) + } +} + impl RpcConfig { /// Creates a new RPC configuration with the given URL and default weight (DEFAULT_RPC_WEIGHT). /// @@ -73,7 +111,7 @@ impl RpcConfig { /// Validates that a URL has an HTTP or HTTPS scheme. /// Helper function, hence private. - fn validate_url_scheme(url: &str) -> Result<()> { + fn validate_url_scheme(url: &str) -> Result<(), eyre::Report> { if !url.starts_with("http://") && !url.starts_with("https://") { return Err(eyre!( "Invalid URL scheme for {}: Only HTTP and HTTPS are supported", @@ -101,7 +139,7 @@ impl RpcConfig { /// ]; /// assert!(RpcConfig::validate_list(&configs).is_ok()); /// ``` - pub fn validate_list(configs: &[RpcConfig]) -> Result<()> { + pub fn validate_list(configs: &[RpcConfig]) -> Result<(), eyre::Report> { for config in configs { // Call the helper function using Self to refer to the type for associated functions Self::validate_url_scheme(&config.url)?; @@ -140,7 +178,11 @@ mod tests { fn test_get_weight_returns_weight_value() { let url = "https://example.com".to_string(); let weight: u8 = 10; - let config = RpcConfig { url, weight }; + let config = RpcConfig { + url, + weight, + ..Default::default() + }; assert_eq!(config.get_weight(), weight); } diff --git a/src/models/transaction/repository.rs b/src/models/transaction/repository.rs index 806760b13..628f396ba 100644 --- a/src/models/transaction/repository.rs +++ b/src/models/transaction/repository.rs @@ -1698,7 +1698,9 @@ mod tests { common: NetworkConfigCommon { network: "ethereum".to_string(), from: None, - rpc_urls: Some(vec!["https://mainnet.infura.io".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://mainnet.infura.io".to_string(), + )]), explorer_urls: Some(vec!["https://etherscan.io".to_string()]), average_blocktime_ms: Some(12000), is_testnet: Some(false), @@ -1780,7 +1782,9 @@ mod tests { common: NetworkConfigCommon { network: "mainnet".to_string(), from: None, - rpc_urls: Some(vec!["https://api.mainnet-beta.solana.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://api.mainnet-beta.solana.com".to_string(), + )]), explorer_urls: Some(vec!["https://explorer.solana.com".to_string()]), average_blocktime_ms: Some(400), is_testnet: Some(false), @@ -1856,7 +1860,9 @@ mod tests { common: NetworkConfigCommon { network: "mainnet".to_string(), from: None, - rpc_urls: Some(vec!["https://horizon.stellar.org".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://horizon.stellar.org".to_string(), + )]), explorer_urls: Some(vec!["https://stellarchain.io".to_string()]), average_blocktime_ms: Some(5000), is_testnet: Some(false), @@ -2116,7 +2122,9 @@ mod tests { common: NetworkConfigCommon { network: "testnet".to_string(), from: None, - rpc_urls: Some(vec!["https://test.stellar.org".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://test.stellar.org".to_string(), + )]), explorer_urls: None, average_blocktime_ms: Some(5000), // 5 seconds for Stellar is_testnet: Some(true), diff --git a/src/repositories/network/network_in_memory.rs b/src/repositories/network/network_in_memory.rs index c611988e6..5a8b065ee 100644 --- a/src/repositories/network/network_in_memory.rs +++ b/src/repositories/network/network_in_memory.rs @@ -178,7 +178,9 @@ mod tests { let common = NetworkConfigCommon { network: name.clone(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://rpc.example.com".to_string(), + )]), explorer_urls: None, average_blocktime_ms: None, is_testnet: Some(true), diff --git a/src/repositories/network/network_redis.rs b/src/repositories/network/network_redis.rs index c5fc706f4..4a01a46db 100644 --- a/src/repositories/network/network_redis.rs +++ b/src/repositories/network/network_redis.rs @@ -658,7 +658,9 @@ mod tests { let common = NetworkConfigCommon { network: name.to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://rpc.example.com".to_string(), + )]), explorer_urls: None, average_blocktime_ms: Some(12000), is_testnet: Some(true), @@ -947,7 +949,9 @@ mod tests { common: NetworkConfigCommon { network: "test".to_string(), from: None, - rpc_urls: Some(vec!["https://rpc.example.com".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "https://rpc.example.com".to_string(), + )]), explorer_urls: None, average_blocktime_ms: Some(12000), is_testnet: Some(true), diff --git a/src/repositories/relayer/mod.rs b/src/repositories/relayer/mod.rs index dc13c8d53..3a27f4501 100644 --- a/src/repositories/relayer/mod.rs +++ b/src/repositories/relayer/mod.rs @@ -69,6 +69,36 @@ pub trait RelayerRepository: Repository + Send + Sync fn is_persistent_storage(&self) -> bool; } +#[cfg(test)] +mockall::mock! { + pub RelayerRepository {} + + #[async_trait] + impl Repository for RelayerRepository { + async fn create(&self, entity: RelayerRepoModel) -> Result; + async fn get_by_id(&self, id: String) -> Result; + async fn list_all(&self) -> Result, RepositoryError>; + async fn list_paginated(&self, query: PaginationQuery) -> Result, RepositoryError>; + async fn update(&self, id: String, entity: RelayerRepoModel) -> Result; + async fn delete_by_id(&self, id: String) -> Result<(), RepositoryError>; + async fn count(&self) -> Result; + async fn has_entries(&self) -> Result; + async fn drop_all_entries(&self) -> Result<(), RepositoryError>; + } + + #[async_trait] + impl RelayerRepository for RelayerRepository { + async fn list_active(&self) -> Result, RepositoryError>; + async fn list_by_signer_id(&self, signer_id: &str) -> Result, RepositoryError>; + async fn list_by_notification_id(&self, notification_id: &str) -> Result, RepositoryError>; + async fn partial_update(&self, id: String, update: UpdateRelayerRequest) -> Result; + async fn enable_relayer(&self, relayer_id: String) -> Result; + async fn disable_relayer(&self, relayer_id: String, reason: DisabledReason) -> Result; + async fn update_policy(&self, id: String, policy: RelayerNetworkPolicy) -> Result; + fn is_persistent_storage(&self) -> bool; + } +} + /// Enum wrapper for different relayer repository implementations #[derive(Debug, Clone)] pub enum RelayerRepositoryStorage { @@ -470,33 +500,3 @@ mod tests { assert!(!repo.has_entries().await.unwrap()); } } - -#[cfg(test)] -mockall::mock! { - pub RelayerRepository {} - - #[async_trait] - impl Repository for RelayerRepository { - async fn create(&self, entity: RelayerRepoModel) -> Result; - async fn get_by_id(&self, id: String) -> Result; - async fn list_all(&self) -> Result, RepositoryError>; - async fn list_paginated(&self, query: PaginationQuery) -> Result, RepositoryError>; - async fn update(&self, id: String, entity: RelayerRepoModel) -> Result; - async fn delete_by_id(&self, id: String) -> Result<(), RepositoryError>; - async fn count(&self) -> Result; - async fn has_entries(&self) -> Result; - async fn drop_all_entries(&self) -> Result<(), RepositoryError>; - } - - #[async_trait] - impl RelayerRepository for RelayerRepository { - async fn list_active(&self) -> Result, RepositoryError>; - async fn list_by_signer_id(&self, signer_id: &str) -> Result, RepositoryError>; - async fn list_by_notification_id(&self, notification_id: &str) -> Result, RepositoryError>; - async fn partial_update(&self, id: String, update: UpdateRelayerRequest) -> Result; - async fn enable_relayer(&self, relayer_id: String) -> Result; - async fn disable_relayer(&self, relayer_id: String, reason: DisabledReason) -> Result; - async fn update_policy(&self, id: String, policy: RelayerNetworkPolicy) -> Result; - fn is_persistent_storage(&self) -> bool; - } -} diff --git a/src/services/gas/cache.rs b/src/services/gas/cache.rs index 5345ead8f..bd9ac4258 100644 --- a/src/services/gas/cache.rs +++ b/src/services/gas/cache.rs @@ -482,7 +482,9 @@ mod tests { // Create a mock zkEVM network let mut zkevm_network = EvmNetwork { network: "polygon-zkevm".to_string(), - rpc_urls: vec!["https://zkevm-rpc.com".to_string()], + rpc_urls: vec![crate::models::RpcConfig::new( + "https://zkevm-rpc.com".to_string(), + )], explorer_urls: None, average_blocktime_ms: 2000, is_testnet: false, diff --git a/src/services/gas/evm_gas_price.rs b/src/services/gas/evm_gas_price.rs index 94e92ab6c..5a47054e6 100644 --- a/src/services/gas/evm_gas_price.rs +++ b/src/services/gas/evm_gas_price.rs @@ -413,7 +413,9 @@ mod tests { fn create_test_evm_network() -> EvmNetwork { EvmNetwork { network: "mainnet".to_string(), - rpc_urls: vec!["https://mainnet.infura.io/v3/YOUR_INFURA_API_KEY".to_string()], + rpc_urls: vec![crate::models::RpcConfig::new( + "https://mainnet.infura.io/v3/YOUR_INFURA_API_KEY".to_string(), + )], explorer_urls: None, average_blocktime_ms: 12000, is_testnet: false, diff --git a/src/services/gas/fetchers/default.rs b/src/services/gas/fetchers/default.rs index c0b14cee9..b6046cb68 100644 --- a/src/services/gas/fetchers/default.rs +++ b/src/services/gas/fetchers/default.rs @@ -43,7 +43,9 @@ mod tests { let fetcher = DefaultGasPriceFetcher; let network = EvmNetwork { network: "ethereum".to_string(), - rpc_urls: vec!["https://mainnet.infura.io".to_string()], + rpc_urls: vec![crate::models::RpcConfig::new( + "https://mainnet.infura.io".to_string(), + )], explorer_urls: None, average_blocktime_ms: 12000, is_testnet: false, @@ -71,7 +73,9 @@ mod tests { let fetcher = DefaultGasPriceFetcher; let network = EvmNetwork { network: "ethereum".to_string(), - rpc_urls: vec!["https://mainnet.infura.io".to_string()], + rpc_urls: vec![crate::models::RpcConfig::new( + "https://mainnet.infura.io".to_string(), + )], explorer_urls: None, average_blocktime_ms: 12000, is_testnet: false, @@ -98,7 +102,9 @@ mod tests { let fetcher = DefaultGasPriceFetcher; let network = EvmNetwork { network: "polygon".to_string(), - rpc_urls: vec!["https://polygon-rpc.com".to_string()], + rpc_urls: vec![crate::models::RpcConfig::new( + "https://polygon-rpc.com".to_string(), + )], explorer_urls: None, average_blocktime_ms: 2000, is_testnet: false, @@ -126,7 +132,9 @@ mod tests { let fetcher = DefaultGasPriceFetcher; let network = EvmNetwork { network: "ethereum".to_string(), - rpc_urls: vec!["https://mainnet.infura.io".to_string()], + rpc_urls: vec![crate::models::RpcConfig::new( + "https://mainnet.infura.io".to_string(), + )], explorer_urls: None, average_blocktime_ms: 12000, is_testnet: false, @@ -154,7 +162,9 @@ mod tests { let fetcher = DefaultGasPriceFetcher; let network = EvmNetwork { network: "polygon".to_string(), - rpc_urls: vec!["https://polygon-rpc.com".to_string()], + rpc_urls: vec![crate::models::RpcConfig::new( + "https://polygon-rpc.com".to_string(), + )], explorer_urls: None, average_blocktime_ms: 2000, is_testnet: false, diff --git a/src/services/gas/fetchers/mod.rs b/src/services/gas/fetchers/mod.rs index 389356870..7880d6867 100644 --- a/src/services/gas/fetchers/mod.rs +++ b/src/services/gas/fetchers/mod.rs @@ -100,7 +100,9 @@ mod tests { fn create_zkevm_network() -> EvmNetwork { EvmNetwork { network: "polygon-zkevm".to_string(), - rpc_urls: vec!["https://zkevm-rpc.com".to_string()], + rpc_urls: vec![crate::models::RpcConfig::new( + "https://zkevm-rpc.com".to_string(), + )], explorer_urls: None, average_blocktime_ms: 2000, is_testnet: false, @@ -116,7 +118,9 @@ mod tests { fn create_default_network() -> EvmNetwork { EvmNetwork { network: "ethereum".to_string(), - rpc_urls: vec!["https://mainnet.infura.io".to_string()], + rpc_urls: vec![crate::models::RpcConfig::new( + "https://mainnet.infura.io".to_string(), + )], explorer_urls: None, average_blocktime_ms: 12000, is_testnet: false, diff --git a/src/services/gas/fetchers/polygon_zkevm.rs b/src/services/gas/fetchers/polygon_zkevm.rs index ec37ce9be..ae0c67df5 100644 --- a/src/services/gas/fetchers/polygon_zkevm.rs +++ b/src/services/gas/fetchers/polygon_zkevm.rs @@ -123,7 +123,9 @@ mod tests { fn create_zkevm_network() -> EvmNetwork { EvmNetwork { network: "polygon-zkevm".to_string(), - rpc_urls: vec!["https://zkevm-rpc.com".to_string()], + rpc_urls: vec![crate::models::RpcConfig::new( + "https://zkevm-rpc.com".to_string(), + )], explorer_urls: None, average_blocktime_ms: 2000, is_testnet: false, diff --git a/src/services/gas/price_params_handler.rs b/src/services/gas/price_params_handler.rs index 96ec0ffac..1adf23715 100644 --- a/src/services/gas/price_params_handler.rs +++ b/src/services/gas/price_params_handler.rs @@ -69,13 +69,16 @@ mod tests { use crate::{ constants::{OPTIMISM_BASED_TAG, POLYGON_ZKEVM_TAG}, models::{RpcConfig, U256}, + services::provider::ProviderConfig, }; use std::env; fn create_test_network_with_tags(tags: Vec<&str>) -> EvmNetwork { EvmNetwork { network: "test-network".to_string(), - rpc_urls: vec!["https://rpc.example.com".to_string()], + rpc_urls: vec![crate::models::RpcConfig::new( + "https://rpc.example.com".to_string(), + )], explorer_urls: None, average_blocktime_ms: 12000, is_testnet: false, @@ -98,7 +101,8 @@ mod tests { fn test_price_params_handler_for_polygon_zkevm() { setup_test_env(); let rpc_configs = vec![RpcConfig::new("http://localhost:8545".to_string())]; - let provider = EvmProvider::new(rpc_configs, 30).expect("Failed to create EvmProvider"); + let provider = EvmProvider::new(ProviderConfig::new(rpc_configs, 30, 3, 60, 60)) + .expect("Failed to create EvmProvider"); let network = create_test_network_with_tags(vec![POLYGON_ZKEVM_TAG]); let handler = PriceParamsHandler::for_network(&network, provider); assert!(handler.is_some()); @@ -112,7 +116,8 @@ mod tests { fn test_price_params_handler_for_optimism() { setup_test_env(); let rpc_configs = vec![RpcConfig::new("http://localhost:8545".to_string())]; - let provider = EvmProvider::new(rpc_configs, 30).expect("Failed to create EvmProvider"); + let provider = EvmProvider::new(ProviderConfig::new(rpc_configs, 30, 3, 60, 60)) + .expect("Failed to create EvmProvider"); let network = create_test_network_with_tags(vec![OPTIMISM_BASED_TAG]); let handler = PriceParamsHandler::for_network(&network, provider); assert!(handler.is_some()); @@ -126,7 +131,8 @@ mod tests { fn test_price_params_handler_for_non_l2() { setup_test_env(); let rpc_configs = vec![RpcConfig::new("http://localhost:8545".to_string())]; - let provider = EvmProvider::new(rpc_configs, 30).expect("Failed to create EvmProvider"); + let provider = EvmProvider::new(ProviderConfig::new(rpc_configs, 30, 3, 60, 60)) + .expect("Failed to create EvmProvider"); let network = create_test_network_with_tags(vec!["mainnet"]); let handler = PriceParamsHandler::for_network(&network, provider); assert!(handler.is_none()); diff --git a/src/services/jupiter/mod.rs b/src/services/jupiter/mod.rs index de51f97d8..6cc644ad1 100644 --- a/src/services/jupiter/mod.rs +++ b/src/services/jupiter/mod.rs @@ -51,9 +51,11 @@ pub struct SwapInfo { #[serde(rename = "outAmount")] pub out_amount: String, #[serde(rename = "feeAmount")] - pub fee_amount: String, + #[serde(default)] + pub fee_amount: Option, #[serde(rename = "feeMint")] - pub fee_mint: String, + #[serde(default)] + pub fee_mint: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -412,8 +414,8 @@ impl JupiterServiceTrait for MockJupiterService { output_mint: request.output_mint.to_string(), in_amount: request.amount.to_string(), out_amount: request.amount.to_string(), - fee_amount: "0".to_string(), - fee_mint: "mock_fee_mint".to_string(), + fee_amount: Some("0".to_string()), + fee_mint: Some("mock_fee_mint".to_string()), }, }], }; @@ -472,8 +474,8 @@ impl JupiterServiceTrait for MockJupiterService { output_mint: request.output_mint.to_string(), in_amount: request.amount.to_string(), out_amount: request.amount.to_string(), - fee_amount: "0".to_string(), - fee_mint: "mock_fee_mint".to_string(), + fee_amount: Some("0".to_string()), + fee_mint: Some("mock_fee_mint".to_string()), }, }], prioritization_fee_lamports: 0, @@ -679,8 +681,8 @@ mod tests { output_mint: "So11111111111111111111111111111111111111112".to_string(), in_amount: "1000000".to_string(), out_amount: "24860952".to_string(), - fee_amount: "1000".to_string(), - fee_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), + fee_amount: Some("1000".to_string()), + fee_mint: Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string()), }, }], }; @@ -750,8 +752,8 @@ mod tests { output_mint: "So11111111111111111111111111111111111111112".to_string(), in_amount: "1000000".to_string(), out_amount: "24860952".to_string(), - fee_amount: "1000".to_string(), - fee_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), + fee_amount: Some("1000".to_string()), + fee_mint: Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string()), }, }], prioritization_fee_lamports: 5000, diff --git a/src/services/provider/evm/mod.rs b/src/services/provider/evm/mod.rs index 2483378a4..dfae824df 100644 --- a/src/services/provider/evm/mod.rs +++ b/src/services/provider/evm/mod.rs @@ -32,9 +32,10 @@ use async_trait::async_trait; use eyre::Result; use reqwest::ClientBuilder as ReqwestClientBuilder; use serde_json; +use tracing::info; use super::rpc_selector::RpcSelector; -use super::{retry_rpc_call, RetryConfig}; +use super::{retry_rpc_call, ProviderConfig, RetryConfig}; use crate::{ models::{ BlockResponse, EvmTransactionData, RpcConfig, TransactionError, TransactionReceipt, U256, @@ -68,6 +69,7 @@ pub struct EvmProvider { #[cfg_attr(test, automock)] #[allow(dead_code)] pub trait EvmProviderTrait: Send + Sync { + fn get_configs(&self) -> Vec; /// Gets the balance of an address in the native currency. /// /// # Arguments @@ -154,23 +156,28 @@ impl EvmProvider { /// Creates a new EVM provider instance. /// /// # Arguments - /// * `configs` - A vector of RPC configurations (URL and weight) - /// * `timeout_seconds` - The timeout duration in seconds (defaults to 30 if None) + /// * `config` - Provider configuration containing RPC configs, timeout, and failure handling settings /// /// # Returns /// * `Result` - A new provider instance or an error - pub fn new(configs: Vec, timeout_seconds: u64) -> Result { - if configs.is_empty() { + pub fn new(config: ProviderConfig) -> Result { + if config.rpc_configs.is_empty() { return Err(ProviderError::NetworkConfiguration( "At least one RPC configuration must be provided".to_string(), )); } - RpcConfig::validate_list(&configs) + RpcConfig::validate_list(&config.rpc_configs) .map_err(|e| ProviderError::NetworkConfiguration(format!("Invalid URL: {e}")))?; // Create the RPC selector - let selector = RpcSelector::new(configs).map_err(|e| { + let selector = RpcSelector::new( + config.rpc_configs, + config.failure_threshold, + config.pause_duration_secs, + config.failure_expiration_secs, + ) + .map_err(|e| { ProviderError::NetworkConfiguration(format!("Failed to create RPC selector: {e}")) })?; @@ -178,13 +185,22 @@ impl EvmProvider { Ok(Self { selector, - timeout_seconds, + timeout_seconds: config.timeout_seconds, retry_config, }) } + /// Gets the current RPC configurations. + /// + /// # Returns + /// * `Vec` - The current configurations + pub fn get_configs(&self) -> Vec { + self.selector.get_configs() + } + /// Initialize a provider for a given URL fn initialize_provider(&self, url: &str) -> Result { + info!("Initializing provider for URL: {url}"); let rpc_url = url .parse() .map_err(|e| ProviderError::NetworkConfiguration(format!("Invalid URL format: {e}")))?; @@ -253,6 +269,10 @@ impl AsRef for EvmProvider { #[async_trait] impl EvmProviderTrait for EvmProvider { + fn get_configs(&self) -> Vec { + self.get_configs() + } + async fn get_balance(&self, address: &str) -> Result { let parsed_address = address .parse::() @@ -591,14 +611,25 @@ mod tests { fn test_new_provider() { let _env_guard = setup_test_env(); - let provider = EvmProvider::new( + let config = ProviderConfig::new( vec![RpcConfig::new("http://localhost:8545".to_string())], 30, + 3, + 60, + 60, ); + let provider = EvmProvider::new(config); assert!(provider.is_ok()); // Test with invalid URL - let provider = EvmProvider::new(vec![RpcConfig::new("invalid-url".to_string())], 30); + let config = ProviderConfig::new( + vec![RpcConfig::new("invalid-url".to_string())], + 30, + 3, + 60, + 60, + ); + let provider = EvmProvider::new(config); assert!(provider.is_err()); } @@ -607,26 +638,47 @@ mod tests { let _env_guard = setup_test_env(); // Test with valid URL and timeout - let provider = EvmProvider::new( + let config = ProviderConfig::new( vec![RpcConfig::new("http://localhost:8545".to_string())], 30, + 3, + 60, + 60, ); + let provider = EvmProvider::new(config); assert!(provider.is_ok()); // Test with invalid URL - let provider = EvmProvider::new(vec![RpcConfig::new("invalid-url".to_string())], 30); + let config = ProviderConfig::new( + vec![RpcConfig::new("invalid-url".to_string())], + 30, + 3, + 60, + 60, + ); + let provider = EvmProvider::new(config); assert!(provider.is_err()); // Test with zero timeout - let provider = - EvmProvider::new(vec![RpcConfig::new("http://localhost:8545".to_string())], 0); + let config = ProviderConfig::new( + vec![RpcConfig::new("http://localhost:8545".to_string())], + 0, + 3, + 60, + 60, + ); + let provider = EvmProvider::new(config); assert!(provider.is_ok()); // Test with large timeout - let provider = EvmProvider::new( + let config = ProviderConfig::new( vec![RpcConfig::new("http://localhost:8545".to_string())], 3600, + 3, + 60, + 60, ); + let provider = EvmProvider::new(config); assert!(provider.is_ok()); } diff --git a/src/services/provider/mod.rs b/src/services/provider/mod.rs index 24a074e94..4508f11e4 100644 --- a/src/services/provider/mod.rs +++ b/src/services/provider/mod.rs @@ -19,8 +19,85 @@ pub use stellar::*; mod retry; pub use retry::*; +pub mod rpc_health_store; pub mod rpc_selector; +pub use rpc_health_store::{RpcConfigMetadata, RpcHealthStore}; + +/// Configuration for creating a provider instance. +/// +/// This struct encapsulates all the parameters needed to create a provider, +/// making the API cleaner and easier to maintain. +#[derive(Debug, Clone)] +pub struct ProviderConfig { + /// RPC endpoint configurations (URLs and weights) + pub rpc_configs: Vec, + /// Timeout duration in seconds for RPC requests + pub timeout_seconds: u64, + /// Number of consecutive failures before pausing a provider + pub failure_threshold: u32, + /// Duration in seconds to pause a provider after reaching failure threshold + pub pause_duration_secs: u64, + /// Duration in seconds after which failures are considered stale and reset + pub failure_expiration_secs: u64, +} + +impl ProviderConfig { + /// Creates a new `ProviderConfig` from individual parameters. + /// + /// # Arguments + /// * `rpc_configs` - RPC endpoint configurations + /// * `timeout_seconds` - Timeout duration in seconds + /// * `failure_threshold` - Number of consecutive failures before pausing + /// * `pause_duration_secs` - Duration in seconds to pause after threshold + /// * `failure_expiration_secs` - Duration in seconds after which failures are considered stale + pub fn new( + rpc_configs: Vec, + timeout_seconds: u64, + failure_threshold: u32, + pause_duration_secs: u64, + failure_expiration_secs: u64, + ) -> Self { + Self { + rpc_configs, + timeout_seconds, + failure_threshold, + pause_duration_secs, + failure_expiration_secs, + } + } + + /// Creates a `ProviderConfig` from `ServerConfig` with the given RPC configs. + /// + /// This is a convenience method that extracts provider-related configuration + /// from the server configuration. + /// + /// # Arguments + /// * `server_config` - The server configuration + /// * `rpc_configs` - RPC endpoint configurations + pub fn from_server_config(server_config: &ServerConfig, rpc_configs: Vec) -> Self { + let timeout_seconds = server_config.rpc_timeout_ms / 1000; // Convert ms to s + Self { + rpc_configs, + timeout_seconds, + failure_threshold: server_config.provider_failure_threshold, + pause_duration_secs: server_config.provider_pause_duration_secs, + failure_expiration_secs: server_config.provider_failure_expiration_secs, + } + } + + /// Creates a `ProviderConfig` from environment variables with the given RPC configs. + /// + /// This loads configuration from `ServerConfig::from_env()`. + /// + /// # Arguments + /// * `rpc_configs` - RPC endpoint configurations + pub fn from_env(rpc_configs: Vec) -> Self { + let server_config = ServerConfig::from_env(); + Self::from_server_config(&server_config, rpc_configs) + } +} + #[derive(Error, Debug, Serialize)] pub enum ProviderError { #[error("RPC client error: {0}")] @@ -174,65 +251,54 @@ impl From for ProviderError { pub trait NetworkConfiguration: Sized { type Provider; - fn public_rpc_urls(&self) -> Vec; + fn public_rpc_urls(&self) -> Vec; - fn new_provider( - rpc_urls: Vec, - timeout_seconds: u64, - ) -> Result; + /// Creates a new provider instance using the provided configuration. + /// + /// # Arguments + /// * `config` - Provider configuration containing RPC configs and settings + fn new_provider(config: ProviderConfig) -> Result; } impl NetworkConfiguration for EvmNetwork { type Provider = EvmProvider; - fn public_rpc_urls(&self) -> Vec { - (*self) - .public_rpc_urls() - .map(|urls| urls.iter().map(|url| url.to_string()).collect()) + fn public_rpc_urls(&self) -> Vec { + self.public_rpc_urls() + .map(|configs| configs.to_vec()) .unwrap_or_default() } - fn new_provider( - rpc_urls: Vec, - timeout_seconds: u64, - ) -> Result { - EvmProvider::new(rpc_urls, timeout_seconds) + fn new_provider(config: ProviderConfig) -> Result { + EvmProvider::new(config) } } impl NetworkConfiguration for SolanaNetwork { type Provider = SolanaProvider; - fn public_rpc_urls(&self) -> Vec { - (*self) - .public_rpc_urls() - .map(|urls| urls.to_vec()) + fn public_rpc_urls(&self) -> Vec { + self.public_rpc_urls() + .map(|configs| configs.to_vec()) .unwrap_or_default() } - fn new_provider( - rpc_urls: Vec, - timeout_seconds: u64, - ) -> Result { - SolanaProvider::new(rpc_urls, timeout_seconds) + fn new_provider(config: ProviderConfig) -> Result { + SolanaProvider::new(config) } } impl NetworkConfiguration for StellarNetwork { type Provider = StellarProvider; - fn public_rpc_urls(&self) -> Vec { - (*self) - .public_rpc_urls() - .map(|urls| urls.to_vec()) + fn public_rpc_urls(&self) -> Vec { + self.public_rpc_urls() + .map(|configs| configs.to_vec()) .unwrap_or_default() } - fn new_provider( - rpc_urls: Vec, - timeout_seconds: u64, - ) -> Result { - StellarProvider::new(rpc_urls, timeout_seconds) + fn new_provider(config: ProviderConfig) -> Result { + StellarProvider::new(config) } } @@ -262,23 +328,21 @@ pub fn get_network_provider( network: &N, custom_rpc_urls: Option>, ) -> Result { - let rpc_timeout_ms = ServerConfig::from_env().rpc_timeout_ms; - let timeout_seconds = rpc_timeout_ms / 1000; // Convert ms to s - let rpc_urls = match custom_rpc_urls { Some(configs) if !configs.is_empty() => configs, _ => { - let urls = network.public_rpc_urls(); - if urls.is_empty() { + let configs = network.public_rpc_urls(); + if configs.is_empty() { return Err(ProviderError::NetworkConfiguration( "No public RPC URLs available for this network".to_string(), )); } - urls.into_iter().map(RpcConfig::new).collect() + configs } }; - N::new_provider(rpc_urls, timeout_seconds) + let provider_config = ProviderConfig::from_env(rpc_urls); + N::new_provider(provider_config) } /// Determines if an HTTP status code indicates the provider should be marked as failed. @@ -415,7 +479,7 @@ mod tests { fn create_test_evm_network() -> EvmNetwork { EvmNetwork { network: "test-evm".to_string(), - rpc_urls: vec!["https://rpc.example.com".to_string()], + rpc_urls: vec![RpcConfig::new("https://rpc.example.com".to_string())], explorer_urls: None, average_blocktime_ms: 12000, is_testnet: true, @@ -431,7 +495,7 @@ mod tests { fn create_test_solana_network(network_str: &str) -> SolanaNetwork { SolanaNetwork { network: network_str.to_string(), - rpc_urls: vec!["https://api.testnet.solana.com".to_string()], + rpc_urls: vec![RpcConfig::new("https://api.testnet.solana.com".to_string())], explorer_urls: None, average_blocktime_ms: 400, is_testnet: true, @@ -442,7 +506,9 @@ mod tests { fn create_test_stellar_network() -> StellarNetwork { StellarNetwork { network: "testnet".to_string(), - rpc_urls: vec!["https://soroban-testnet.stellar.org".to_string()], + rpc_urls: vec![RpcConfig::new( + "https://soroban-testnet.stellar.org".to_string(), + )], explorer_urls: None, average_blocktime_ms: 5000, is_testnet: true, @@ -595,10 +661,12 @@ mod tests { RpcConfig { url: "https://custom-rpc1.example.com".to_string(), weight: 1, + ..Default::default() }, RpcConfig { url: "https://custom-rpc2.example.com".to_string(), weight: 1, + ..Default::default() }, ]; let result = get_network_provider(&network, Some(custom_urls)); @@ -654,10 +722,12 @@ mod tests { RpcConfig { url: "https://custom-rpc1.example.com".to_string(), weight: 1, + ..Default::default() }, RpcConfig { url: "https://custom-rpc2.example.com".to_string(), weight: 1, + ..Default::default() }, ]; let result = get_network_provider(&network, Some(custom_urls)); diff --git a/src/services/provider/retry.rs b/src/services/provider/retry.rs index fe638aff0..ea7f416cb 100644 --- a/src/services/provider/retry.rs +++ b/src/services/provider/retry.rs @@ -21,6 +21,7 @@ //! The retry mechanism works with any RPC provider type and automatically handles //! errors, maximizing the chances of successful operations. use rand::Rng; +use std::collections::HashSet; use std::future::Future; use std::time::Duration; @@ -209,6 +210,8 @@ where let mut failover_count = 0; let mut total_attempts = 0; let mut last_error = None; + // Track providers that have been tried in this failover cycle to avoid retrying them + let mut tried_urls = HashSet::new(); tracing::debug!( operation_name = %operation_name, @@ -218,17 +221,24 @@ where "starting rpc call" ); - while failover_count <= max_failovers && selector.available_provider_count() > 0 { + // Continue retrying as long as we haven't exceeded max failovers and there are providers to try + // Note: We use provider_count() instead of available_provider_count() because select_url() + // will now fall back to paused providers when no non-paused providers are available. + while failover_count <= max_failovers && selector.provider_count() > 0 { // Try to get and initialize a provider let (provider, provider_url) = - match get_provider(selector, operation_name, &provider_initializer) { - Ok((provider, url)) => (provider, url), + match get_provider(selector, operation_name, &provider_initializer, &tried_urls) { + Ok((provider, url)) => { + // Track this provider as tried + tried_urls.insert(url.clone()); + (provider, url) + } Err(e) => { last_error = Some(e); failover_count += 1; // If we've exhausted all providers or reached max failovers, stop - if failover_count > max_failovers || selector.available_provider_count() == 0 { + if failover_count > max_failovers || selector.provider_count() == 0 { break; } @@ -241,6 +251,7 @@ where tracing::debug!( provider_url = %provider_url, operation_name = %operation_name, + tried_providers = %tried_urls.len(), "selected provider" ); @@ -269,9 +280,7 @@ where match internal_err { InternalRetryError::NonRetriable(original_err) => { // Check if this non-retriable error should mark the provider as failed - if should_mark_provider_failed(&original_err) - && selector.available_provider_count() > 1 - { + if should_mark_provider_failed(&original_err) { tracing::warn!( error = %original_err, provider_url = %provider_url, @@ -285,30 +294,18 @@ where InternalRetryError::RetriesExhausted(original_err) => { last_error = Some(original_err); - // If retries are exhausted, we always intend to mark the provider as failed, - // unless it's the last available one. - if selector.available_provider_count() > 1 { - tracing::warn!( - max_retries = %config.max_retries, - provider_url = %provider_url, - operation_name = %operation_name, - error = %last_error.as_ref().unwrap(), - failover_count = %(failover_count + 1), - max_failovers = %max_failovers, - "all retry attempts failed, marking as failed and switching to next provider" - ); - selector.mark_current_as_failed(); - failover_count += 1; - } else { - tracing::warn!( - max_retries = %config.max_retries, - provider_url = %provider_url, - operation_name = %operation_name, - error = %last_error.as_ref().unwrap(), - "all retry attempts failed, this is the last available provider, not marking as failed" - ); - break; - } + // If retries are exhausted, mark the provider as failed + tracing::warn!( + max_retries = %config.max_retries, + provider_url = %provider_url, + operation_name = %operation_name, + error = %last_error.as_ref().unwrap(), + failover_count = %(failover_count + 1), + max_failovers = %max_failovers, + "all retry attempts failed, marking as failed and switching to next provider" + ); + selector.mark_current_as_failed(); + failover_count += 1; } } } @@ -353,14 +350,15 @@ fn get_provider( selector: &RpcSelector, operation_name: &str, provider_initializer: &I, + excluded_urls: &HashSet, ) -> Result<(P, String), E> where E: std::fmt::Display + From, I: Fn(&str) -> Result, { - // Get the next provider URL from the selector + // Get the next provider URL from the selector, excluding already tried providers let provider_url = selector - .get_client(|url| Ok::<_, eyre::Report>(url.to_string())) + .get_client(|url| Ok::<_, eyre::Report>(url.to_string()), excluded_urls) .map_err(|e| { let err_msg = format!("Failed to get provider URL for {operation_name}: {e}"); tracing::warn!(operation_name = %operation_name, error = %e, "failed to get provider url"); @@ -478,8 +476,10 @@ where mod tests { use super::*; use crate::models::RpcConfig; + use crate::services::provider::rpc_health_store::RpcHealthStore; use lazy_static::lazy_static; use std::cmp::Ordering; + use std::collections::HashSet; use std::env; use std::sync::atomic::{AtomicU8, Ordering as AtomicOrdering}; use std::sync::Arc; @@ -547,11 +547,15 @@ mod tests { guard.set("PROVIDER_MAX_FAILOVERS", "1"); guard.set("PROVIDER_RETRY_BASE_DELAY_MS", "1"); guard.set("PROVIDER_RETRY_MAX_DELAY_MS", "5"); + guard.set("PROVIDER_FAILURE_THRESHOLD", "1"); guard.set("REDIS_URL", "redis://localhost:6379"); guard.set( "RELAYER_PRIVATE_KEY", "0x1234567890123456789012345678901234567890123456789012345678901234", ); + // Clear health store to ensure test isolation + // Note: When running tests in parallel, use --test-threads=1 to avoid flakiness + RpcHealthStore::instance().clear_all(); guard } @@ -787,22 +791,23 @@ mod tests { RpcConfig::new("http://localhost:8545".to_string()), RpcConfig::new("http://localhost:8546".to_string()), ]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let initializer = |url: &str| -> Result { Ok(format!("provider-{}", url)) }; - let result = get_provider(&selector, "test_operation", &initializer); + let result = get_provider(&selector, "test_operation", &initializer, &HashSet::new()); assert!(result.is_ok()); let (provider, url) = result.unwrap(); - assert_eq!(url, "http://localhost:8545"); - assert_eq!(provider, "provider-http://localhost:8545"); + // When weights are equal, selection may start from any provider + assert!(url == "http://localhost:8545" || url == "http://localhost:8546"); + assert_eq!(provider, format!("provider-{}", url)); let initializer = |_: &str| -> Result { Err(TestError("Failed to initialize".to_string())) }; - let result = get_provider(&selector, "test_operation", &initializer); + let result = get_provider(&selector, "test_operation", &initializer, &HashSet::new()); assert!(result.is_err()); let err = result.unwrap_err(); assert!(format!("{}", err).contains("Failed to initialize")); @@ -1000,12 +1005,14 @@ mod tests { #[tokio::test] async fn test_non_retriable_error_does_not_mark_provider_failed() { let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); + // Use unique URLs to avoid conflicts with other tests let configs = vec![ - RpcConfig::new("http://localhost:8545".to_string()), - RpcConfig::new("http://localhost:8546".to_string()), + RpcConfig::new("http://localhost:9986".to_string()), + RpcConfig::new("http://localhost:9985".to_string()), ]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let provider_initializer = |url: &str| -> Result { Ok(url.to_string()) }; @@ -1015,8 +1022,12 @@ mod tests { let config = RetryConfig::new(3, 1, 0, 0); - // Get initial provider count + // Get initial provider count - should be 2 after clearing let initial_available_count = selector.available_provider_count(); + assert_eq!( + initial_available_count, 2, + "Both providers should be available after clearing" + ); let result: Result = retry_rpc_call( &selector, @@ -1042,12 +1053,14 @@ mod tests { #[tokio::test] async fn test_retriable_error_marks_provider_failed_after_retries_exhausted() { let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); + // Use unique URLs to avoid conflicts with other tests let configs = vec![ - RpcConfig::new("http://localhost:8545".to_string()), - RpcConfig::new("http://localhost:8546".to_string()), + RpcConfig::new("http://localhost:9984".to_string()), + RpcConfig::new("http://localhost:9983".to_string()), ]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let provider_initializer = |url: &str| -> Result { Ok(url.to_string()) }; @@ -1056,8 +1069,12 @@ mod tests { let config = RetryConfig::new(2, 1, 0, 0); // 2 retries, 1 failover - // Get initial provider count + // Get initial provider count - should be 2 after clearing let initial_available_count = selector.available_provider_count(); + assert_eq!( + initial_available_count, 2, + "Both providers should be available after clearing" + ); let result: Result = retry_rpc_call( &selector, @@ -1081,12 +1098,13 @@ mod tests { #[tokio::test] async fn test_retry_rpc_call_success() { let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); let configs = vec![ RpcConfig::new("http://localhost:8545".to_string()), RpcConfig::new("http://localhost:8546".to_string()), ]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let attempts = Arc::new(AtomicU8::new(0)); let attempts_clone = attempts.clone(); @@ -1123,12 +1141,13 @@ mod tests { #[tokio::test] async fn test_retry_rpc_call_with_provider_failover() { let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); let configs = vec![ RpcConfig::new("http://localhost:8545".to_string()), RpcConfig::new("http://localhost:8546".to_string()), ]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let current_provider = Arc::new(Mutex::new(String::new())); let current_provider_clone = current_provider.clone(); @@ -1175,12 +1194,13 @@ mod tests { #[tokio::test] async fn test_retry_rpc_call_all_providers_fail() { let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); let configs = vec![ RpcConfig::new("http://localhost:8545".to_string()), RpcConfig::new("http://localhost:8546".to_string()), ]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let provider_initializer = |_: &str| -> Result { Ok("mock_provider".to_string()) }; @@ -1212,7 +1232,8 @@ mod tests { let guard = setup_test_env(); let configs = vec![RpcConfig::new("http://localhost:8545".to_string())]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = + RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); (guard, selector) }; @@ -1240,19 +1261,21 @@ mod tests { #[tokio::test] async fn test_retry_rpc_call_provider_initialization_failures() { let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); + // Use unique URLs to avoid conflicts let configs = vec![ - RpcConfig::new("http://localhost:8545".to_string()), - RpcConfig::new("http://localhost:8546".to_string()), + RpcConfig::new("http://localhost:9988".to_string()), + RpcConfig::new("http://localhost:9987".to_string()), ]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let attempt_count = Arc::new(AtomicU8::new(0)); let attempt_count_clone = attempt_count.clone(); let provider_initializer = move |url: &str| -> Result { let count = attempt_count_clone.fetch_add(1, AtomicOrdering::SeqCst); - if count == 0 && url.contains("8545") { + if count == 0 && url.contains("9988") { Err(TestError("First provider init failed".to_string())) } else { Ok(url.to_string()) @@ -1285,7 +1308,7 @@ mod tests { // Create selector with a single provider, select it, then mark it as failed let configs = vec![RpcConfig::new("http://localhost:8545".to_string())]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); // First select the provider to make it current, then mark it as failed let _ = selector.get_current_url().unwrap(); // This selects the provider @@ -1294,18 +1317,29 @@ mod tests { let provider_initializer = |url: &str| -> Result { Ok(format!("provider-{}", url)) }; - // Now get_provider should fail because the only provider is marked as failed - let result = get_provider(&selector, "test_operation", &provider_initializer); - assert!(result.is_err()); + // Even though the provider is marked as failed/paused, for a single provider + // we still select it as a last resort since there are no alternatives + let result = get_provider( + &selector, + "test_operation", + &provider_initializer, + &HashSet::new(), + ); + assert!(result.is_ok()); + let (provider, url) = result.unwrap(); + assert_eq!(url, "http://localhost:8545"); + assert_eq!(provider, "provider-http://localhost:8545"); } #[tokio::test] async fn test_last_provider_never_marked_as_failed() { let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); - // Test with a single provider - let configs = vec![RpcConfig::new("http://localhost:8545".to_string())]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + // Test with a single provider - use unique URL to avoid conflicts with other tests + let unique_url = "http://localhost:9999".to_string(); + let configs = vec![RpcConfig::new(unique_url.clone())]; + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let provider_initializer = |url: &str| -> Result { Ok(url.to_string()) }; @@ -1314,9 +1348,12 @@ mod tests { let config = RetryConfig::new(2, 1, 0, 0); // 2 retries, 1 failover - // Get initial provider count + // Get initial provider count - should be 1 after clearing let initial_available_count = selector.available_provider_count(); - assert_eq!(initial_available_count, 1); + assert_eq!( + initial_available_count, 1, + "Provider should be available after clearing health store" + ); let result: Result = retry_rpc_call( &selector, @@ -1331,29 +1368,26 @@ mod tests { assert!(result.is_err()); - // The last provider should NOT be marked as failed + // The provider should be marked as failed, but selector can still use paused providers let final_available_count = selector.available_provider_count(); assert_eq!( - final_available_count, initial_available_count, - "Last provider should never be marked as failed" - ); - assert_eq!( - final_available_count, 1, - "Should still have 1 provider available" + final_available_count, 0, + "Provider should be marked as failed, but selector can still use paused providers" ); } #[tokio::test] async fn test_last_provider_behavior_with_multiple_providers() { let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); - // Test with multiple providers, but mark all but one as failed + // Test with multiple providers - use unique URLs to avoid conflicts let configs = vec![ - RpcConfig::new("http://localhost:8545".to_string()), - RpcConfig::new("http://localhost:8546".to_string()), - RpcConfig::new("http://localhost:8547".to_string()), + RpcConfig::new("http://localhost:9991".to_string()), + RpcConfig::new("http://localhost:9990".to_string()), + RpcConfig::new("http://localhost:9989".to_string()), ]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let provider_initializer = |url: &str| -> Result { Ok(url.to_string()) }; @@ -1362,9 +1396,12 @@ mod tests { let config = RetryConfig::new(2, 2, 0, 0); // 2 retries, 2 failovers - // Get initial provider count + // Get initial provider count - should be 3 after clearing let initial_available_count = selector.available_provider_count(); - assert_eq!(initial_available_count, 3); + assert_eq!( + initial_available_count, 3, + "All 3 providers should be available after clearing" + ); let result: Result = retry_rpc_call( &selector, @@ -1379,23 +1416,25 @@ mod tests { assert!(result.is_err()); - // Should have marked 2 providers as failed, but kept the last one + // Should have marked all providers as failed, but selector can still use paused providers let final_available_count = selector.available_provider_count(); assert_eq!( - final_available_count, 1, - "Should have exactly 1 provider left (the last one should not be marked as failed)" + final_available_count, 0, + "All providers should be marked as failed, but paused providers can still be used" ); } #[tokio::test] async fn test_non_retriable_error_should_mark_provider_failed() { let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); + // Use unique URLs to avoid conflicts with other tests let configs = vec![ - RpcConfig::new("http://localhost:8545".to_string()), - RpcConfig::new("http://localhost:8546".to_string()), + RpcConfig::new("http://localhost:9995".to_string()), + RpcConfig::new("http://localhost:9994".to_string()), ]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let provider_initializer = |url: &str| -> Result { Ok(url.to_string()) }; @@ -1406,9 +1445,12 @@ mod tests { let config = RetryConfig::new(3, 1, 0, 0); - // Get initial provider count + // Get initial provider count - should be 2 after clearing let initial_available_count = selector.available_provider_count(); - assert_eq!(initial_available_count, 2); + assert_eq!( + initial_available_count, 2, + "Both providers should be available after clearing health store" + ); let result: Result = retry_rpc_call( &selector, @@ -1432,12 +1474,14 @@ mod tests { #[tokio::test] async fn test_non_retriable_error_should_not_mark_provider_failed() { let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); + // Use unique URLs to avoid conflicts with other tests let configs = vec![ - RpcConfig::new("http://localhost:8545".to_string()), - RpcConfig::new("http://localhost:8546".to_string()), + RpcConfig::new("http://localhost:9997".to_string()), + RpcConfig::new("http://localhost:9996".to_string()), ]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let provider_initializer = |url: &str| -> Result { Ok(url.to_string()) }; @@ -1448,9 +1492,12 @@ mod tests { let config = RetryConfig::new(3, 1, 0, 0); - // Get initial provider count + // Get initial provider count - should be 2 after clearing let initial_available_count = selector.available_provider_count(); - assert_eq!(initial_available_count, 2); + assert_eq!( + initial_available_count, 2, + "Both providers should be available after clearing health store" + ); let result: Result = retry_rpc_call( &selector, @@ -1474,12 +1521,14 @@ mod tests { #[tokio::test] async fn test_retriable_error_ignores_should_mark_provider_failed() { let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); + // Use unique URLs to avoid conflicts with other tests let configs = vec![ - RpcConfig::new("http://localhost:8545".to_string()), - RpcConfig::new("http://localhost:8546".to_string()), + RpcConfig::new("http://localhost:9982".to_string()), + RpcConfig::new("http://localhost:9981".to_string()), ]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let provider_initializer = |url: &str| -> Result { Ok(url.to_string()) }; @@ -1489,9 +1538,12 @@ mod tests { let config = RetryConfig::new(2, 1, 0, 0); // 2 retries, 1 failover - // Get initial provider count + // Get initial provider count - should be 2 after clearing let initial_available_count = selector.available_provider_count(); - assert_eq!(initial_available_count, 2); + assert_eq!( + initial_available_count, 2, + "Both providers should be available after clearing" + ); let result: Result = retry_rpc_call( &selector, @@ -1516,13 +1568,15 @@ mod tests { #[tokio::test] async fn test_mixed_error_scenarios_with_different_marking_behavior() { let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); // Test scenario 1: Non-retriable error that should mark provider as failed + // Use unique URLs to avoid conflicts with other tests let configs = vec![ - RpcConfig::new("http://localhost:8545".to_string()), - RpcConfig::new("http://localhost:8546".to_string()), + RpcConfig::new("http://localhost:9993".to_string()), + RpcConfig::new("http://localhost:9992".to_string()), ]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let provider_initializer = |url: &str| -> Result { Ok(url.to_string()) }; @@ -1531,6 +1585,10 @@ mod tests { let config = RetryConfig::new(1, 1, 0, 0); let initial_count = selector.available_provider_count(); + assert_eq!( + initial_count, 2, + "Both providers should be available after clearing" + ); let result: Result = retry_rpc_call( &selector, @@ -1577,10 +1635,12 @@ mod tests { #[tokio::test] async fn test_should_mark_provider_failed_respects_last_provider_protection() { let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); - // Test with a single provider (last provider protection) - let configs = vec![RpcConfig::new("http://localhost:8545".to_string())]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + // Test with a single provider - use unique URL to avoid conflicts + let unique_url = "http://localhost:9998".to_string(); + let configs = vec![RpcConfig::new(unique_url.clone())]; + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let provider_initializer = |url: &str| -> Result { Ok(url.to_string()) }; @@ -1590,9 +1650,12 @@ mod tests { let config = RetryConfig::new(1, 1, 0, 0); - // Get initial provider count + // Get initial provider count - should be 1 after clearing let initial_available_count = selector.available_provider_count(); - assert_eq!(initial_available_count, 1); + assert_eq!( + initial_available_count, 1, + "Provider should be available after clearing health store" + ); let result: Result = retry_rpc_call( &selector, @@ -1607,26 +1670,25 @@ mod tests { assert!(result.is_err()); - // Last provider should NEVER be marked as failed, even if should_mark_provider_failed returns true + // Provider should be marked as failed, but selector can still use paused providers let final_available_count = selector.available_provider_count(); - assert_eq!(final_available_count, initial_available_count, - "Last provider should never be marked as failed, regardless of should_mark_provider_failed"); assert_eq!( - final_available_count, 1, - "Should still have 1 provider available" + final_available_count, 0, + "Provider should be marked as failed, but selector can still use paused providers" ); } #[tokio::test] async fn test_should_mark_provider_failed_with_multiple_providers_last_protection() { let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); // Test with multiple providers, but ensure last one is protected let configs = vec![ RpcConfig::new("http://localhost:8545".to_string()), RpcConfig::new("http://localhost:8546".to_string()), ]; - let selector = RpcSelector::new(configs).expect("Failed to create selector"); + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); let attempt_count = Arc::new(AtomicU8::new(0)); let attempt_count_clone = attempt_count.clone(); @@ -1661,11 +1723,172 @@ mod tests { assert!(result.is_err()); - // First provider should be marked as failed, but last provider should be protected + // First provider should be marked as failed, no failover happens for non-retriable errors let final_available_count = selector.available_provider_count(); assert_eq!( final_available_count, 1, - "First provider should be marked as failed, but last provider should be protected" + "First provider should be marked as failed, second provider remains available" + ); + } + + #[tokio::test] + async fn test_tried_urls_tracking_prevents_duplicate_selection() { + let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); + + let configs = vec![ + RpcConfig::new("http://localhost:8545".to_string()), + RpcConfig::new("http://localhost:8546".to_string()), + RpcConfig::new("http://localhost:8547".to_string()), + ]; + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); + + let provider_initializer = |url: &str| -> Result { Ok(url.to_string()) }; + + // Operation that fails for first two providers, succeeds on third + let operation = |provider: String| async move { + if provider.contains("8545") { + Err(TestError("Provider 1 failed".to_string())) + } else if provider.contains("8546") { + Err(TestError("Provider 2 failed".to_string())) + } else { + Ok(42) + } + }; + + let config = RetryConfig::new(2, 10, 0, 0); // max_retries=2 means 2 attempts per provider, 10 failovers + + let result: Result = retry_rpc_call( + &selector, + "test_operation", + |_| true, // Retriable + |_| true, // Mark as failed + provider_initializer, + operation, + Some(config), + ) + .await; + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 42); + } + + #[tokio::test] + async fn test_all_providers_tried_returns_error() { + let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); + + let configs = vec![ + RpcConfig::new("http://localhost:8545".to_string()), + RpcConfig::new("http://localhost:8546".to_string()), + ]; + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); + + let provider_initializer = |url: &str| -> Result { Ok(url.to_string()) }; + + // Operation that always fails + let operation = |_provider: String| async { Err(TestError("Always fails".to_string())) }; + + let config = RetryConfig::new(2, 10, 0, 0); // max_retries=2 means 2 attempts per provider, 10 failovers (more than providers) + + let result: Result = retry_rpc_call( + &selector, + "test_operation", + |_| true, // Retriable + |_| true, // Mark as failed + provider_initializer, + operation, + Some(config), + ) + .await; + + assert!(result.is_err()); + // Should have tried both providers, both should be marked as failed + assert_eq!( + selector.available_provider_count(), + 0, + "Both providers should be marked as failed" + ); + } + + #[tokio::test] + async fn test_tried_urls_passed_to_selector() { + let _guard = setup_test_env(); + RpcHealthStore::instance().clear_all(); + + // Use unique URLs to avoid conflicts with other tests + let url1 = "http://localhost:9980".to_string(); + let url2 = "http://localhost:9979".to_string(); + let url3 = "http://localhost:9978".to_string(); + let configs = vec![ + RpcConfig::new(url1.clone()), + RpcConfig::new(url2.clone()), + RpcConfig::new(url3.clone()), + ]; + let selector = RpcSelector::new_with_defaults(configs).expect("Failed to create selector"); + + let provider_initializer = |url: &str| -> Result { Ok(url.to_string()) }; + + // Track which providers were selected + let selected_providers = Arc::new(Mutex::new(Vec::new())); + let selected_providers_clone = selected_providers.clone(); + let url3_clone = url3.clone(); + + let operation = move |provider: String| { + let selected = selected_providers_clone.clone(); + let url3 = url3_clone.clone(); + async move { + let mut selected_guard = selected.lock().unwrap(); + selected_guard.push(provider.clone()); + + // Succeed if this is the 3rd provider, fail otherwise + if provider == url3 { + Ok(42) + } else { + Err(TestError("Provider failed".to_string())) + } + } + }; + + let config = RetryConfig::new(2, 10, 0, 0); // max_retries=2 means 2 attempts per provider + + let result: Result = retry_rpc_call( + &selector, + "test_operation", + |_| true, + |_| true, + provider_initializer, + operation, + Some(config), + ) + .await; + + assert!( + result.is_ok(), + "Operation should succeed eventually, got error: {:?}", + result + ); + let selected = selected_providers.lock().unwrap(); + // Should have tried at least 1 provider (the one that succeeds) + let unique_providers: HashSet<_> = selected.iter().collect(); + assert!( + unique_providers.len() >= 1, + "Should have tried at least 1 provider: {:?}", + selected + ); + // Should have tried provider 3 (the one that succeeds) + assert!( + unique_providers.contains(&url3), + "Should have tried provider 3: {:?}", + selected + ); + // With max_retries=2, we get multiple attempts per provider + // If provider 3 is selected first and succeeds, we might only have 1 attempt + // If providers 1 or 2 are selected first, we'll have more attempts + assert!( + selected.len() >= 1, + "Should have at least 1 total attempt, got: {}", + selected.len() ); } } diff --git a/src/services/provider/rpc_health_store.rs b/src/services/provider/rpc_health_store.rs new file mode 100644 index 000000000..58d33416f --- /dev/null +++ b/src/services/provider/rpc_health_store.rs @@ -0,0 +1,705 @@ +//! RPC Health Store +//! +//! This module provides a shared in-memory store for RPC health metadata. +//! Health state is shared across all relayers using the same RPC URL. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use chrono::{DateTime, Utc}; +use once_cell::sync::Lazy; +use tracing::debug; + +/// Metadata for tracking RPC endpoint health. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RpcConfigMetadata { + /// Timestamps of recent failures. Only failures within the expiration window are kept. + /// Limited to a reasonable size (threshold * 2) to prevent unbounded growth. + pub failure_timestamps: Vec>, + /// Timestamp until which this RPC endpoint is paused due to failures. + /// If set and in the future, the endpoint is considered paused. + pub paused_until: Option>, +} + +/// Shared in-memory store for RPC health metadata. +/// +/// This store is shared across all relayers, so health state for a given RPC URL +/// is consistent across all relayers using that URL. +pub struct RpcHealthStore { + metadata: Arc>>, +} + +static HEALTH_STORE: Lazy = Lazy::new(|| RpcHealthStore { + metadata: Arc::new(RwLock::new(HashMap::new())), +}); + +impl RpcHealthStore { + /// Gets the singleton instance of the health store. + pub fn instance() -> &'static RpcHealthStore { + &HEALTH_STORE + } + + /// Gets metadata for a given RPC URL. + /// + /// Returns default (empty) metadata if the URL is not in the store. + /// + /// # Arguments + /// * `url` - The RPC endpoint URL + /// + /// # Returns + /// * `RpcConfigMetadata` - The metadata for the URL, or default if not found + pub fn get_metadata(&self, url: &str) -> RpcConfigMetadata { + let metadata = self.metadata.read().unwrap(); + metadata.get(url).cloned().unwrap_or_default() + } + + /// Updates metadata for a given RPC URL. + /// + /// # Arguments + /// * `url` - The RPC endpoint URL + /// * `metadata` - The metadata to store + pub fn update_metadata(&self, url: &str, metadata: RpcConfigMetadata) { + let mut store = self.metadata.write().unwrap(); + store.insert(url.to_string(), metadata); + } + + /// Marks an RPC endpoint as failed, adding a failure timestamp. + /// If the number of recent failures (within expiration window) reaches the threshold, pauses the endpoint. + /// Stale failures (older than failure_expiration) are automatically removed. + /// + /// # Arguments + /// * `url` - The RPC endpoint URL + /// * `threshold` - The number of failures before pausing + /// * `pause_duration` - The duration to pause for + /// * `failure_expiration` - Duration after which failures are considered stale and removed + pub fn mark_failed( + &self, + url: &str, + threshold: u32, + pause_duration: chrono::Duration, + failure_expiration: chrono::Duration, + ) { + let mut store = self.metadata.write().unwrap(); + let mut metadata = store.get(url).cloned().unwrap_or_default(); + + let now = Utc::now(); + + // Remove stale failures (older than expiration window) + metadata + .failure_timestamps + .retain(|&ts| now - ts <= failure_expiration); + + // Add current failure timestamp + metadata.failure_timestamps.push(now); + + // Limit size to prevent unbounded growth (keep slightly more than threshold for safety) + let max_size = (threshold * 2) as usize; + if metadata.failure_timestamps.len() > max_size { + // Keep only the most recent failures (they're already in chronological order) + // Remove the oldest ones + let remove_count = metadata.failure_timestamps.len() - max_size; + metadata.failure_timestamps.drain(0..remove_count); + } + + // Check if we've reached the threshold + let recent_failures = metadata.failure_timestamps.len() as u32; + let was_paused = metadata.paused_until.is_some(); + + if recent_failures >= threshold { + let paused_until = now + pause_duration; + metadata.paused_until = Some(paused_until); + + if !was_paused { + // Provider just got paused + debug!( + provider_url = %url, + failure_count = %recent_failures, + threshold = %threshold, + paused_until = %paused_until, + pause_duration_secs = %pause_duration.num_seconds(), + "RPC provider paused due to failures" + ); + } else { + // Provider was already paused, but pause duration extended + debug!( + provider_url = %url, + failure_count = %recent_failures, + threshold = %threshold, + paused_until = %paused_until, + pause_duration_secs = %pause_duration.num_seconds(), + "RPC provider pause extended due to additional failures" + ); + } + } + + store.insert(url.to_string(), metadata); + } + + /// Resets the failure count and unpauses the endpoint. + /// + /// # Arguments + /// * `url` - The RPC endpoint URL + pub fn reset_failures(&self, url: &str) { + let mut store = self.metadata.write().unwrap(); + store.remove(url); + } + + /// Resets the failure count only if the provider has failures recorded. + /// This is more efficient than `reset_failures` as it avoids write lock + /// acquisition when there are no failures. + /// + /// # Arguments + /// * `url` - The RPC endpoint URL + /// + /// # Returns + /// * `bool` - True if failures were reset, false if no failures existed + pub fn reset_failures_if_exists(&self, url: &str) -> bool { + // Fast path: check if entry exists with read lock first + let has_failures = { + let store = self.metadata.read().unwrap(); + store.contains_key(url) + }; + + if has_failures { + let mut store = self.metadata.write().unwrap(); + store.remove(url); + true + } else { + false + } + } + + /// Checks if an RPC endpoint is currently paused. + /// + /// An endpoint is considered paused if: + /// - It has reached the failure threshold (within expiration window) AND + /// - It has a paused_until timestamp that is in the future + /// + /// Stale failures (older than failure_expiration) are automatically removed + /// to allow the provider to be retried. + /// + /// # Arguments + /// * `url` - The RPC endpoint URL + /// * `threshold` - The failure threshold to check against + /// * `failure_expiration` - Duration after which failures are considered stale and removed + /// + /// # Returns + /// * `bool` - True if the endpoint is paused, false otherwise + pub fn is_paused( + &self, + url: &str, + threshold: u32, + failure_expiration: chrono::Duration, + ) -> bool { + let mut metadata_guard = self.metadata.write().unwrap(); + if let Some(meta) = metadata_guard.get_mut(url) { + let now = Utc::now(); + + // Remove stale failures (older than expiration window) + meta.failure_timestamps + .retain(|&ts| now - ts <= failure_expiration); + + // If pause has expired, clear it + if let Some(paused_until) = meta.paused_until { + if now >= paused_until { + // Pause expired - clear pause (but keep failure timestamps for tracking) + debug!( + provider_url = %url, + paused_until = %paused_until, + current_time = %now, + remaining_failures = %meta.failure_timestamps.len(), + "RPC provider pause expired, provider available again" + ); + meta.paused_until = None; + // If no recent failures remain, clear everything + if meta.failure_timestamps.is_empty() { + metadata_guard.remove(url); + } + return false; + } + } + + // Check if paused: must have reached threshold AND be within pause window + let recent_failures = meta.failure_timestamps.len() as u32; + if recent_failures >= threshold { + if let Some(paused_until) = meta.paused_until { + return now < paused_until; + } + // If we've reached threshold but no pause_until is set, not paused + return false; + } + + // If no recent failures remain, remove the entry + if meta.failure_timestamps.is_empty() && meta.paused_until.is_none() { + metadata_guard.remove(url); + } + } + false + } + + /// Clears all metadata from the store. + /// Primarily useful for testing. + #[cfg(test)] + pub fn clear_all(&self) { + let mut store = self.metadata.write().unwrap(); + store.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_metadata_returns_default_when_not_found() { + let store = RpcHealthStore::instance(); + store.clear_all(); + + // Use a unique URL to avoid interference from other tests + let url = "https://test-get-metadata.example.com"; + let metadata = store.get_metadata(url); + assert_eq!(metadata, RpcConfigMetadata::default()); + assert_eq!(metadata.failure_timestamps.len(), 0); + assert_eq!(metadata.paused_until, None); + } + + #[test] + fn test_update_and_get_metadata() { + let store = RpcHealthStore::instance(); + store.clear_all(); + + let url = "https://test-update-metadata.example.com"; + let mut metadata = RpcConfigMetadata::default(); + metadata.failure_timestamps.push(Utc::now()); + metadata.failure_timestamps.push(Utc::now()); + metadata.failure_timestamps.push(Utc::now()); + + store.update_metadata(url, metadata.clone()); + + let retrieved = store.get_metadata(url); + assert_eq!( + retrieved.failure_timestamps.len(), + metadata.failure_timestamps.len() + ); + } + + #[test] + fn test_mark_failed_increments_count() { + let store = RpcHealthStore::instance(); + store.clear_all(); + + // Use a unique URL to avoid interference + let url = "https://test-increment-count.example.com"; + let expiration = chrono::Duration::seconds(60); + let threshold = 3; + + // First failure + store.mark_failed(url, threshold, chrono::Duration::seconds(60), expiration); + let metadata = store.get_metadata(url); + assert_eq!( + metadata.failure_timestamps.len(), + 1, + "Should have 1 failure after first mark" + ); + assert!(metadata.paused_until.is_none(), "Should not be paused yet"); + // Check pause status after verifying metadata + assert!( + !store.is_paused(url, threshold, expiration), + "Should not be paused with 1 failure" + ); + // Verify metadata still exists after is_paused call + let metadata_after = store.get_metadata(url); + assert_eq!( + metadata_after.failure_timestamps.len(), + 1, + "Should still have 1 failure" + ); + + // Second failure + store.mark_failed(url, threshold, chrono::Duration::seconds(60), expiration); + let metadata = store.get_metadata(url); + assert_eq!( + metadata.failure_timestamps.len(), + 2, + "Should have 2 failures after second mark" + ); + assert!(metadata.paused_until.is_none(), "Should not be paused yet"); + assert!( + !store.is_paused(url, threshold, expiration), + "Should not be paused with 2 failures" + ); + let metadata_after = store.get_metadata(url); + assert_eq!( + metadata_after.failure_timestamps.len(), + 2, + "Should still have 2 failures" + ); + + // Third failure - should pause + store.mark_failed(url, threshold, chrono::Duration::seconds(60), expiration); + let metadata = store.get_metadata(url); + assert_eq!( + metadata.failure_timestamps.len(), + 3, + "Should have 3 failures after third mark" + ); + assert!( + metadata.paused_until.is_some(), + "Should be paused after reaching threshold" + ); + assert!( + store.is_paused(url, threshold, expiration), + "Should be paused" + ); + let metadata_after = store.get_metadata(url); + assert_eq!( + metadata_after.failure_timestamps.len(), + 3, + "Should still have 3 failures" + ); + assert!( + metadata_after.paused_until.is_some(), + "Should still be paused" + ); + } + + #[test] + fn test_reset_failures() { + let store = RpcHealthStore::instance(); + store.clear_all(); + + let url = "https://test-reset-failures.example.com"; + let expiration = chrono::Duration::seconds(60); + let threshold = 3; + + // Mark failed 3 times to trigger pause + store.mark_failed(url, threshold, chrono::Duration::seconds(60), expiration); + store.mark_failed(url, threshold, chrono::Duration::seconds(60), expiration); + store.mark_failed(url, threshold, chrono::Duration::seconds(60), expiration); + assert!(store.is_paused(url, threshold, expiration)); + + store.reset_failures(url); + assert!(!store.is_paused(url, threshold, expiration)); + let metadata = store.get_metadata(url); + assert_eq!(metadata, RpcConfigMetadata::default()); + } + + #[test] + fn test_is_paused_with_failure_count_below_threshold() { + let store = RpcHealthStore::instance(); + store.clear_all(); + + let url = "https://test-below-threshold.example.com"; + let expiration = chrono::Duration::seconds(60); + let mut metadata = RpcConfigMetadata::default(); + metadata.failure_timestamps.push(Utc::now()); + store.update_metadata(url, metadata); + + // Should not be paused if below threshold + assert!(!store.is_paused(url, 3, expiration)); + } + + #[test] + fn test_is_paused_with_time_based_pause() { + let store = RpcHealthStore::instance(); + store.clear_all(); + + let url = "https://test-time-based-pause.example.com"; + let expiration = chrono::Duration::seconds(60); + let mut metadata = RpcConfigMetadata::default(); + // Add 3 failures to reach threshold + metadata.failure_timestamps.push(Utc::now()); + metadata.failure_timestamps.push(Utc::now()); + metadata.failure_timestamps.push(Utc::now()); + metadata.paused_until = Some(Utc::now() + chrono::Duration::seconds(60)); + store.update_metadata(url, metadata); + + assert!(store.is_paused(url, 3, expiration)); + } + + #[test] + fn test_is_paused_expires_after_time() { + let store = RpcHealthStore::instance(); + store.clear_all(); + + let url = "https://test-expires-after-time.example.com"; + let expiration = chrono::Duration::seconds(60); + let mut metadata = RpcConfigMetadata::default(); + // Set pause to expire in the past + metadata.paused_until = Some(Utc::now() - chrono::Duration::seconds(60)); + store.update_metadata(url, metadata); + + // Should not be paused if pause time has expired + assert!(!store.is_paused(url, 3, expiration)); + } + + #[test] + fn test_shared_state_across_instances() { + let store1 = RpcHealthStore::instance(); + let store2 = RpcHealthStore::instance(); + store1.clear_all(); + + let url = "https://test-shared-state.example.com"; + let expiration = chrono::Duration::seconds(60); + let threshold = 3; + + // Mark failed 3 times to trigger pause + store1.mark_failed(url, threshold, chrono::Duration::seconds(60), expiration); + store1.mark_failed(url, threshold, chrono::Duration::seconds(60), expiration); + store1.mark_failed(url, threshold, chrono::Duration::seconds(60), expiration); + + // Both instances should see the same state + assert!(store1.is_paused(url, threshold, expiration)); + assert!(store2.is_paused(url, threshold, expiration)); + + let metadata1 = store1.get_metadata(url); + let metadata2 = store2.get_metadata(url); + assert_eq!( + metadata1.failure_timestamps.len(), + metadata2.failure_timestamps.len() + ); + } + + #[test] + fn test_stale_failures_are_expired() { + let store = RpcHealthStore::instance(); + store.clear_all(); + + let url = "https://test-stale-failures.example.com"; + let expiration = chrono::Duration::seconds(60); + + // Add failures that are old (outside expiration window) + let mut metadata = RpcConfigMetadata::default(); + metadata + .failure_timestamps + .push(Utc::now() - chrono::Duration::seconds(120)); // 2 minutes ago + metadata + .failure_timestamps + .push(Utc::now() - chrono::Duration::seconds(90)); // 1.5 minutes ago + store.update_metadata(url, metadata); + + // Old failures should be expired when checking pause status + assert!(!store.is_paused(url, 3, expiration)); + + // Metadata should be cleaned up (no recent failures) + let metadata = store.get_metadata(url); + assert_eq!(metadata.failure_timestamps.len(), 0); + } + + #[test] + fn test_failure_timestamps_size_limit() { + let store = RpcHealthStore::instance(); + store.clear_all(); + + let url = "https://test-size-limit.example.com"; + let expiration = chrono::Duration::seconds(60); + let threshold = 3; + + // Add many failures quickly (within expiration window) + for _ in 0..10 { + store.mark_failed(url, threshold, chrono::Duration::seconds(60), expiration); + } + + let metadata = store.get_metadata(url); + // Should be limited to threshold * 2 = 6 entries + assert!(metadata.failure_timestamps.len() <= (threshold * 2) as usize); + } + + #[test] + fn test_mixed_stale_and_recent_failures() { + let store = RpcHealthStore::instance(); + store.clear_all(); + + let url = "https://test-mixed-failures.example.com"; + let expiration = chrono::Duration::seconds(60); + let threshold = 3; + + // Add old failures manually + let mut metadata = RpcConfigMetadata::default(); + metadata + .failure_timestamps + .push(Utc::now() - chrono::Duration::seconds(120)); // Stale + metadata + .failure_timestamps + .push(Utc::now() - chrono::Duration::seconds(90)); // Stale + store.update_metadata(url, metadata); + + // Add recent failures - mark_failed will remove stale ones first + store.mark_failed(url, threshold, chrono::Duration::seconds(60), expiration); + store.mark_failed(url, threshold, chrono::Duration::seconds(60), expiration); + + let metadata = store.get_metadata(url); + // Should only have 2 recent failures (stale ones removed during mark_failed) + assert_eq!(metadata.failure_timestamps.len(), 2); + assert!(!store.is_paused(url, threshold, expiration)); // Below threshold + } + + #[test] + fn test_pause_extension_when_already_paused() { + let store = RpcHealthStore::instance(); + store.clear_all(); + + // Use a unique URL to avoid interference + let url = "https://test-pause-extension.example.com"; + let expiration = chrono::Duration::seconds(60); + let pause_duration = chrono::Duration::seconds(60); + let threshold = 3; + + // Mark failed 3 times to trigger pause + store.mark_failed(url, threshold, pause_duration, expiration); + store.mark_failed(url, threshold, pause_duration, expiration); + store.mark_failed(url, threshold, pause_duration, expiration); + + // Verify pause was set (check metadata before calling is_paused) + let metadata1 = store.get_metadata(url); + assert_eq!( + metadata1.failure_timestamps.len(), + 3, + "Should have 3 failures" + ); + assert!( + metadata1.paused_until.is_some(), + "Should be paused after 3 failures" + ); + let initial_paused_until = metadata1.paused_until.unwrap(); + + // Verify it's actually paused + assert!( + store.is_paused(url, threshold, expiration), + "Should be paused" + ); + // Verify metadata still exists after is_paused + let metadata1_after = store.get_metadata(url); + assert_eq!( + metadata1_after.failure_timestamps.len(), + 3, + "Should still have 3 failures" + ); + + // Wait a bit (simulate time passing) + std::thread::sleep(std::time::Duration::from_millis(10)); + + // Mark failed again while already paused - should extend pause + store.mark_failed(url, threshold, pause_duration, expiration); + + let metadata2 = store.get_metadata(url); + assert_eq!( + metadata2.failure_timestamps.len(), + 4, + "Should have 4 failures now" + ); + assert!( + metadata2.paused_until.is_some(), + "Should still be paused after 4th failure" + ); + let new_paused_until = metadata2.paused_until.unwrap(); + + // Pause should be extended (new paused_until should be later) + assert!( + new_paused_until > initial_paused_until, + "Pause should be extended" + ); + assert!( + store.is_paused(url, threshold, expiration), + "Should still be paused" + ); + } + + #[test] + fn test_stale_failures_removed_during_mark_failed() { + let store = RpcHealthStore::instance(); + store.clear_all(); + + let url = "https://test-stale-removed.example.com"; + let expiration = chrono::Duration::seconds(60); + + // Add old failures manually + let mut metadata = RpcConfigMetadata::default(); + metadata + .failure_timestamps + .push(Utc::now() - chrono::Duration::seconds(120)); // Stale + metadata + .failure_timestamps + .push(Utc::now() - chrono::Duration::seconds(90)); // Stale + store.update_metadata(url, metadata); + + // Mark failed - should remove stale failures and add new one + store.mark_failed(url, 3, chrono::Duration::seconds(60), expiration); + + let metadata = store.get_metadata(url); + // Should only have 1 failure (stale ones removed, new one added) + assert_eq!(metadata.failure_timestamps.len(), 1); + // Verify the remaining failure is recent + let remaining_failure = metadata.failure_timestamps[0]; + let age = Utc::now() - remaining_failure; + assert!(age.num_seconds() < 5); // Should be very recent + } + + #[test] + fn test_pause_expiration_cleans_up_metadata() { + let store = RpcHealthStore::instance(); + store.clear_all(); + + let url = "https://test-pause-expiration-cleanup.example.com"; + let expiration = chrono::Duration::seconds(60); + + // Create metadata with expired pause but no recent failures + let mut metadata = RpcConfigMetadata::default(); + metadata.paused_until = Some(Utc::now() - chrono::Duration::seconds(10)); // Expired + store.update_metadata(url, metadata); + + // Check pause status - should expire and clean up + assert!(!store.is_paused(url, 3, expiration)); + + // Metadata should be removed since pause expired and no failures remain + let metadata_after = store.get_metadata(url); + assert_eq!(metadata_after, RpcConfigMetadata::default()); + } + + #[test] + fn test_pause_expiration_keeps_recent_failures() { + let store = RpcHealthStore::instance(); + store.clear_all(); + + // Use a unique URL to avoid interference + let url = "https://test-pause-expiration.example.com"; + let expiration = chrono::Duration::seconds(60); + let threshold = 3; + + // Create metadata with expired pause but recent failures + // We need at least threshold failures to have been paused, but pause is now expired + let mut metadata = RpcConfigMetadata::default(); + // Add threshold failures (but they're recent, not stale) + metadata + .failure_timestamps + .push(Utc::now() - chrono::Duration::seconds(30)); // Recent + metadata + .failure_timestamps + .push(Utc::now() - chrono::Duration::seconds(25)); // Recent + metadata + .failure_timestamps + .push(Utc::now() - chrono::Duration::seconds(20)); // Recent + metadata.paused_until = Some(Utc::now() - chrono::Duration::seconds(10)); // Expired pause + store.update_metadata(url, metadata); + + // Check pause status - should expire pause but keep failures + // Note: is_paused will modify the metadata (remove expired pause) + // Since we have threshold failures but pause is expired, should return false + assert!( + !store.is_paused(url, threshold, expiration), + "Should not be paused when pause expired" + ); + + // Metadata should still exist with failures but no pause + let metadata_after = store.get_metadata(url); + assert_eq!( + metadata_after.failure_timestamps.len(), + 3, + "Should keep all recent failures" + ); + assert!( + metadata_after.paused_until.is_none(), + "Pause should be cleared" + ); + } +} diff --git a/src/services/provider/rpc_selector.rs b/src/services/provider/rpc_selector.rs index 85a59841e..f95889698 100644 --- a/src/services/provider/rpc_selector.rs +++ b/src/services/provider/rpc_selector.rs @@ -12,7 +12,6 @@ use std::collections::HashSet; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; -use std::time::Duration; use eyre::Result; use parking_lot::RwLock; @@ -20,9 +19,10 @@ use rand::distr::weighted::WeightedIndex; use rand::prelude::*; use serde::Serialize; use thiserror::Error; -use tokio::time::Instant; +use tracing::info; use crate::models::RpcConfig; +use crate::services::provider::rpc_health_store::RpcHealthStore; #[derive(Error, Debug, Serialize)] pub enum RpcSelectorError { @@ -36,86 +36,52 @@ pub enum RpcSelectorError { AllProvidersFailed, } -// Provider health tracking struct -#[derive(Debug)] -struct ProviderHealth { - // Maps the index of each failed provider to the timestamp (Instant) when it will become available for use again. - failed_provider_reset_times: std::collections::HashMap, - // The amount of time a provider remains unavailable after being marked as failed. - reset_duration: Duration, -} - -impl ProviderHealth { - // Create a new ProviderHealth tracker with a given reset duration - fn new(reset_duration: Duration) -> Self { - Self { - failed_provider_reset_times: std::collections::HashMap::new(), - reset_duration, - } - } - - // Mark a provider as failed - fn mark_failed(&mut self, index: usize) { - let reset_time = Instant::now() + self.reset_duration; - self.failed_provider_reset_times.insert(index, reset_time); - } - - // Check if a provider is marked as failed and handle auto-reset if needed - fn is_failed(&mut self, index: usize) -> bool { - if let Some(reset_time) = self.failed_provider_reset_times.get(&index) { - if Instant::now() >= *reset_time { - // Time has passed, remove from failed set (auto-reset for this provider) - self.failed_provider_reset_times.remove(&index); - return false; - } - return true; - } - false - } - - // Reset all failed providers - fn reset(&mut self) { - self.failed_provider_reset_times.clear(); - } - - // Get the number of failed providers whose reset time has not yet passed. - fn failed_count(&self) -> usize { - self.failed_provider_reset_times - .values() - .filter(|&&reset_time| Instant::now() < reset_time) - .count() - } -} - /// Manages selection of RPC endpoints based on configuration. #[derive(Debug)] pub struct RpcSelector { /// RPC configurations - configs: Vec, + configs: Arc>>, /// Pre-computed weighted distribution for faster provider selection weights_dist: Option>>, /// Counter for round-robin selection as a fallback or for equal weights next_index: Arc, - /// Health tracking for providers - health: Arc>, /// Currently selected provider index current_index: Arc, /// Flag indicating whether a current provider is valid has_current: Arc, + /// Number of consecutive failures before pausing a provider + failure_threshold: u32, + /// Duration in seconds to pause a provider after reaching failure threshold + pause_duration_secs: u64, + /// Duration in seconds after which failures are considered stale and reset + failure_expiration_secs: u64, } -// Auto-reset duration for failed providers (5 minutes) -const DEFAULT_PROVIDER_RESET_DURATION: Duration = Duration::from_secs(300); - impl RpcSelector { /// Creates a new RpcSelector instance. /// /// # Arguments - /// * `configs` - A vector of RPC configurations (URL and weight) + /// * `configs` - RPC configurations + /// * `failure_threshold` - Number of consecutive failures before pausing a provider. + /// Defaults to [`DEFAULT_PROVIDER_FAILURE_THRESHOLD`] if not provided via env var `PROVIDER_FAILURE_THRESHOLD`. + /// * `pause_duration_secs` - Duration in seconds to pause a provider after reaching failure threshold. + /// Defaults to [`DEFAULT_PROVIDER_PAUSE_DURATION_SECS`] if not provided via env var `PROVIDER_PAUSE_DURATION_SECS`. + /// * `failure_expiration_secs` - Duration in seconds after which failures are considered stale and reset. + /// Defaults to [`DEFAULT_PROVIDER_FAILURE_EXPIRATION_SECS`] (60 seconds). /// /// # Returns /// * `Result` - A new selector instance or an error - pub fn new(configs: Vec) -> Result { + /// + /// # Note + /// These values are typically loaded from `ServerConfig::from_env()` which reads from environment variables: + /// - `PROVIDER_FAILURE_THRESHOLD` (default: 3, legacy: `RPC_FAILURE_THRESHOLD`) + /// - `PROVIDER_PAUSE_DURATION_SECS` (default: 60, legacy: `RPC_PAUSE_DURATION_SECS`) + pub fn new( + configs: Vec, + failure_threshold: u32, + pause_duration_secs: u64, + failure_expiration_secs: u64, + ) -> Result { if configs.is_empty() { return Err(RpcSelectorError::NoProviders); } @@ -123,17 +89,44 @@ impl RpcSelector { // Create the weights distribution based on provided weights let weights_dist = Self::create_weights_distribution(&configs, &HashSet::new()); - // Initialize health tracker with default reset duration - let health = ProviderHealth::new(DEFAULT_PROVIDER_RESET_DURATION); - - Ok(Self { - configs, + let selector = Self { + configs: Arc::new(RwLock::new(configs)), weights_dist, next_index: Arc::new(AtomicUsize::new(0)), - health: Arc::new(RwLock::new(health)), current_index: Arc::new(AtomicUsize::new(0)), has_current: Arc::new(AtomicBool::new(false)), // Initially no current provider - }) + failure_threshold, + pause_duration_secs, + failure_expiration_secs, + }; + + // Randomize the starting index to avoid always starting with the same provider + let mut rng = rand::rng(); + selector.next_index.store( + rng.random_range(0..selector.configs.read().len()), + Ordering::Relaxed, + ); + + Ok(selector) + } + + /// Creates a new RpcSelector instance with default failure threshold and pause duration. + /// + /// This is a convenience method primarily for testing. In production code, use `new()` with + /// values from `ServerConfig::from_env()`. + /// + /// # Arguments + /// * `configs` - RPC configurations + /// + /// # Returns + /// * `Result` - A new selector instance or an error + pub fn new_with_defaults(configs: Vec) -> Result { + Self::new( + configs, + crate::config::ServerConfig::get_provider_failure_threshold(), + crate::config::ServerConfig::get_provider_pause_duration_secs(), + crate::config::ServerConfig::get_provider_failure_expiration_secs(), + ) } /// Gets the number of available providers @@ -141,16 +134,29 @@ impl RpcSelector { /// # Returns /// * `usize` - The number of providers in the selector pub fn provider_count(&self) -> usize { - self.configs.len() + self.configs.read().len() } - /// Gets the number of available (non-failed) providers + /// Gets the number of available (non-paused) providers /// /// # Returns - /// * `usize` - The number of non-failed providers + /// * `usize` - The number of non-paused providers pub fn available_provider_count(&self) -> usize { - let health = self.health.read(); - self.configs.len() - health.failed_count() + let health_store = RpcHealthStore::instance(); + let expiration = chrono::Duration::seconds(self.failure_expiration_secs as i64); + self.configs + .read() + .iter() + .filter(|c| !health_store.is_paused(&c.url, self.failure_threshold, expiration)) + .count() + } + + /// Gets the current RPC configurations. + /// + /// # Returns + /// * `Vec` - The current configurations + pub fn get_configs(&self) -> Vec { + self.configs.read().clone() } /// Marks the current endpoint as failed and forces selection of a different endpoint. @@ -158,30 +164,33 @@ impl RpcSelector { /// This method is used when a provider consistently fails, and we want to try a different one. /// It adds the current provider to the failed providers set and will avoid selecting it again. pub fn mark_current_as_failed(&self) { + info!("Marking current provider as failed"); // Only proceed if we have a current provider if self.has_current.load(Ordering::Relaxed) { let current = self.current_index.load(Ordering::Relaxed); - - // Mark this provider as failed - let mut health = self.health.write(); - health.mark_failed(current); + let configs = self.configs.read(); + let config = &configs[current]; + + // Mark this provider as failed in the health store + let health_store = RpcHealthStore::instance(); + use chrono::Duration; + health_store.mark_failed( + &config.url, + self.failure_threshold, + Duration::seconds(self.pause_duration_secs as i64), + Duration::seconds(self.failure_expiration_secs as i64), + ); // Clear the current provider self.has_current.store(false, Ordering::Relaxed); // Move round-robin index forward to avoid selecting the same provider again - if self.configs.len() > 1 { + if configs.len() > 1 { self.next_index.fetch_add(1, Ordering::Relaxed); } } } - /// Resets the failed providers set, making all providers available again. - pub fn reset_failed_providers(&self) { - let mut health = self.health.write(); - health.reset(); - } - /// Creates a weighted distribution for selecting RPC endpoints based on their weights. /// /// # Arguments @@ -234,57 +243,84 @@ impl RpcSelector { } /// Gets the URL of the next RPC endpoint based on the selection strategy. - fn select_url(&self) -> Result<&str, RpcSelectorError> { - if self.configs.is_empty() { + /// + /// This method first tries to select non-paused providers. If no non-paused providers + /// are available, it falls back to paused providers as a last resort, since they might + /// have recovered. + /// + /// # Arguments + /// * `excluded_urls` - URLs of providers that have already been tried in the current failover cycle + fn select_url_internal( + &self, + excluded_urls: &std::collections::HashSet, + ) -> Result { + let configs = self.configs.read(); + if configs.is_empty() { return Err(RpcSelectorError::NoProviders); } + let health_store = RpcHealthStore::instance(); + let expiration = chrono::Duration::seconds(self.failure_expiration_secs as i64); + // For a single provider, handle special case - if self.configs.len() == 1 { - let mut health = self.health.write(); - if health.is_failed(0) { - // is_failed will attempt auto-reset for provider 0 + if configs.len() == 1 { + // Skip providers with zero weight + if configs[0].get_weight() == 0 { return Err(RpcSelectorError::AllProvidersFailed); } - - // Set as current + // Skip if already tried + if excluded_urls.contains(&configs[0].url) { + return Err(RpcSelectorError::AllProvidersFailed); + } + // Even if paused, try it as last resort self.current_index.store(0, Ordering::Relaxed); self.has_current.store(true, Ordering::Relaxed); - return Ok(&self.configs[0].url); + return Ok(configs[0].url.clone()); } + // First, try to find a non-paused provider // Try weighted selection first if available if let Some(dist) = &self.weights_dist { let mut rng = rand::rng(); - let mut health = self.health.write(); - // Try a limited number of times to find a non-failed provider with weighted selection - const MAX_ATTEMPTS: usize = 5; + // Try a limited number of times to find a non-paused provider with weighted selection + const MAX_ATTEMPTS: usize = 10; for _ in 0..MAX_ATTEMPTS { let index = dist.sample(&mut rng); - if !health.is_failed(index) { + // Skip providers with zero weight + if configs[index].get_weight() == 0 { + continue; + } + // Skip providers already tried in this failover cycle + if excluded_urls.contains(&configs[index].url) { + continue; + } + if !health_store.is_paused(&configs[index].url, self.failure_threshold, expiration) + { self.current_index.store(index, Ordering::Relaxed); self.has_current.store(true, Ordering::Relaxed); - return Ok(&self.configs[index].url); + return Ok(configs[index].url.clone()); } } - // If we couldn't find a provider after multiple attempts, fall back to round-robin } - // Fall back to round-robin selection - let len = self.configs.len(); + // Fall back to round-robin selection for non-paused providers + let len = configs.len(); let start_index = self.next_index.load(Ordering::Relaxed) % len; - // Find the next available (non-failed) provider + // Find the next available (non-paused) provider for i in 0..len { let index = (start_index + i) % len; // Skip providers with zero weight - if self.configs[index].get_weight() == 0 { + if configs[index].get_weight() == 0 { + continue; + } + // Skip providers already tried in this failover cycle + if excluded_urls.contains(&configs[index].url) { continue; } - let mut health = self.health.write(); - if !health.is_failed(index) { + if !health_store.is_paused(&configs[index].url, self.failure_threshold, expiration) { // Update the next_index atomically to point after this provider self.next_index.store((index + 1) % len, Ordering::Relaxed); @@ -292,11 +328,56 @@ impl RpcSelector { self.current_index.store(index, Ordering::Relaxed); self.has_current.store(true, Ordering::Relaxed); - return Ok(&self.configs[index].url); + return Ok(configs[index].url.clone()); } } - // If we get here, all providers must have failed + // If we get here, no non-paused providers are available + // Fall back to paused providers as a last resort + tracing::warn!( + "No non-paused providers available, falling back to paused providers as last resort" + ); + + // Try weighted selection for paused providers + if let Some(dist) = &self.weights_dist { + let mut rng = rand::rng(); + const MAX_ATTEMPTS: usize = 10; + for _ in 0..MAX_ATTEMPTS { + let index = dist.sample(&mut rng); + // Skip providers with zero weight + if configs[index].get_weight() == 0 { + continue; + } + // Skip providers already tried in this failover cycle + if excluded_urls.contains(&configs[index].url) { + continue; + } + // Accept paused providers as last resort + self.current_index.store(index, Ordering::Relaxed); + self.has_current.store(true, Ordering::Relaxed); + return Ok(configs[index].url.clone()); + } + } + + // Fall back to round-robin for paused providers + for i in 0..len { + let index = (start_index + i) % len; + // Skip providers with zero weight + if configs[index].get_weight() == 0 { + continue; + } + // Skip providers already tried in this failover cycle + if excluded_urls.contains(&configs[index].url) { + continue; + } + // Accept paused providers as last resort + self.next_index.store((index + 1) % len, Ordering::Relaxed); + self.current_index.store(index, Ordering::Relaxed); + self.has_current.store(true, Ordering::Relaxed); + return Ok(configs[index].url.clone()); + } + + // If we get here, all providers have zero weight (shouldn't happen in practice) Err(RpcSelectorError::AllProvidersFailed) } @@ -305,23 +386,46 @@ impl RpcSelector { /// # Returns /// * `Result` - The URL of the current provider, or an error pub fn get_current_url(&self) -> Result { - self.select_url().map(|url| url.to_string()) + self.select_url_internal(&std::collections::HashSet::new()) + } + + /// Gets the URL of the next RPC endpoint, excluding providers that have already been tried. + /// + /// # Arguments + /// * `excluded_urls` - URLs of providers that have already been tried in the current failover cycle + /// + /// # Returns + /// * `Result` - The URL of the next provider, or an error + pub fn get_next_url( + &self, + excluded_urls: &std::collections::HashSet, + ) -> Result { + self.select_url_internal(excluded_urls) + } + + /// Gets the URL of the next RPC endpoint (for backward compatibility in tests). + /// This method doesn't exclude any providers - use `get_next_url()` with excluded URLs in production code. + #[cfg(test)] + pub fn select_url(&self) -> Result { + self.select_url_internal(&std::collections::HashSet::new()) } /// Gets a client for the selected RPC endpoint. /// /// # Arguments /// * `initializer` - A function that takes a URL string and returns a `Result` + /// * `excluded_urls` - URLs of providers that have already been tried in the current failover cycle /// /// # Returns /// * `Result` - The client instance or an error pub fn get_client( &self, initializer: impl Fn(&str) -> Result, + excluded_urls: &std::collections::HashSet, ) -> Result { - let url = self.select_url()?; + let url = self.select_url_internal(excluded_urls)?; - initializer(url).map_err(|e| RpcSelectorError::ClientInitializationError(e.to_string())) + initializer(&url).map_err(|e| RpcSelectorError::ClientInitializationError(e.to_string())) } } @@ -329,12 +433,14 @@ impl RpcSelector { impl Clone for RpcSelector { fn clone(&self) -> Self { Self { - configs: self.configs.clone(), + configs: Arc::new(RwLock::new(self.configs.read().clone())), weights_dist: self.weights_dist.clone(), next_index: Arc::clone(&self.next_index), - health: Arc::clone(&self.health), current_index: Arc::clone(&self.current_index), has_current: Arc::clone(&self.has_current), + failure_threshold: self.failure_threshold, + pause_duration_secs: self.pause_duration_secs, + failure_expiration_secs: self.failure_expiration_secs, } } } @@ -342,6 +448,8 @@ impl Clone for RpcSelector { #[cfg(test)] mod tests { use super::*; + use crate::services::provider::rpc_health_store::RpcHealthStore; + use std::sync::Arc; use std::thread; #[test] @@ -349,6 +457,7 @@ mod tests { let configs = vec![RpcConfig { url: "https://example.com/rpc".to_string(), weight: 1, + ..Default::default() }]; let excluded = HashSet::new(); @@ -362,14 +471,17 @@ mod tests { RpcConfig { url: "https://example1.com/rpc".to_string(), weight: 5, + ..Default::default() }, RpcConfig { url: "https://example2.com/rpc".to_string(), weight: 5, + ..Default::default() }, RpcConfig { url: "https://example3.com/rpc".to_string(), weight: 5, + ..Default::default() }, ]; @@ -384,14 +496,17 @@ mod tests { RpcConfig { url: "https://example1.com/rpc".to_string(), weight: 1, + ..Default::default() }, RpcConfig { url: "https://example2.com/rpc".to_string(), weight: 2, + ..Default::default() }, RpcConfig { url: "https://example3.com/rpc".to_string(), weight: 3, + ..Default::default() }, ]; @@ -406,14 +521,17 @@ mod tests { RpcConfig { url: "https://example1.com/rpc".to_string(), weight: 1, + ..Default::default() }, RpcConfig { url: "https://example2.com/rpc".to_string(), weight: 2, + ..Default::default() }, RpcConfig { url: "https://example3.com/rpc".to_string(), weight: 3, + ..Default::default() }, ]; @@ -433,7 +551,7 @@ mod tests { #[test] fn test_rpc_selector_new_empty_configs() { let configs: Vec = vec![]; - let result = RpcSelector::new(configs); + let result = RpcSelector::new_with_defaults(configs); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), RpcSelectorError::NoProviders)); } @@ -443,9 +561,10 @@ mod tests { let configs = vec![RpcConfig { url: "https://example.com/rpc".to_string(), weight: 1, + ..Default::default() }]; - let result = RpcSelector::new(configs); + let result = RpcSelector::new_with_defaults(configs); assert!(result.is_ok()); let selector = result.unwrap(); assert!(selector.weights_dist.is_none()); @@ -457,14 +576,16 @@ mod tests { RpcConfig { url: "https://example1.com/rpc".to_string(), weight: 5, + ..Default::default() }, RpcConfig { url: "https://example2.com/rpc".to_string(), weight: 5, + ..Default::default() }, ]; - let result = RpcSelector::new(configs); + let result = RpcSelector::new_with_defaults(configs); assert!(result.is_ok()); let selector = result.unwrap(); assert!(selector.weights_dist.is_none()); @@ -476,14 +597,16 @@ mod tests { RpcConfig { url: "https://example1.com/rpc".to_string(), weight: 1, + ..Default::default() }, RpcConfig { url: "https://example2.com/rpc".to_string(), weight: 3, + ..Default::default() }, ]; - let result = RpcSelector::new(configs); + let result = RpcSelector::new_with_defaults(configs); assert!(result.is_ok()); let selector = result.unwrap(); assert!(selector.weights_dist.is_some()); @@ -494,9 +617,10 @@ mod tests { let configs = vec![RpcConfig { url: "https://example.com/rpc".to_string(), weight: 1, + ..Default::default() }]; - let selector = RpcSelector::new(configs).unwrap(); + let selector = RpcSelector::new_with_defaults(configs).unwrap(); let result = selector.select_url(); assert!(result.is_ok()); assert_eq!(result.unwrap(), "https://example.com/rpc"); @@ -509,14 +633,16 @@ mod tests { RpcConfig { url: "https://example1.com/rpc".to_string(), weight: 1, + ..Default::default() }, RpcConfig { url: "https://example2.com/rpc".to_string(), weight: 1, + ..Default::default() }, ]; - let selector = RpcSelector::new(configs).unwrap(); + let selector = RpcSelector::new_with_defaults(configs).unwrap(); // First call should return the first URL let first_url = selector.select_url().unwrap(); @@ -535,14 +661,15 @@ mod tests { let configs = vec![RpcConfig { url: "https://example.com/rpc".to_string(), weight: 1, + ..Default::default() }]; - let selector = RpcSelector::new(configs).unwrap(); + let selector = RpcSelector::new_with_defaults(configs).unwrap(); // Create a simple initializer function that returns the URL as a string let initializer = |url: &str| -> Result { Ok(url.to_string()) }; - let result = selector.get_client(initializer); + let result = selector.get_client(initializer, &std::collections::HashSet::new()); assert!(result.is_ok()); assert_eq!(result.unwrap(), "https://example.com/rpc"); } @@ -552,15 +679,16 @@ mod tests { let configs = vec![RpcConfig { url: "https://example.com/rpc".to_string(), weight: 1, + ..Default::default() }]; - let selector = RpcSelector::new(configs).unwrap(); + let selector = RpcSelector::new_with_defaults(configs).unwrap(); // Create a failing initializer function let initializer = |_url: &str| -> Result { Err(eyre::eyre!("Initialization error")) }; - let result = selector.get_client(initializer); + let result = selector.get_client(initializer, &std::collections::HashSet::new()); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), @@ -574,20 +702,22 @@ mod tests { RpcConfig { url: "https://example1.com/rpc".to_string(), weight: 1, + ..Default::default() }, RpcConfig { url: "https://example2.com/rpc".to_string(), weight: 3, + ..Default::default() }, ]; - let selector = RpcSelector::new(configs).unwrap(); + let selector = RpcSelector::new_with_defaults(configs).unwrap(); let cloned = selector.clone(); // Check that the cloned selector has the same configuration - assert_eq!(selector.configs.len(), cloned.configs.len()); - assert_eq!(selector.configs[0].url, cloned.configs[0].url); - assert_eq!(selector.configs[1].url, cloned.configs[1].url); + assert_eq!(selector.configs.read().len(), cloned.configs.read().len()); + assert_eq!(selector.configs.read()[0].url, cloned.configs.read()[0].url); + assert_eq!(selector.configs.read()[1].url, cloned.configs.read()[1].url); // Check that weights distribution is also cloned assert_eq!( @@ -598,171 +728,147 @@ mod tests { #[test] fn test_mark_current_as_failed_single_provider() { - // With a single provider, marking as failed should cause an error when trying to select it again + // Clear health store to ensure clean state + RpcHealthStore::instance().clear_all(); + + // With a single provider, marking as failed multiple times (to reach threshold) will pause it, + // but it can still be selected as a last resort let configs = vec![RpcConfig { - url: "https://example.com/rpc".to_string(), + url: "https://test-single-provider.example.com/rpc".to_string(), weight: 1, + ..Default::default() }]; - let selector = RpcSelector::new(configs).unwrap(); - let initial_url = selector.select_url().unwrap(); + // Create selector with threshold=1 for testing + let selector = RpcSelector::new(configs, 1, 60, 60).unwrap(); + let _initial_url = selector.select_url().unwrap(); - // Mark as failed + // Mark as failed once (with threshold=1, this will pause it) selector.mark_current_as_failed(); - // Next call should return an error - let next_url = selector.select_url(); - assert!(next_url.is_err()); - assert!(matches!( - next_url.unwrap_err(), - RpcSelectorError::AllProvidersFailed - )); - - // Reset failed providers - selector.reset_failed_providers(); + // With threshold=1, available_provider_count should be 0 + assert_eq!(selector.available_provider_count(), 0); - // Now we should be able to select the provider again - let after_reset = selector.select_url(); - assert!(after_reset.is_ok()); - assert_eq!(initial_url, after_reset.unwrap()); + // But select_url should still work (selecting paused provider as last resort) + let next_url = selector.select_url(); + assert!(next_url.is_ok()); + assert_eq!( + next_url.unwrap(), + "https://test-single-provider.example.com/rpc" + ); } #[test] fn test_mark_current_as_failed_multiple_providers() { - // With multiple providers, marking as failed should prevent that provider from being selected again + // Clear health store to ensure clean state + RpcHealthStore::instance().clear_all(); + + // With multiple providers, marking as failed (with threshold=1) will pause them, + // but they can still be selected as a last resort let configs = vec![ RpcConfig { - url: "https://example1.com/rpc".to_string(), + url: "https://test-multi1.example.com/rpc".to_string(), weight: 5, + ..Default::default() }, RpcConfig { - url: "https://example2.com/rpc".to_string(), + url: "https://test-multi2.example.com/rpc".to_string(), weight: 5, + ..Default::default() }, RpcConfig { - url: "https://example3.com/rpc".to_string(), + url: "https://test-multi3.example.com/rpc".to_string(), weight: 5, + ..Default::default() }, ]; - let selector = RpcSelector::new(configs).unwrap(); + // Create selector with threshold=1 for testing + let selector = RpcSelector::new(configs, 1, 60, 60).unwrap(); // Get the first URL let url1 = selector.select_url().unwrap().to_string(); - // Mark as failed to move to a different one + // Mark as failed (with threshold=1, this pauses it) selector.mark_current_as_failed(); - let url2 = selector.select_url().unwrap().to_string(); + // Available count should decrease + assert_eq!(selector.available_provider_count(), 2); - // The URLs should be different + // Next selection should prefer non-paused providers + let url2 = selector.select_url().unwrap().to_string(); + // Should be different from the paused one assert_ne!(url1, url2); // Mark the second URL as failed too selector.mark_current_as_failed(); - let url3 = selector.select_url().unwrap().to_string(); + assert_eq!(selector.available_provider_count(), 1); - // Should get a third different URL + let url3 = selector.select_url().unwrap().to_string(); + // Should get the third URL (non-paused) assert_ne!(url1, url3); assert_ne!(url2, url3); // Mark the third URL as failed too selector.mark_current_as_failed(); + assert_eq!(selector.available_provider_count(), 0); - // Now all URLs should be marked as failed, so next call should return error + // Now all URLs are paused, but select_url should still work (selecting paused providers as last resort) let url4 = selector.select_url(); - assert!(url4.is_err()); - assert!(matches!( - url4.unwrap_err(), - RpcSelectorError::AllProvidersFailed - )); + assert!(url4.is_ok()); + // Should return one of the paused providers + let url4_str = url4.unwrap(); + assert!( + url4_str == "https://test-multi1.example.com/rpc" + || url4_str == "https://test-multi2.example.com/rpc" + || url4_str == "https://test-multi3.example.com/rpc" + ); } #[test] fn test_mark_current_as_failed_weighted() { + // Clear health store to ensure clean state + RpcHealthStore::instance().clear_all(); + // Test with weighted selection let configs = vec![ RpcConfig { - url: "https://example1.com/rpc".to_string(), + url: "https://test-weighted1.example.com/rpc".to_string(), weight: 1, // Low weight + ..Default::default() }, RpcConfig { - url: "https://example2.com/rpc".to_string(), + url: "https://test-weighted2.example.com/rpc".to_string(), weight: 10, // High weight + ..Default::default() }, ]; - let selector = RpcSelector::new(configs).unwrap(); + // Create selector with threshold=1 for testing + let selector = RpcSelector::new(configs, 1, 60, 60).unwrap(); assert!(selector.weights_dist.is_some()); // Confirm we're using weighted selection // Get a URL let url1 = selector.select_url().unwrap().to_string(); - // Mark it as failed + // Mark it as failed (with threshold=1, this pauses it) selector.mark_current_as_failed(); + assert_eq!(selector.available_provider_count(), 1); - // Get another URL, it should be different + // Get another URL, it should prefer the non-paused one let url2 = selector.select_url().unwrap().to_string(); assert_ne!(url1, url2); // Mark this one as failed too selector.mark_current_as_failed(); + assert_eq!(selector.available_provider_count(), 0); - // With no more providers, next call should fail + // With all providers paused, select_url should still work (selecting paused providers as last resort) let url3 = selector.select_url(); - assert!(url3.is_err()); - - // Reset and try again - selector.reset_failed_providers(); - let url4 = selector.select_url(); - assert!(url4.is_ok()); - } - - #[test] - fn test_auto_reset_mechanism() { - // Create a selector with a very short reset duration - let configs = vec![ - RpcConfig { - url: "https://example1.com/rpc".to_string(), - weight: 1, - }, - RpcConfig { - url: "https://example2.com/rpc".to_string(), - weight: 1, - }, - ]; - - // Change the auto-reset duration for this test - let selector = RpcSelector::new(configs).unwrap(); - { - let mut health = selector.health.write(); - *health = ProviderHealth::new(Duration::from_millis(100)); // Very short duration for testing - } - - // Select and mark both as failed - selector.select_url().unwrap(); - selector.mark_current_as_failed(); - selector.select_url().unwrap(); - selector.mark_current_as_failed(); - - // Immediately after, all providers should be failed - let result = selector.select_url(); - assert!(result.is_err()); - - // Sleep for longer than the reset duration - thread::sleep(Duration::from_millis(150)); - - // Force a check for auto-reset by directly calling is_failed() - { - let mut health = selector.health.write(); - // This should trigger auto-reset - let _ = health.is_failed(0); - } - - // After sleeping and checking, providers should be auto-reset - let result = selector.select_url(); + assert!(url3.is_ok()); + let url3_str = url3.unwrap(); assert!( - result.is_ok(), - "Providers should have been auto-reset after timeout" + url3_str == "https://test-weighted1.example.com/rpc" + || url3_str == "https://test-weighted2.example.com/rpc" ); } @@ -770,15 +876,16 @@ mod tests { fn test_provider_count() { // Test with no providers let configs: Vec = vec![]; - let result = RpcSelector::new(configs); + let result = RpcSelector::new_with_defaults(configs); assert!(result.is_err()); // Test with a single provider let configs = vec![RpcConfig { url: "https://example.com/rpc".to_string(), weight: 1, + ..Default::default() }]; - let selector = RpcSelector::new(configs).unwrap(); + let selector = RpcSelector::new_with_defaults(configs).unwrap(); assert_eq!(selector.provider_count(), 1); // Test with multiple providers @@ -786,54 +893,61 @@ mod tests { RpcConfig { url: "https://example1.com/rpc".to_string(), weight: 1, + ..Default::default() }, RpcConfig { url: "https://example2.com/rpc".to_string(), weight: 2, + ..Default::default() }, RpcConfig { url: "https://example3.com/rpc".to_string(), weight: 3, + ..Default::default() }, ]; - let selector = RpcSelector::new(configs).unwrap(); + let selector = RpcSelector::new_with_defaults(configs).unwrap(); assert_eq!(selector.provider_count(), 3); } #[test] fn test_available_provider_count() { + // Clear health store to ensure clean state + RpcHealthStore::instance().clear_all(); + let configs = vec![ RpcConfig { - url: "https://example1.com/rpc".to_string(), + url: "https://test-available1.example.com/rpc".to_string(), weight: 1, + ..Default::default() }, RpcConfig { - url: "https://example2.com/rpc".to_string(), + url: "https://test-available2.example.com/rpc".to_string(), weight: 2, + ..Default::default() }, RpcConfig { - url: "https://example3.com/rpc".to_string(), + url: "https://test-available3.example.com/rpc".to_string(), weight: 3, + ..Default::default() }, ]; - let selector = RpcSelector::new(configs).unwrap(); + // Create selector with threshold=1 for testing + let selector = RpcSelector::new(configs, 1, 60, 60).unwrap(); assert_eq!(selector.provider_count(), 3); assert_eq!(selector.available_provider_count(), 3); - // Mark one provider as failed + // Mark one provider as failed (with threshold=1, this pauses it) selector.select_url().unwrap(); // Select a provider first selector.mark_current_as_failed(); + // Available count should decrease (only non-paused providers) assert_eq!(selector.available_provider_count(), 2); // Mark another provider as failed selector.select_url().unwrap(); // Select another provider selector.mark_current_as_failed(); assert_eq!(selector.available_provider_count(), 1); - - // Reset failed providers - selector.reset_failed_providers(); - assert_eq!(selector.available_provider_count(), 3); } #[test] @@ -843,7 +957,7 @@ mod tests { RpcConfig::new("https://example2.com/rpc".to_string()), ]; - let selector = RpcSelector::new(configs).unwrap(); + let selector = RpcSelector::new_with_defaults(configs).unwrap(); // Should return a valid URL let url = selector.get_current_url(); @@ -858,14 +972,18 @@ mod tests { #[test] fn test_concurrent_usage() { + // Clear health store to ensure clean state + RpcHealthStore::instance().clear_all(); + // Test RpcSelector with concurrent access from multiple threads let configs = vec![ - RpcConfig::new("https://example1.com/rpc".to_string()), - RpcConfig::new("https://example2.com/rpc".to_string()), - RpcConfig::new("https://example3.com/rpc".to_string()), + RpcConfig::new("https://test-concurrent1.example.com/rpc".to_string()), + RpcConfig::new("https://test-concurrent2.example.com/rpc".to_string()), + RpcConfig::new("https://test-concurrent3.example.com/rpc".to_string()), ]; - let selector = RpcSelector::new(configs).unwrap(); + // Create selector with threshold=1 for testing + let selector = RpcSelector::new(configs, 1, 60, 60).unwrap(); let selector_arc = Arc::new(selector); let mut handles = Vec::with_capacity(10); @@ -875,7 +993,7 @@ mod tests { let selector_clone = Arc::clone(&selector_arc); let handle = thread::spawn(move || { let url = selector_clone.select_url().unwrap().to_string(); - if url.contains("example1") { + if url.contains("test-concurrent1") { // Only mark example1 as failed selector_clone.mark_current_as_failed(); } @@ -894,36 +1012,17 @@ mod tests { let unique_urls: std::collections::HashSet = urls.into_iter().collect(); assert!(unique_urls.len() > 1, "Expected multiple unique URLs"); - // After all threads, example1 should be marked as failed + // After all threads, example1 should be marked as failed (paused) + // Selections should prefer non-paused providers let mut found_non_example1 = false; for _ in 0..10 { let url = selector_arc.select_url().unwrap().to_string(); - if !url.contains("example1") { + if !url.contains("test-concurrent1") { found_non_example1 = true; } } - assert!(found_non_example1, "Should avoid selecting failed provider"); - } - - #[test] - fn test_provider_health_methods() { - let duration = Duration::from_secs(10); - let mut health = ProviderHealth::new(duration); - - // Initially no failed providers - assert_eq!(health.failed_count(), 0); - assert!(!health.is_failed(0)); - - // Mark as failed and verify - health.mark_failed(0); - assert_eq!(health.failed_count(), 1); - assert!(health.is_failed(0)); - - // Reset and verify - health.reset(); - assert_eq!(health.failed_count(), 0); - assert!(!health.is_failed(0)); + assert!(found_non_example1, "Should prefer non-paused providers"); } #[test] @@ -933,7 +1032,7 @@ mod tests { RpcConfig::new("https://example2.com/rpc".to_string()), ]; - let selector = RpcSelector::new(configs).unwrap(); + let selector = RpcSelector::new_with_defaults(configs).unwrap(); // First call to select a provider selector.select_url().unwrap(); @@ -947,68 +1046,31 @@ mod tests { assert!(result.is_ok()); } - #[test] - fn test_partial_auto_reset() { - let configs = vec![ - RpcConfig::new("https://example1.com/rpc".to_string()), - RpcConfig::new("https://example2.com/rpc".to_string()), - RpcConfig::new("https://example3.com/rpc".to_string()), - ]; - - let selector = RpcSelector::new(configs).unwrap(); - - // Override the reset durations to be different - { - let mut health = selector.health.write(); - *health = ProviderHealth::new(Duration::from_millis(50)); // Very short duration - } - - // Select and mark all providers as failed - for _ in 0..3 { - selector.select_url().unwrap(); - selector.mark_current_as_failed(); - } - - // All providers should now be marked as failed - assert!(selector.select_url().is_err()); - - // Mark provider 0 with a longer timeout manually - { - let mut health = selector.health.write(); - health - .failed_provider_reset_times - .insert(0, Instant::now() + Duration::from_millis(200)); - } - - // Sleep for enough time to auto-reset providers 1 and 2, but not 0 - thread::sleep(Duration::from_millis(100)); - - // Now provider 0 should still be failed, but 1 and 2 should be available - let url = selector.select_url(); - assert!(url.is_ok()); - - // The selected URL should not be provider 0 - assert!(!url.unwrap().contains("example1")); - } - #[test] fn test_weighted_to_round_robin_fallback() { + // Clear health store to ensure clean state + RpcHealthStore::instance().clear_all(); + let configs = vec![ RpcConfig { - url: "https://example1.com/rpc".to_string(), + url: "https://test-wrr1.example.com/rpc".to_string(), weight: 10, // High weight + ..Default::default() }, RpcConfig { - url: "https://example2.com/rpc".to_string(), + url: "https://test-wrr2.example.com/rpc".to_string(), weight: 1, // Low weight + ..Default::default() }, RpcConfig { - url: "https://example3.com/rpc".to_string(), + url: "https://test-wrr3.example.com/rpc".to_string(), weight: 1, // Low weight + ..Default::default() }, ]; - let selector = RpcSelector::new(configs).unwrap(); + // Create selector with threshold=1 for testing + let selector = RpcSelector::new(configs, 1, 60, 60).unwrap(); assert!(selector.weights_dist.is_some()); // Using weighted selection // Mock a situation where weighted selection would fail multiple times @@ -1018,9 +1080,9 @@ mod tests { // Try multiple times - the first provider should be selected more often due to weight for _ in 0..10 { let url = selector.select_url().unwrap(); - if url.contains("example1") { + if url.contains("test-wrr1") { selected_first = true; - // Mark the high-weight provider as failed + // Mark the high-weight provider as failed (pauses it) selector.mark_current_as_failed(); break; } @@ -1031,18 +1093,18 @@ mod tests { "High-weight provider should have been selected" ); - // After marking it failed, the other providers should be selected + // After marking it failed (paused), selections should prefer the other providers (non-paused) let mut seen_urls = HashSet::new(); for _ in 0..10 { let url = selector.select_url().unwrap().to_string(); seen_urls.insert(url); } - // Should have seen at least example2 and example3 + // Should have seen at least example2 and example3 (non-paused providers) assert!(seen_urls.len() >= 2); assert!( - !seen_urls.iter().any(|url| url.contains("example1")), - "Failed provider should not be selected" + !seen_urls.iter().any(|url| url.contains("test-wrr1")), + "Paused provider should not be selected (prefer non-paused)" ); } @@ -1052,14 +1114,16 @@ mod tests { RpcConfig { url: "https://example1.com/rpc".to_string(), weight: 0, // Zero weight + ..Default::default() }, RpcConfig { url: "https://example2.com/rpc".to_string(), weight: 5, // Normal weight + ..Default::default() }, ]; - let selector = RpcSelector::new(configs).unwrap(); + let selector = RpcSelector::new_with_defaults(configs).unwrap(); // With weighted selection, should never select the zero-weight provider let mut seen_urls = HashSet::new(); @@ -1081,14 +1145,16 @@ mod tests { RpcConfig { url: "https://example1.com/rpc".to_string(), weight: 100, // Very high weight + ..Default::default() }, RpcConfig { url: "https://example2.com/rpc".to_string(), weight: 1, // Very low weight + ..Default::default() }, ]; - let selector = RpcSelector::new(configs).unwrap(); + let selector = RpcSelector::new_with_defaults(configs).unwrap(); // High weight provider should be selected much more frequently let mut count_high = 0; @@ -1119,7 +1185,7 @@ mod tests { RpcConfig::new("https://example2.com/rpc".to_string()), ]; - let selector = RpcSelector::new(configs).unwrap(); + let selector = RpcSelector::new_with_defaults(configs).unwrap(); // Without selecting, mark as failed (should be a no-op) selector.mark_current_as_failed(); @@ -1161,4 +1227,479 @@ mod tests { let json = serde_json::to_string(&error).unwrap(); assert!(json.contains("AllProvidersFailed")); } + + #[cfg(test)] + mod rate_limiting_tests { + use super::*; + use crate::services::provider::rpc_health_store::RpcHealthStore; + + /// Test that RpcSelector switches to the second RPC when the first one is rate-limited. + /// + /// This test simulates a scenario where: + /// 1. Two RPC configs are set up with equal weights + /// 2. The first RPC starts returning rate limit errors (429) + /// 3. The selector should switch to the second RPC + /// 4. The first RPC should be marked as failed and excluded from selection + #[test] + fn test_rpc_selector_switches_on_rate_limit() { + RpcHealthStore::instance().clear_all(); + let configs = vec![ + RpcConfig { + url: "https://test-rate-limit1.example.com".to_string(), + weight: 100, + ..Default::default() + }, + RpcConfig { + url: "https://test-rate-limit2.example.com".to_string(), + weight: 100, + ..Default::default() + }, + ]; + + // Create selector with threshold=1 for testing + let selector = RpcSelector::new(configs, 1, 60, 60).unwrap(); + + // Initially, both providers should be available + assert_eq!(selector.available_provider_count(), 2); + + // Select the first provider + let first_url = selector.select_url().unwrap(); + + // Verify we got a valid URL + assert!( + first_url == "https://test-rate-limit1.example.com" + || first_url == "https://test-rate-limit2.example.com" + ); + + // Simulate rate limiting: mark the current provider as failed + // This simulates what happens when a provider returns HTTP 429 after retries are exhausted + selector.mark_current_as_failed(); + + // Now only one provider should be available (non-paused) + assert_eq!(selector.available_provider_count(), 1); + + // The next selection should prefer the non-paused provider + let second_url = selector.select_url().unwrap(); + + // Verify we got a different URL (the non-paused one) + assert_ne!(first_url, second_url); + + // Verify the failed provider is not selected again (prefer non-paused) + let third_url = selector.select_url().unwrap(); + assert_eq!(second_url, third_url); // Should keep using the working provider + + // Verify the failed provider is excluded from preferred selection + let mut selected_urls = std::collections::HashSet::new(); + for _ in 0..10 { + let url = selector.select_url().unwrap(); + selected_urls.insert(url.to_string()); + } + + // Should only select from the non-failed provider (preferred) + assert_eq!(selected_urls.len(), 1); + assert!(!selected_urls.contains(&first_url.to_string())); + assert!(selected_urls.contains(&second_url.to_string())); + } + + /// Test that RpcSelector handles rate limiting with weighted selection. + /// + /// This test verifies that even with weighted selection, a rate-limited provider + /// is excluded and the selector falls back to the other provider. + #[test] + fn test_rpc_selector_rate_limit_with_weighted_selection() { + RpcHealthStore::instance().clear_all(); + let configs = vec![ + RpcConfig { + url: "https://test-weighted-rl1.example.com".to_string(), + weight: 80, // Higher weight, should be preferred + ..Default::default() + }, + RpcConfig { + url: "https://test-weighted-rl2.example.com".to_string(), + weight: 20, // Lower weight + ..Default::default() + }, + ]; + + // Create selector with threshold=1 for testing + let selector = RpcSelector::new(configs, 1, 60, 60).unwrap(); + + // Select multiple times - with weighted selection, rpc1 should be selected more often + let mut rpc1_count = 0; + let mut rpc2_count = 0; + + for _ in 0..20 { + let url = selector.select_url().unwrap(); + if url == "https://test-weighted-rl1.example.com" { + rpc1_count += 1; + } else { + rpc2_count += 1; + } + } + + // With weighted selection, rpc1 should be selected more often + assert!(rpc1_count > rpc2_count); + + // Now simulate rate limiting on rpc1 + // First, select rpc1 + let mut selected_rpc1 = false; + for _ in 0..10 { + let url = selector.select_url().unwrap(); + if url == "https://test-weighted-rl1.example.com" { + selector.mark_current_as_failed(); + selected_rpc1 = true; + break; + } + } + assert!(selected_rpc1, "Should have selected rpc1 at least once"); + + // After marking rpc1 as failed (paused), selections should prefer rpc2 (non-paused) + for _ in 0..20 { + let url = selector.select_url().unwrap(); + assert_eq!(url, "https://test-weighted-rl2.example.com"); + } + + // Verify only one provider is available (non-paused) + assert_eq!(selector.available_provider_count(), 1); + } + + /// Test that a rate-limited provider stays failed. + /// + /// This test verifies that failed providers remain failed, + /// which simulates persistence of health state. + #[test] + fn test_rpc_selector_rate_limit_recovery() { + RpcHealthStore::instance().clear_all(); + let configs = vec![ + RpcConfig { + url: "https://test-recovery1.example.com".to_string(), + weight: 100, + ..Default::default() + }, + RpcConfig { + url: "https://test-recovery2.example.com".to_string(), + weight: 100, + ..Default::default() + }, + ]; + + // Create selector with threshold=1 for testing + let selector = RpcSelector::new(configs, 1, 60, 60).unwrap(); + + // Select first provider + let first_url = selector.select_url().unwrap(); + + // Mark it as failed (simulating rate limit, with threshold=1 this pauses it) + selector.mark_current_as_failed(); + assert_eq!(selector.available_provider_count(), 1); + + // Next selection should prefer the other provider (non-paused) + let second_url = selector.select_url().unwrap(); + assert_ne!(first_url, second_url); + + // Verify only the working provider is selected (prefer non-paused) + for _ in 0..10 { + let url = selector.select_url().unwrap(); + assert_eq!(url, second_url); + } + + // Since we persist health, the failed provider stays failed (paused) + assert_eq!(selector.available_provider_count(), 1); + } + + /// Test that when both providers are rate-limited, the selector handles it gracefully. + #[test] + fn test_rpc_selector_both_providers_rate_limited() { + RpcHealthStore::instance().clear_all(); + let configs = vec![ + RpcConfig { + url: "https://test-both-rl1.example.com".to_string(), + weight: 100, + ..Default::default() + }, + RpcConfig { + url: "https://test-both-rl2.example.com".to_string(), + weight: 100, + ..Default::default() + }, + ]; + + // Create selector with threshold=1 for testing + let selector = RpcSelector::new(configs, 1, 60, 60).unwrap(); + + // Select and mark first provider as failed (pauses it) + selector.select_url().unwrap(); + selector.mark_current_as_failed(); + assert_eq!(selector.available_provider_count(), 1); + + // Select and mark second provider as failed (pauses it) + selector.select_url().unwrap(); + selector.mark_current_as_failed(); + assert_eq!(selector.available_provider_count(), 0); + + // Now all providers are paused, but select_url should still work (selecting paused providers as last resort) + let result = selector.select_url(); + assert!(result.is_ok()); + let url = result.unwrap(); + assert!( + url == "https://test-both-rl1.example.com" + || url == "https://test-both-rl2.example.com" + ); + } + + /// Test that rate limiting works correctly with round-robin fallback. + /// + /// This test verifies that when weighted selection fails due to rate limiting, + /// the selector correctly falls back to round-robin selection. + #[test] + fn test_rpc_selector_rate_limit_round_robin_fallback() { + RpcHealthStore::instance().clear_all(); + let configs = vec![ + RpcConfig { + url: "https://test-rr-fallback1.example.com".to_string(), + weight: 100, + ..Default::default() + }, + RpcConfig { + url: "https://test-rr-fallback2.example.com".to_string(), + weight: 100, + ..Default::default() + }, + RpcConfig { + url: "https://test-rr-fallback3.example.com".to_string(), + weight: 100, + ..Default::default() + }, + ]; + + // Create selector with threshold=1 for testing + let selector = RpcSelector::new(configs, 1, 60, 60).unwrap(); + + // Mark rpc1 as failed (simulating rate limit) + selector.select_url().unwrap(); + let first_url = selector.get_current_url().unwrap(); + + // If we got rpc1, mark it as failed + if first_url == "https://test-rr-fallback1.example.com" { + selector.mark_current_as_failed(); + } else { + // Otherwise, select until we get rpc1, then mark it as failed + loop { + let url = selector.select_url().unwrap(); + if url == "https://test-rr-fallback1.example.com" { + selector.mark_current_as_failed(); + break; + } + } + } + + // Now rpc1 should be paused, and selections should prefer rpc2 and rpc3 (non-paused) + let mut selected_urls = std::collections::HashSet::new(); + for _ in 0..20 { + let url = selector.select_url().unwrap(); + selected_urls.insert(url.to_string()); + // rpc1 should not be selected (prefer non-paused) + assert_ne!(url, "https://test-rr-fallback1.example.com"); + } + + // Should have selected from both rpc2 and rpc3 + assert!(selected_urls.contains("https://test-rr-fallback2.example.com")); + assert!(selected_urls.contains("https://test-rr-fallback3.example.com")); + assert_eq!(selected_urls.len(), 2); + } + + #[test] + fn test_select_url_excludes_tried_providers() { + RpcHealthStore::instance().clear_all(); + let configs = vec![ + RpcConfig { + url: "https://provider1.com".to_string(), + weight: 1, + ..Default::default() + }, + RpcConfig { + url: "https://provider2.com".to_string(), + weight: 1, + ..Default::default() + }, + RpcConfig { + url: "https://provider3.com".to_string(), + weight: 1, + ..Default::default() + }, + ]; + + let selector = RpcSelector::new_with_defaults(configs).unwrap(); + + // Exclude provider1 + let mut excluded = std::collections::HashSet::new(); + excluded.insert("https://provider1.com".to_string()); + + // Should select provider2 or provider3, not provider1 + for _ in 0..10 { + let url = selector.get_next_url(&excluded).unwrap(); + assert_ne!(url, "https://provider1.com"); + } + } + + #[test] + fn test_select_url_fallback_to_paused_providers() { + RpcHealthStore::instance().clear_all(); + let configs = vec![ + RpcConfig { + url: "https://provider1.com".to_string(), + weight: 1, + ..Default::default() + }, + RpcConfig { + url: "https://provider2.com".to_string(), + weight: 1, + ..Default::default() + }, + ]; + + let selector = RpcSelector::new_with_defaults(configs).unwrap(); + let health_store = RpcHealthStore::instance(); + let expiration = chrono::Duration::seconds(60); + + // Pause both providers + health_store.mark_failed( + "https://provider1.com", + 3, + chrono::Duration::seconds(60), + expiration, + ); + health_store.mark_failed( + "https://provider1.com", + 3, + chrono::Duration::seconds(60), + expiration, + ); + health_store.mark_failed( + "https://provider1.com", + 3, + chrono::Duration::seconds(60), + expiration, + ); + + health_store.mark_failed( + "https://provider2.com", + 3, + chrono::Duration::seconds(60), + expiration, + ); + health_store.mark_failed( + "https://provider2.com", + 3, + chrono::Duration::seconds(60), + expiration, + ); + health_store.mark_failed( + "https://provider2.com", + 3, + chrono::Duration::seconds(60), + expiration, + ); + + // Both should be paused + assert!(health_store.is_paused("https://provider1.com", 3, expiration)); + assert!(health_store.is_paused("https://provider2.com", 3, expiration)); + + // Should still be able to select (fallback to paused providers) + let url = selector + .get_next_url(&std::collections::HashSet::new()) + .unwrap(); + assert!(url == "https://provider1.com" || url == "https://provider2.com"); + } + + #[test] + fn test_select_url_single_provider_excluded() { + RpcHealthStore::instance().clear_all(); + let configs = vec![RpcConfig { + url: "https://single-provider.com".to_string(), + weight: 1, + ..Default::default() + }]; + + let selector = RpcSelector::new_with_defaults(configs).unwrap(); + + // Exclude the only provider + let mut excluded = std::collections::HashSet::new(); + excluded.insert("https://single-provider.com".to_string()); + + // Should return error + let result = selector.get_next_url(&excluded); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RpcSelectorError::AllProvidersFailed + )); + } + + #[test] + fn test_select_url_all_providers_excluded() { + RpcHealthStore::instance().clear_all(); + let configs = vec![ + RpcConfig { + url: "https://provider1.com".to_string(), + weight: 1, + ..Default::default() + }, + RpcConfig { + url: "https://provider2.com".to_string(), + weight: 1, + ..Default::default() + }, + ]; + + let selector = RpcSelector::new_with_defaults(configs).unwrap(); + + // Exclude all providers + let mut excluded = std::collections::HashSet::new(); + excluded.insert("https://provider1.com".to_string()); + excluded.insert("https://provider2.com".to_string()); + + // Should return error + let result = selector.get_next_url(&excluded); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RpcSelectorError::AllProvidersFailed + )); + } + + #[test] + fn test_select_url_excluded_providers_with_weighted_selection() { + RpcHealthStore::instance().clear_all(); + let configs = vec![ + RpcConfig { + url: "https://provider1.com".to_string(), + weight: 10, + ..Default::default() + }, + RpcConfig { + url: "https://provider2.com".to_string(), + weight: 1, + ..Default::default() + }, + RpcConfig { + url: "https://provider3.com".to_string(), + weight: 1, + ..Default::default() + }, + ]; + + let selector = RpcSelector::new_with_defaults(configs).unwrap(); + + // Exclude provider1 (highest weight) + let mut excluded = std::collections::HashSet::new(); + excluded.insert("https://provider1.com".to_string()); + + // Should select from provider2 or provider3, never provider1 + for _ in 0..20 { + let url = selector.get_next_url(&excluded).unwrap(); + assert_ne!(url, "https://provider1.com"); + } + } + } } diff --git a/src/services/provider/solana/mod.rs b/src/services/provider/solana/mod.rs index 9391fac52..a1cdb5eed 100644 --- a/src/services/provider/solana/mod.rs +++ b/src/services/provider/solana/mod.rs @@ -43,7 +43,7 @@ use crate::{ use super::ProviderError; use super::{ rpc_selector::{RpcSelector, RpcSelectorError}, - RetryConfig, + ProviderConfig, RetryConfig, }; /// Utility function to match error patterns by normalizing both strings. @@ -355,6 +355,7 @@ impl SolanaProviderError { #[cfg_attr(test, automock)] #[allow(dead_code)] pub trait SolanaProviderTrait: Send + Sync { + fn get_configs(&self) -> Vec; /// Retrieves the balance (in lamports) for the given address. async fn get_balance(&self, address: &str) -> Result; @@ -495,8 +496,15 @@ impl std::fmt::Display for TokenMetadata { #[allow(dead_code)] impl SolanaProvider { - pub fn new(configs: Vec, timeout_seconds: u64) -> Result { - Self::new_with_commitment(configs, timeout_seconds, CommitmentConfig::confirmed()) + pub fn new(config: ProviderConfig) -> Result { + Self::new_with_commitment_and_health( + config.rpc_configs, + config.timeout_seconds, + CommitmentConfig::confirmed(), + config.failure_threshold, + config.pause_duration_secs, + config.failure_expiration_secs, + ) } /// Creates a new SolanaProvider with RPC configurations and optional settings. @@ -506,14 +514,20 @@ impl SolanaProvider { /// * `configs` - A vector of RPC configurations /// * `timeout` - Optional custom timeout /// * `commitment` - Optional custom commitment level + /// * `failure_threshold` - Number of consecutive failures before pausing a provider + /// * `pause_duration_secs` - Duration in seconds to pause a provider after reaching failure threshold + /// * `failure_expiration_secs` - Duration in seconds after which failures are considered stale /// /// # Returns /// /// A Result containing the provider or an error - pub fn new_with_commitment( + pub fn new_with_commitment_and_health( configs: Vec, timeout_seconds: u64, commitment: CommitmentConfig, + failure_threshold: u32, + pause_duration_secs: u64, + failure_expiration_secs: u64, ) -> Result { if configs.is_empty() { return Err(ProviderError::NetworkConfiguration( @@ -525,7 +539,13 @@ impl SolanaProvider { .map_err(|e| ProviderError::NetworkConfiguration(format!("Invalid URL: {e}")))?; // Now create the selector with validated configs - let selector = RpcSelector::new(configs).map_err(|e| { + let selector = RpcSelector::new( + configs, + failure_threshold, + pause_duration_secs, + failure_expiration_secs, + ) + .map_err(|e| { ProviderError::NetworkConfiguration(format!("Failed to create RPC selector: {e}")) })?; @@ -539,6 +559,14 @@ impl SolanaProvider { }) } + /// Gets the current RPC configurations. + /// + /// # Returns + /// * `Vec` - The current configurations + pub fn get_configs(&self) -> Vec { + self.selector.get_configs() + } + /// Retrieves an RPC client instance using the configured selector. /// /// # Returns @@ -549,13 +577,16 @@ impl SolanaProvider { /// fn get_client(&self) -> Result { self.selector - .get_client(|url| { - Ok(RpcClient::new_with_timeout_and_commitment( - url.to_string(), - self.timeout_seconds, - self.commitment, - )) - }) + .get_client( + |url| { + Ok(RpcClient::new_with_timeout_and_commitment( + url.to_string(), + self.timeout_seconds, + self.commitment, + )) + }, + &std::collections::HashSet::new(), + ) .map_err(SolanaProviderError::SelectorError) } @@ -611,6 +642,10 @@ impl SolanaProvider { #[async_trait] #[allow(dead_code)] impl SolanaProviderTrait for SolanaProvider { + fn get_configs(&self) -> Vec { + self.get_configs() + } + /// Retrieves the balance (in lamports) for the given address. /// # Errors /// @@ -991,16 +1026,21 @@ mod tests { RpcConfig { url: "https://api.devnet.solana.com".to_string(), weight: 1, + ..Default::default() } } + fn create_test_provider_config(configs: Vec, timeout: u64) -> ProviderConfig { + ProviderConfig::new(configs, timeout, 3, 60, 60) + } + #[tokio::test] async fn test_new_with_valid_config() { let _env_guard = setup_test_env(); let configs = vec![create_test_rpc_config()]; let timeout = 30; - let result = SolanaProvider::new(configs, timeout); + let result = SolanaProvider::new(create_test_provider_config(configs, timeout)); assert!(result.is_ok()); let provider = result.unwrap(); @@ -1016,7 +1056,8 @@ mod tests { let timeout = 30; let commitment = CommitmentConfig::finalized(); - let result = SolanaProvider::new_with_commitment(configs, timeout, commitment); + let result = + SolanaProvider::new_with_commitment_and_health(configs, timeout, commitment, 3, 60, 60); assert!(result.is_ok()); let provider = result.unwrap(); @@ -1030,7 +1071,7 @@ mod tests { let configs: Vec = vec![]; let timeout = 30; - let result = SolanaProvider::new(configs, timeout); + let result = SolanaProvider::new(create_test_provider_config(configs, timeout)); assert!(result.is_err()); assert!(matches!( @@ -1046,7 +1087,8 @@ mod tests { let timeout = 30; let commitment = CommitmentConfig::finalized(); - let result = SolanaProvider::new_with_commitment(configs, timeout, commitment); + let result = + SolanaProvider::new_with_commitment_and_health(configs, timeout, commitment, 3, 60, 60); assert!(result.is_err()); assert!(matches!( @@ -1061,10 +1103,11 @@ mod tests { let configs = vec![RpcConfig { url: "invalid-url".to_string(), weight: 1, + ..Default::default() }]; let timeout = 30; - let result = SolanaProvider::new(configs, timeout); + let result = SolanaProvider::new(create_test_provider_config(configs, timeout)); assert!(result.is_err()); assert!(matches!( @@ -1079,11 +1122,13 @@ mod tests { let configs = vec![RpcConfig { url: "invalid-url".to_string(), weight: 1, + ..Default::default() }]; let timeout = 30; let commitment = CommitmentConfig::finalized(); - let result = SolanaProvider::new_with_commitment(configs, timeout, commitment); + let result = + SolanaProvider::new_with_commitment_and_health(configs, timeout, commitment, 3, 60, 60); assert!(result.is_err()); assert!(matches!( @@ -1100,11 +1145,12 @@ mod tests { RpcConfig { url: "https://api.mainnet-beta.solana.com".to_string(), weight: 1, + ..Default::default() }, ]; let timeout = 30; - let result = SolanaProvider::new(configs, timeout); + let result = SolanaProvider::new(create_test_provider_config(configs, timeout)); assert!(result.is_ok()); } @@ -1114,7 +1160,7 @@ mod tests { let _env_guard = setup_test_env(); let configs = vec![create_test_rpc_config()]; let timeout = 30; - let provider = SolanaProvider::new(configs, timeout); + let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)); assert!(provider.is_ok()); } @@ -1123,7 +1169,7 @@ mod tests { let _env_guard = setup_test_env(); let configs = vec![create_test_rpc_config()]; let timeout = 30; - let provider = SolanaProvider::new(configs, timeout).unwrap(); + let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap(); let keypair = Keypair::new(); let balance = provider.get_balance(&keypair.pubkey().to_string()).await; assert!(balance.is_ok()); @@ -1135,7 +1181,7 @@ mod tests { let _env_guard = setup_test_env(); let configs = vec![create_test_rpc_config()]; let timeout = 30; - let provider = SolanaProvider::new(configs, timeout).unwrap(); + let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap(); let keypair = get_funded_keypair(); let balance = provider.get_balance(&keypair.pubkey().to_string()).await; assert!(balance.is_ok()); @@ -1147,7 +1193,7 @@ mod tests { let _env_guard = setup_test_env(); let configs = vec![create_test_rpc_config()]; let timeout = 30; - let provider = SolanaProvider::new(configs, timeout).unwrap(); + let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap(); let blockhash = provider.get_latest_blockhash().await; assert!(blockhash.is_ok()); } @@ -1157,7 +1203,8 @@ mod tests { let _env_guard = setup_test_env(); let configs = vec![create_test_rpc_config()]; let timeout = 30; - let provider = SolanaProvider::new(configs, timeout).expect("Failed to create provider"); + let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)) + .expect("Failed to create provider"); let fee_payer = get_funded_keypair(); @@ -1195,9 +1242,10 @@ mod tests { let configs = vec![RpcConfig { url: "https://api.mainnet-beta.solana.com".to_string(), weight: 1, + ..Default::default() }]; let timeout = 30; - let provider = SolanaProvider::new(configs, timeout).unwrap(); + let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap(); let usdc_token_metadata = provider .get_token_metadata_from_pubkey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") .await @@ -1232,7 +1280,7 @@ mod tests { let _env_guard = setup_test_env(); let configs = vec![create_test_rpc_config()]; let timeout = 30; - let provider = SolanaProvider::new(configs, timeout).unwrap(); + let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap(); let client = provider.get_client(); assert!(client.is_ok()); @@ -1249,7 +1297,9 @@ mod tests { let timeout = 30; let commitment = CommitmentConfig::finalized(); - let provider = SolanaProvider::new_with_commitment(configs, timeout, commitment).unwrap(); + let provider = + SolanaProvider::new_with_commitment_and_health(configs, timeout, commitment, 3, 60, 60) + .unwrap(); let client = provider.get_client(); assert!(client.is_ok()); @@ -1267,11 +1317,12 @@ mod tests { RpcConfig { url: "https://api.mainnet-beta.solana.com".to_string(), weight: 2, + ..Default::default() }, ]; let timeout = 30; - let provider = SolanaProvider::new(configs, timeout).unwrap(); + let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap(); let client_result = provider.get_client(); assert!(client_result.is_ok()); @@ -1290,8 +1341,9 @@ mod tests { let configs = vec![RpcConfig { url: "https://api.devnet.solana.com".to_string(), weight: 1, + ..Default::default() }]; - let provider = SolanaProvider::new(configs, 10).unwrap(); + let provider = SolanaProvider::new(create_test_provider_config(configs, 10)).unwrap(); let result = provider.initialize_provider("https://api.devnet.solana.com"); assert!(result.is_ok()); let arc_client = result.unwrap(); @@ -1306,8 +1358,9 @@ mod tests { let configs = vec![RpcConfig { url: "https://api.devnet.solana.com".to_string(), weight: 1, + ..Default::default() }]; - let provider = SolanaProvider::new(configs, 10).unwrap(); + let provider = SolanaProvider::new(create_test_provider_config(configs, 10)).unwrap(); let result = provider.initialize_provider("not-a-valid-url"); assert!(result.is_err()); match result { @@ -1478,7 +1531,7 @@ mod tests { let _env_guard = super::tests::setup_test_env(); let configs = vec![super::tests::create_test_rpc_config()]; let timeout = 30; - let provider = SolanaProvider::new(configs, timeout).unwrap(); + let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap(); // 0 bytes is always valid, should return a value >= 0 let result = provider.get_minimum_balance_for_rent_exemption(0).await; @@ -1490,7 +1543,7 @@ mod tests { let _env_guard = super::tests::setup_test_env(); let configs = vec![super::tests::create_test_rpc_config()]; let timeout = 30; - let provider = SolanaProvider::new(configs, timeout).unwrap(); + let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap(); // Get a recent blockhash (should be valid) let blockhash = provider.get_latest_blockhash().await.unwrap(); @@ -1505,7 +1558,7 @@ mod tests { let _env_guard = super::tests::setup_test_env(); let configs = vec![super::tests::create_test_rpc_config()]; let timeout = 30; - let provider = SolanaProvider::new(configs, timeout).unwrap(); + let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap(); let invalid_blockhash = solana_sdk::hash::Hash::new_from_array([0u8; 32]); let is_valid = provider @@ -1519,7 +1572,7 @@ mod tests { let _env_guard = super::tests::setup_test_env(); let configs = vec![super::tests::create_test_rpc_config()]; let timeout = 30; - let provider = SolanaProvider::new(configs, timeout).unwrap(); + let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap(); let commitment = CommitmentConfig::confirmed(); let result = provider diff --git a/src/services/provider/stellar/mod.rs b/src/services/provider/stellar/mod.rs index 843c19958..47a5c997b 100644 --- a/src/services/provider/stellar/mod.rs +++ b/src/services/provider/stellar/mod.rs @@ -30,8 +30,8 @@ use crate::services::provider::is_retriable_error; use crate::services::provider::retry::retry_rpc_call; use crate::services::provider::rpc_selector::RpcSelector; use crate::services::provider::should_mark_provider_failed; -use crate::services::provider::ProviderError; use crate::services::provider::RetryConfig; +use crate::services::provider::{ProviderConfig, ProviderError}; // Reqwest client is used for raw JSON-RPC HTTP requests. Alias to avoid name clash with the // soroban `Client` type imported above. use reqwest::Client as ReqwestClient; @@ -275,6 +275,7 @@ pub struct StellarProvider { #[cfg_attr(test, automock)] #[allow(dead_code)] pub trait StellarProviderTrait: Send + Sync { + fn get_configs(&self) -> Vec; async fn get_account(&self, account_id: &str) -> Result; async fn simulate_transaction_envelope( &self, @@ -331,19 +332,17 @@ pub trait StellarProviderTrait: Send + Sync { impl StellarProvider { // Create new StellarProvider instance - pub fn new( - mut rpc_configs: Vec, - timeout_seconds: u64, - ) -> Result { - if rpc_configs.is_empty() { + pub fn new(config: ProviderConfig) -> Result { + if config.rpc_configs.is_empty() { return Err(ProviderError::NetworkConfiguration( "No RPC configurations provided for StellarProvider".to_string(), )); } - RpcConfig::validate_list(&rpc_configs) + RpcConfig::validate_list(&config.rpc_configs) .map_err(|e| ProviderError::NetworkConfiguration(e.to_string()))?; + let mut rpc_configs = config.rpc_configs; rpc_configs.retain(|config| config.get_weight() > 0); if rpc_configs.is_empty() { @@ -352,7 +351,13 @@ impl StellarProvider { )); } - let selector = RpcSelector::new(rpc_configs).map_err(|e| { + let selector = RpcSelector::new( + rpc_configs, + config.failure_threshold, + config.pause_duration_secs, + config.failure_expiration_secs, + ) + .map_err(|e| { ProviderError::NetworkConfiguration(format!("Failed to create RPC selector: {e}")) })?; @@ -360,11 +365,19 @@ impl StellarProvider { Ok(Self { selector, - timeout_seconds: Duration::from_secs(timeout_seconds), + timeout_seconds: Duration::from_secs(config.timeout_seconds), retry_config, }) } + /// Gets the current RPC configurations. + /// + /// # Returns + /// * `Vec` - The current configurations + pub fn get_configs(&self) -> Vec { + self.selector.get_configs() + } + /// Initialize a Stellar client for a given URL fn initialize_provider(&self, url: &str) -> Result { Client::new(url).map_err(|e| { @@ -488,6 +501,10 @@ impl StellarProvider { #[async_trait] impl StellarProviderTrait for StellarProvider { + fn get_configs(&self) -> Vec { + self.get_configs() + } + async fn get_account(&self, account_id: &str) -> Result { let account_id = Arc::new(account_id.to_string()); @@ -985,15 +1002,21 @@ mod stellar_rpc_tests { // Tests // --------------------------------------------------------------------- + fn create_test_provider_config(configs: Vec, timeout: u64) -> ProviderConfig { + ProviderConfig::new(configs, timeout, 3, 60, 60) + } + #[test] fn test_new_provider() { let _env_guard = setup_test_env(); - let provider = - StellarProvider::new(vec![RpcConfig::new("http://localhost:8000".to_string())], 0); + let provider = StellarProvider::new(create_test_provider_config( + vec![RpcConfig::new("http://localhost:8000".to_string())], + 0, + )); assert!(provider.is_ok()); - let provider_err = StellarProvider::new(vec![], 0); + let provider_err = StellarProvider::new(create_test_provider_config(vec![], 0)); assert!(provider_err.is_err()); match provider_err.unwrap_err() { ProviderError::NetworkConfiguration(msg) => { @@ -1012,7 +1035,7 @@ mod stellar_rpc_tests { RpcConfig::with_weight("http://rpc2.example.com".to_string(), 100).unwrap(), // Highest weight RpcConfig::with_weight("http://rpc3.example.com".to_string(), 50).unwrap(), ]; - let provider = StellarProvider::new(configs, 0); + let provider = StellarProvider::new(create_test_provider_config(configs, 0)); assert!(provider.is_ok()); // We can't directly inspect the client's URL easily without more complex mocking or changes. // For now, we trust the sorting logic and that Client::new would fail for a truly bad URL if selection was wrong. @@ -1027,12 +1050,12 @@ mod stellar_rpc_tests { RpcConfig::with_weight("http://rpc1.example.com".to_string(), 0).unwrap(), // Weight 0 RpcConfig::with_weight("http://rpc2.example.com".to_string(), 100).unwrap(), // Should be selected ]; - let provider = StellarProvider::new(configs, 0); + let provider = StellarProvider::new(create_test_provider_config(configs, 0)); assert!(provider.is_ok()); let configs_only_zero = vec![RpcConfig::with_weight("http://rpc1.example.com".to_string(), 0).unwrap()]; - let provider_err = StellarProvider::new(configs_only_zero, 0); + let provider_err = StellarProvider::new(create_test_provider_config(configs_only_zero, 0)); assert!(provider_err.is_err()); match provider_err.unwrap_err() { ProviderError::NetworkConfiguration(msg) => { @@ -1045,7 +1068,7 @@ mod stellar_rpc_tests { #[test] fn test_new_provider_invalid_url_scheme() { let configs = vec![RpcConfig::new("ftp://invalid.example.com".to_string())]; - let provider_err = StellarProvider::new(configs, 0); + let provider_err = StellarProvider::new(create_test_provider_config(configs, 0)); assert!(provider_err.is_err()); match provider_err.unwrap_err() { ProviderError::NetworkConfiguration(msg) => { @@ -1063,7 +1086,7 @@ mod stellar_rpc_tests { RpcConfig::with_weight("http://rpc1.example.com".to_string(), 0).unwrap(), RpcConfig::with_weight("http://rpc2.example.com".to_string(), 0).unwrap(), ]; - let provider_err = StellarProvider::new(configs, 0); + let provider_err = StellarProvider::new(create_test_provider_config(configs, 0)); assert!(provider_err.is_err()); match provider_err.unwrap_err() { ProviderError::NetworkConfiguration(msg) => { @@ -1268,9 +1291,16 @@ mod stellar_rpc_tests { const NON_EXISTENT_URL: &str = "http://127.0.0.1:9998"; + fn create_test_provider_config(configs: Vec, timeout: u64) -> ProviderConfig { + ProviderConfig::new(configs, timeout, 3, 60, 60) + } + fn setup_provider() -> StellarProvider { - StellarProvider::new(vec![RpcConfig::new(NON_EXISTENT_URL.to_string())], 0) - .expect("Provider creation should succeed even with bad URL") + StellarProvider::new(create_test_provider_config( + vec![RpcConfig::new(NON_EXISTENT_URL.to_string())], + 0, + )) + .expect("Provider creation should succeed even with bad URL") } #[tokio::test] @@ -1710,10 +1740,10 @@ mod stellar_rpc_tests { #[test] fn test_initialize_provider_invalid_url() { let _env_guard = setup_test_env(); - let provider = StellarProvider::new( + let provider = StellarProvider::new(create_test_provider_config( vec![RpcConfig::new("http://localhost:8000".to_string())], 30, - ) + )) .unwrap(); // Test with invalid URL that should fail client creation @@ -1730,10 +1760,10 @@ mod stellar_rpc_tests { #[test] fn test_initialize_raw_provider_timeout_config() { let _env_guard = setup_test_env(); - let provider = StellarProvider::new( + let provider = StellarProvider::new(create_test_provider_config( vec![RpcConfig::new("http://localhost:8000".to_string())], 30, - ) + )) .unwrap(); // Test with valid URL - should succeed @@ -1753,9 +1783,11 @@ mod stellar_rpc_tests { let _env_guard = setup_test_env(); // Create a provider with a mock server URL that won't actually connect - let provider = - StellarProvider::new(vec![RpcConfig::new("http://127.0.0.1:9999".to_string())], 1) - .unwrap(); + let provider = StellarProvider::new(create_test_provider_config( + vec![RpcConfig::new("http://127.0.0.1:9999".to_string())], + 1, + )) + .unwrap(); let params = serde_json::json!({"test": "value"}); let result = provider @@ -1778,9 +1810,11 @@ mod stellar_rpc_tests { async fn test_raw_request_dyn_with_auto_generated_id() { let _env_guard = setup_test_env(); - let provider = - StellarProvider::new(vec![RpcConfig::new("http://127.0.0.1:9999".to_string())], 1) - .unwrap(); + let provider = StellarProvider::new(create_test_provider_config( + vec![RpcConfig::new("http://127.0.0.1:9999".to_string())], + 1, + )) + .unwrap(); let params = serde_json::json!({"test": "value"}); let result = provider.raw_request_dyn("test_method", params, None).await; @@ -1793,9 +1827,11 @@ mod stellar_rpc_tests { async fn test_retry_raw_request_connection_failure() { let _env_guard = setup_test_env(); - let provider = - StellarProvider::new(vec![RpcConfig::new("http://127.0.0.1:9999".to_string())], 1) - .unwrap(); + let provider = StellarProvider::new(create_test_provider_config( + vec![RpcConfig::new("http://127.0.0.1:9999".to_string())], + 1, + )) + .unwrap(); let request = serde_json::json!({ "jsonrpc": "2.0", @@ -1822,9 +1858,11 @@ mod stellar_rpc_tests { // This test would require mocking the HTTP response, which is complex // For now, we test that the function exists and can be called - let provider = - StellarProvider::new(vec![RpcConfig::new("http://127.0.0.1:9999".to_string())], 1) - .unwrap(); + let provider = StellarProvider::new(create_test_provider_config( + vec![RpcConfig::new("http://127.0.0.1:9999".to_string())], + 1, + )) + .unwrap(); let params = serde_json::json!({"test": "value"}); let result = provider @@ -1844,7 +1882,7 @@ mod stellar_rpc_tests { let _env_guard = setup_test_env(); // Test with empty configs - let result = StellarProvider::new(vec![], 30); + let result = StellarProvider::new(create_test_provider_config(vec![], 30)); assert!(result.is_err()); match result.unwrap_err() { ProviderError::NetworkConfiguration(msg) => { @@ -1859,7 +1897,7 @@ mod stellar_rpc_tests { let mut config2 = RpcConfig::new("http://localhost:8001".to_string()); config2.weight = 0; let configs = vec![config1, config2]; - let result = StellarProvider::new(configs, 30); + let result = StellarProvider::new(create_test_provider_config(configs, 30)); assert!(result.is_err()); match result.unwrap_err() { ProviderError::NetworkConfiguration(msg) => { diff --git a/src/utils/error_sanitization.rs b/src/utils/error_sanitization.rs new file mode 100644 index 000000000..1d684517d --- /dev/null +++ b/src/utils/error_sanitization.rs @@ -0,0 +1,401 @@ +//! Error sanitization and mapping utilities for provider errors. +//! +//! This module provides network-agnostic utilities for sanitizing and mapping +//! provider errors to JSON-RPC error codes and user-friendly messages. +//! +//! These utilities are used by all network relayers (EVM, Stellar, Solana) to +//! ensure consistent error handling and prevent exposing sensitive information. + +use crate::{ + models::{OpenZeppelinErrorCodes, RpcErrorCodes}, + services::provider::ProviderError, +}; + +/// Maps provider errors to appropriate JSON-RPC error codes and messages. +/// +/// This function translates internal provider errors into standardized +/// JSON-RPC error codes and user-friendly messages that can be returned +/// to clients. It follows JSON-RPC 2.0 specification for standard errors +/// and uses OpenZeppelin-specific codes for extended functionality. +/// +/// # Arguments +/// +/// * `error` - A reference to the provider error to be mapped +/// +/// # Returns +/// +/// Returns a tuple containing: +/// - `i32` - The error code (following JSON-RPC 2.0 and OpenZeppelin conventions) +/// - `&'static str` - A static string describing the error type +/// +/// # Error Code Mappings +/// +/// - `InvalidAddress` → -32602 ("Invalid params") +/// - `NetworkConfiguration` → -33004 ("Network configuration error") +/// - `Timeout` → -33000 ("Request timeout") +/// - `RateLimited` → -33001 ("Rate limited") +/// - `BadGateway` → -33002 ("Bad gateway") +/// - `RequestError` → -33003 ("Request error") +/// - `Other` and unknown errors → -32603 ("Internal error") +pub fn map_provider_error(error: &ProviderError) -> (i32, &'static str) { + match error { + ProviderError::InvalidAddress(_) => (RpcErrorCodes::INVALID_PARAMS, "Invalid params"), + ProviderError::NetworkConfiguration(_) => ( + OpenZeppelinErrorCodes::NETWORK_CONFIGURATION, + "Network configuration error", + ), + ProviderError::Timeout => (OpenZeppelinErrorCodes::TIMEOUT, "Request timeout"), + ProviderError::RateLimited => (OpenZeppelinErrorCodes::RATE_LIMITED, "Rate limited"), + ProviderError::BadGateway => (OpenZeppelinErrorCodes::BAD_GATEWAY, "Bad gateway"), + ProviderError::RequestError { .. } => { + (OpenZeppelinErrorCodes::REQUEST_ERROR, "Request error") + } + ProviderError::Other(_) => (RpcErrorCodes::INTERNAL_ERROR, "Internal error"), + _ => (RpcErrorCodes::INTERNAL_ERROR, "Internal error"), + } +} + +/// Sanitizes provider error descriptions to prevent exposing internal details. +/// +/// This function creates a safe, user-friendly error description that doesn't +/// expose sensitive information like API keys, internal URLs, or implementation +/// details. The full error is logged internally for debugging purposes. +/// +/// # Arguments +/// +/// * `error` - A reference to the provider error to sanitize +/// +/// # Returns +/// +/// Returns a sanitized error description string that is safe to return to clients. +pub fn sanitize_error_description(error: &ProviderError) -> String { + match error { + ProviderError::InvalidAddress(_) => "The provided address is invalid".to_string(), + ProviderError::NetworkConfiguration(_) => { + "Network configuration error. Please check your network settings".to_string() + } + ProviderError::Timeout => "The request timed out. Please try again later".to_string(), + ProviderError::RateLimited => "Rate limit exceeded. Please try again later".to_string(), + ProviderError::BadGateway => { + "Service temporarily unavailable. Please try again later".to_string() + } + ProviderError::RequestError { status_code, .. } => { + format!("Request failed with status code {}", status_code) + } + ProviderError::RpcErrorCode { code, .. } => { + format!("RPC error occurred (code: {})", code) + } + ProviderError::TransportError(_) => { + "Network error occurred. Please try again later".to_string() + } + ProviderError::SolanaRpcError(_) => { + "RPC request failed. Please try again later".to_string() + } + ProviderError::Other(_) => "An internal error occurred. Please try again later".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::provider::{rpc_selector::RpcSelectorError, SolanaProviderError}; + + #[test] + fn test_map_provider_error_invalid_address() { + let error = ProviderError::InvalidAddress("invalid address".to_string()); + let (code, _message) = map_provider_error(&error); + + assert_eq!(code, RpcErrorCodes::INVALID_PARAMS); + } + + #[test] + fn test_map_provider_error_invalid_address_empty() { + let error = ProviderError::InvalidAddress("".to_string()); + let (code, _message) = map_provider_error(&error); + + assert_eq!(code, RpcErrorCodes::INVALID_PARAMS); + } + + #[test] + fn test_map_provider_error_network_configuration() { + let error = ProviderError::NetworkConfiguration("network config error".to_string()); + let (code, _message) = map_provider_error(&error); + + assert_eq!(code, OpenZeppelinErrorCodes::NETWORK_CONFIGURATION); + } + + #[test] + fn test_map_provider_error_network_configuration_empty() { + let error = ProviderError::NetworkConfiguration("".to_string()); + let (code, _message) = map_provider_error(&error); + + assert_eq!(code, OpenZeppelinErrorCodes::NETWORK_CONFIGURATION); + } + + #[test] + fn test_map_provider_error_timeout() { + let error = ProviderError::Timeout; + let (code, _message) = map_provider_error(&error); + + assert_eq!(code, OpenZeppelinErrorCodes::TIMEOUT); + } + + #[test] + fn test_map_provider_error_rate_limited() { + let error = ProviderError::RateLimited; + let (code, _message) = map_provider_error(&error); + + assert_eq!(code, OpenZeppelinErrorCodes::RATE_LIMITED); + } + + #[test] + fn test_map_provider_error_bad_gateway() { + let error = ProviderError::BadGateway; + let (code, _message) = map_provider_error(&error); + + assert_eq!(code, OpenZeppelinErrorCodes::BAD_GATEWAY); + } + + #[test] + fn test_map_provider_error_request_error_400() { + let error = ProviderError::RequestError { + error: "Bad request".to_string(), + status_code: 400, + }; + let (code, _message) = map_provider_error(&error); + + assert_eq!(code, OpenZeppelinErrorCodes::REQUEST_ERROR); + } + + #[test] + fn test_map_provider_error_request_error_500() { + let error = ProviderError::RequestError { + error: "Internal server error".to_string(), + status_code: 500, + }; + let (code, _message) = map_provider_error(&error); + + assert_eq!(code, OpenZeppelinErrorCodes::REQUEST_ERROR); + } + + #[test] + fn test_map_provider_error_request_error_empty_message() { + let error = ProviderError::RequestError { + error: "".to_string(), + status_code: 404, + }; + let (code, _message) = map_provider_error(&error); + + assert_eq!(code, OpenZeppelinErrorCodes::REQUEST_ERROR); + } + + #[test] + fn test_map_provider_error_request_error_zero_status() { + let error = ProviderError::RequestError { + error: "No status".to_string(), + status_code: 0, + }; + let (code, _message) = map_provider_error(&error); + + assert_eq!(code, OpenZeppelinErrorCodes::REQUEST_ERROR); + } + + #[test] + fn test_map_provider_error_other() { + let error = ProviderError::Other("some other error".to_string()); + let (code, _message) = map_provider_error(&error); + + assert_eq!(code, RpcErrorCodes::INTERNAL_ERROR); + } + + #[test] + fn test_map_provider_error_other_empty() { + let error = ProviderError::Other("".to_string()); + let (code, _message) = map_provider_error(&error); + + assert_eq!(code, RpcErrorCodes::INTERNAL_ERROR); + } + + #[test] + fn test_map_provider_error_solana_rpc_error() { + let solana_error = SolanaProviderError::RpcError("Solana RPC failed".to_string()); + let error = ProviderError::SolanaRpcError(solana_error); + let (code, _message) = map_provider_error(&error); + + // The SolanaRpcError variant should be caught by the wildcard pattern + assert_eq!(code, RpcErrorCodes::INTERNAL_ERROR); + } + + #[test] + fn test_map_provider_error_solana_invalid_address() { + let solana_error = + SolanaProviderError::InvalidAddress("Invalid Solana address".to_string()); + let error = ProviderError::SolanaRpcError(solana_error); + let (code, _message) = map_provider_error(&error); + + // The SolanaRpcError variant should be caught by the wildcard pattern + assert_eq!(code, RpcErrorCodes::INTERNAL_ERROR); + } + + #[test] + fn test_map_provider_error_solana_selector_error() { + let selector_error = RpcSelectorError::NoProviders; + let solana_error = SolanaProviderError::SelectorError(selector_error); + let error = ProviderError::SolanaRpcError(solana_error); + let (code, _message) = map_provider_error(&error); + + // The SolanaRpcError variant should be caught by the wildcard pattern + assert_eq!(code, RpcErrorCodes::INTERNAL_ERROR); + } + + #[test] + fn test_map_provider_error_solana_network_configuration() { + let solana_error = + SolanaProviderError::NetworkConfiguration("Solana network config error".to_string()); + let error = ProviderError::SolanaRpcError(solana_error); + let (code, _message) = map_provider_error(&error); + + // The SolanaRpcError variant should be caught by the wildcard pattern + assert_eq!(code, RpcErrorCodes::INTERNAL_ERROR); + } + + #[test] + fn test_map_provider_error_wildcard_pattern() { + // This test ensures the wildcard pattern works by testing all variations + // that should fall through to the default case + let test_cases = vec![ + ProviderError::SolanaRpcError(SolanaProviderError::RpcError("test".to_string())), + ProviderError::SolanaRpcError(SolanaProviderError::InvalidAddress("test".to_string())), + ProviderError::SolanaRpcError(SolanaProviderError::NetworkConfiguration( + "test".to_string(), + )), + ProviderError::SolanaRpcError(SolanaProviderError::SelectorError( + RpcSelectorError::NoProviders, + )), + ]; + + for error in test_cases { + let (code, _message) = map_provider_error(&error); + assert_eq!(code, RpcErrorCodes::INTERNAL_ERROR); + } + } + + #[test] + fn test_sanitize_error_description_invalid_address() { + let error = ProviderError::InvalidAddress("0xinvalid".to_string()); + let description = sanitize_error_description(&error); + assert_eq!(description, "The provided address is invalid"); + // Ensure no internal details are exposed + assert!(!description.contains("0xinvalid")); + } + + #[test] + fn test_sanitize_error_description_network_configuration() { + let error = + ProviderError::NetworkConfiguration("RPC selector error: No providers".to_string()); + let description = sanitize_error_description(&error); + assert_eq!( + description, + "Network configuration error. Please check your network settings" + ); + // Ensure no internal details are exposed + assert!(!description.contains("RPC selector")); + assert!(!description.contains("No providers")); + } + + #[test] + fn test_sanitize_error_description_timeout() { + let error = ProviderError::Timeout; + let description = sanitize_error_description(&error); + assert_eq!(description, "The request timed out. Please try again later"); + } + + #[test] + fn test_sanitize_error_description_rate_limited() { + let error = ProviderError::RateLimited; + let description = sanitize_error_description(&error); + assert_eq!(description, "Rate limit exceeded. Please try again later"); + } + + #[test] + fn test_sanitize_error_description_bad_gateway() { + let error = ProviderError::BadGateway; + let description = sanitize_error_description(&error); + assert_eq!( + description, + "Service temporarily unavailable. Please try again later" + ); + } + + #[test] + fn test_sanitize_error_description_request_error() { + let error = ProviderError::RequestError { + error: "API key invalid: abc123".to_string(), + status_code: 401, + }; + let description = sanitize_error_description(&error); + assert_eq!(description, "Request failed with status code 401"); + // Ensure no API key details are exposed + assert!(!description.contains("API key")); + assert!(!description.contains("abc123")); + } + + #[test] + fn test_sanitize_error_description_rpc_error_code() { + let error = ProviderError::RpcErrorCode { + code: -32000, + message: "Server error: Invalid API key".to_string(), + }; + let description = sanitize_error_description(&error); + assert_eq!(description, "RPC error occurred (code: -32000)"); + // Ensure no internal message details are exposed + assert!(!description.contains("Server error")); + assert!(!description.contains("API key")); + } + + #[test] + fn test_sanitize_error_description_transport_error() { + let error = ProviderError::TransportError( + "Connection failed: https://rpc.example.com/api?key=secret".to_string(), + ); + let description = sanitize_error_description(&error); + assert_eq!( + description, + "Network error occurred. Please try again later" + ); + // Ensure no URLs or keys are exposed + assert!(!description.contains("https://")); + assert!(!description.contains("key=")); + assert!(!description.contains("secret")); + } + + #[test] + fn test_sanitize_error_description_solana_rpc_error() { + let solana_error = + SolanaProviderError::RpcError("RPC failed: Invalid API key abc123".to_string()); + let error = ProviderError::SolanaRpcError(solana_error); + let description = sanitize_error_description(&error); + assert_eq!(description, "RPC request failed. Please try again later"); + // Ensure no internal details are exposed + assert!(!description.contains("API key")); + assert!(!description.contains("abc123")); + } + + #[test] + fn test_sanitize_error_description_other() { + let error = ProviderError::Other( + "Internal error: Failed to connect to https://rpc.example.com with key abc123" + .to_string(), + ); + let description = sanitize_error_description(&error); + assert_eq!( + description, + "An internal error occurred. Please try again later" + ); + // Ensure no internal details are exposed + assert!(!description.contains("Failed to connect")); + assert!(!description.contains("https://")); + assert!(!description.contains("key")); + assert!(!description.contains("abc123")); + } +} diff --git a/src/utils/mocks.rs b/src/utils/mocks.rs index 89687dc54..2638f491b 100644 --- a/src/utils/mocks.rs +++ b/src/utils/mocks.rs @@ -16,7 +16,7 @@ pub mod mockutils { ApiKeyRepoModel, AppState, EvmTransactionData, EvmTransactionRequest, LocalSignerConfigStorage, NetworkConfigData, NetworkRepoModel, NetworkTransactionData, NetworkType, NotificationRepoModel, PluginModel, RelayerEvmPolicy, - RelayerNetworkPolicy, RelayerRepoModel, RelayerSolanaPolicy, SecretString, + RelayerNetworkPolicy, RelayerRepoModel, RelayerSolanaPolicy, RpcConfig, SecretString, SignerConfigStorage, SignerRepoModel, SolanaTransactionData, TransactionRepoModel, TransactionStatus, }, @@ -99,7 +99,9 @@ pub mod mockutils { common: NetworkConfigCommon { network: "test".to_string(), from: None, - rpc_urls: Some(vec!["http://localhost:8545".to_string()]), + rpc_urls: Some(vec![crate::models::RpcConfig::new( + "http://localhost:8545".to_string(), + )]), explorer_urls: None, average_blocktime_ms: Some(1000), is_testnet: Some(true), @@ -123,7 +125,7 @@ pub mod mockutils { common: NetworkConfigCommon { network: "devnet".to_string(), from: None, - rpc_urls: Some(vec!["http://localhost:8545".to_string()]), + rpc_urls: Some(vec![RpcConfig::new("http://localhost:8545".to_string())]), explorer_urls: None, average_blocktime_ms: Some(1000), is_testnet: Some(true), @@ -320,6 +322,9 @@ pub mod mockutils { provider_retry_base_delay_ms: 100, provider_retry_max_delay_ms: 2000, provider_max_failovers: 3, + provider_failure_threshold: 3, + provider_pause_duration_secs: 60, + provider_failure_expiration_secs: 60, repository_storage_type: storage_type, reset_storage_on_start: false, storage_encryption_key: Some(SecretString::new( diff --git a/src/utils/mod.rs b/src/utils/mod.rs index ecaa915cd..9bbb58a9d 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -44,5 +44,8 @@ pub use encryption::*; mod json_rpc_error; pub use json_rpc_error::*; +mod error_sanitization; +pub use error_sanitization::*; + #[cfg(test)] pub mod mocks; From 8359191fdc4cc5f6fa182d26946051ddb30807f0 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 30 Dec 2025 23:20:41 +0100 Subject: [PATCH 2/9] feat: Add networks routes --- openapi.json | 644 +++++++++++++++++++++++++++- src/api/controllers/mod.rs | 1 + src/api/controllers/network.rs | 202 +++++++++ src/api/routes/docs/mod.rs | 1 + src/api/routes/docs/network_docs.rs | 198 +++++++++ src/api/routes/mod.rs | 4 +- src/api/routes/network.rs | 47 ++ src/models/network/mod.rs | 4 + src/models/network/request.rs | 249 +++++++++++ src/models/network/response.rs | 167 ++++++++ src/openapi.rs | 9 +- 11 files changed, 1521 insertions(+), 5 deletions(-) create mode 100644 src/api/controllers/network.rs create mode 100644 src/api/routes/docs/network_docs.rs create mode 100644 src/api/routes/network.rs create mode 100644 src/models/network/request.rs create mode 100644 src/models/network/response.rs diff --git a/openapi.json b/openapi.json index 70d0084f8..10e0daa34 100644 --- a/openapi.json +++ b/openapi.json @@ -15,6 +15,302 @@ "version": "1.3.0" }, "paths": { + "/api/v1/networks": { + "get": { + "tags": [ + "Networks" + ], + "summary": "Network routes implementation", + "description": "Note: OpenAPI documentation for these endpoints can be found in the `openapi.rs` file\n\nLists all networks with pagination support.", + "operationId": "listNetworks", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "Page number for pagination (starts at 1)", + "required": false, + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "per_page", + "in": "query", + "description": "Number of items per page (default: 10)", + "required": false, + "schema": { + "type": "integer", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Network list retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_NetworkResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "message": "Bad Request", + "success": false + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "message": "Unauthorized", + "success": false + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "message": "Internal Server Error", + "success": false + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/networks/{network_id}": { + "get": { + "tags": [ + "Networks" + ], + "summary": "Retrieves details of a specific network by ID.", + "operationId": "getNetwork", + "parameters": [ + { + "name": "network_id", + "in": "path", + "description": "Network ID (e.g., evm:sepolia, solana:mainnet)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Network retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_NetworkResponse" + } + } + } + }, + "400": { + "description": "Bad Request - invalid network type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "message": "Bad Request", + "success": false + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "message": "Unauthorized", + "success": false + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "message": "Network with ID 'evm:sepolia' not found", + "success": false + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "message": "Internal Server Error", + "success": false + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "patch": { + "tags": [ + "Networks" + ], + "summary": "Updates a network's configuration.\nCurrently supports updating RPC URLs only. Can be extended to support other fields.", + "operationId": "updateNetwork", + "parameters": [ + { + "name": "network_id", + "in": "path", + "description": "Network ID (e.g., evm:sepolia, solana:mainnet)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateNetworkRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Network updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_NetworkResponse" + } + } + } + }, + "400": { + "description": "Bad Request - invalid network type or request data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "message": "Bad Request", + "success": false + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "message": "Unauthorized", + "success": false + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "message": "Network with ID 'evm:sepolia' not found", + "success": false + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_String" + }, + "example": { + "data": null, + "message": "Internal Server Error", + "success": false + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/api/v1/notifications": { "get": { "tags": [ @@ -3751,6 +4047,111 @@ } } }, + "ApiResponse_NetworkResponse": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "data": { + "type": "object", + "description": "Network response model for API endpoints.\n\nThis flattens the internal NetworkRepoModel structure for API responses,\nmaking network-type-specific fields available at the top level.", + "required": [ + "id", + "name", + "network_type" + ], + "properties": { + "average_blocktime_ms": { + "type": "integer", + "format": "int64", + "description": "Estimated average time between blocks in milliseconds", + "minimum": 0 + }, + "chain_id": { + "type": "integer", + "format": "int64", + "description": "EVM-specific: Chain ID", + "minimum": 0 + }, + "explorer_urls": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of Explorer endpoint URLs" + }, + "features": { + "type": "array", + "items": { + "type": "string" + }, + "description": "EVM-specific: Network features (e.g., \"eip1559\")" + }, + "horizon_url": { + "type": "string", + "description": "Stellar-specific: Horizon URL" + }, + "id": { + "type": "string", + "description": "Unique identifier composed of network_type and name, e.g., \"evm:mainnet\"" + }, + "is_testnet": { + "type": "boolean", + "description": "Flag indicating if the network is a testnet" + }, + "name": { + "type": "string", + "description": "Name of the network (e.g., \"mainnet\", \"sepolia\")" + }, + "network_type": { + "$ref": "#/components/schemas/RelayerNetworkType", + "description": "Type of the network (EVM, Solana, Stellar)" + }, + "passphrase": { + "type": "string", + "description": "Stellar-specific: Network passphrase" + }, + "required_confirmations": { + "type": "integer", + "format": "int64", + "description": "EVM-specific: Required confirmations", + "minimum": 0 + }, + "rpc_urls": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RpcConfig" + }, + "description": "List of RPC endpoint configurations" + }, + "symbol": { + "type": "string", + "description": "EVM-specific: Native token symbol" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of arbitrary tags for categorizing or filtering networks" + } + } + }, + "error": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/PluginMetadata" + }, + "pagination": { + "$ref": "#/components/schemas/PaginationMeta" + }, + "success": { + "type": "boolean" + } + } + }, "ApiResponse_NotificationResponse": { "type": "object", "required": [ @@ -4302,6 +4703,114 @@ } } }, + "ApiResponse_Vec_NetworkResponse": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "description": "Network response model for API endpoints.\n\nThis flattens the internal NetworkRepoModel structure for API responses,\nmaking network-type-specific fields available at the top level.", + "required": [ + "id", + "name", + "network_type" + ], + "properties": { + "average_blocktime_ms": { + "type": "integer", + "format": "int64", + "description": "Estimated average time between blocks in milliseconds", + "minimum": 0 + }, + "chain_id": { + "type": "integer", + "format": "int64", + "description": "EVM-specific: Chain ID", + "minimum": 0 + }, + "explorer_urls": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of Explorer endpoint URLs" + }, + "features": { + "type": "array", + "items": { + "type": "string" + }, + "description": "EVM-specific: Network features (e.g., \"eip1559\")" + }, + "horizon_url": { + "type": "string", + "description": "Stellar-specific: Horizon URL" + }, + "id": { + "type": "string", + "description": "Unique identifier composed of network_type and name, e.g., \"evm:mainnet\"" + }, + "is_testnet": { + "type": "boolean", + "description": "Flag indicating if the network is a testnet" + }, + "name": { + "type": "string", + "description": "Name of the network (e.g., \"mainnet\", \"sepolia\")" + }, + "network_type": { + "$ref": "#/components/schemas/RelayerNetworkType", + "description": "Type of the network (EVM, Solana, Stellar)" + }, + "passphrase": { + "type": "string", + "description": "Stellar-specific: Network passphrase" + }, + "required_confirmations": { + "type": "integer", + "format": "int64", + "description": "EVM-specific: Required confirmations", + "minimum": 0 + }, + "rpc_urls": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RpcConfig" + }, + "description": "List of RPC endpoint configurations" + }, + "symbol": { + "type": "string", + "description": "EVM-specific: Native token symbol" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of arbitrary tags for categorizing or filtering networks" + } + } + } + }, + "error": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/PluginMetadata" + }, + "pagination": { + "$ref": "#/components/schemas/PaginationMeta" + }, + "success": { + "type": "boolean" + } + } + }, "ApiResponse_Vec_NotificationResponse": { "type": "object", "required": [ @@ -5659,6 +6168,91 @@ ], "description": "Network policy response models for OpenAPI documentation" }, + "NetworkResponse": { + "type": "object", + "description": "Network response model for API endpoints.\n\nThis flattens the internal NetworkRepoModel structure for API responses,\nmaking network-type-specific fields available at the top level.", + "required": [ + "id", + "name", + "network_type" + ], + "properties": { + "average_blocktime_ms": { + "type": "integer", + "format": "int64", + "description": "Estimated average time between blocks in milliseconds", + "minimum": 0 + }, + "chain_id": { + "type": "integer", + "format": "int64", + "description": "EVM-specific: Chain ID", + "minimum": 0 + }, + "explorer_urls": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of Explorer endpoint URLs" + }, + "features": { + "type": "array", + "items": { + "type": "string" + }, + "description": "EVM-specific: Network features (e.g., \"eip1559\")" + }, + "horizon_url": { + "type": "string", + "description": "Stellar-specific: Horizon URL" + }, + "id": { + "type": "string", + "description": "Unique identifier composed of network_type and name, e.g., \"evm:mainnet\"" + }, + "is_testnet": { + "type": "boolean", + "description": "Flag indicating if the network is a testnet" + }, + "name": { + "type": "string", + "description": "Name of the network (e.g., \"mainnet\", \"sepolia\")" + }, + "network_type": { + "$ref": "#/components/schemas/RelayerNetworkType", + "description": "Type of the network (EVM, Solana, Stellar)" + }, + "passphrase": { + "type": "string", + "description": "Stellar-specific: Network passphrase" + }, + "required_confirmations": { + "type": "integer", + "format": "int64", + "description": "EVM-specific: Required confirmations", + "minimum": 0 + }, + "rpc_urls": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RpcConfig" + }, + "description": "List of RPC endpoint configurations" + }, + "symbol": { + "type": "string", + "description": "EVM-specific: Native token symbol" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of arbitrary tags for categorizing or filtering networks" + } + } + }, "NetworkRpcRequest": { "oneOf": [ { @@ -6509,9 +7103,10 @@ }, "RpcConfig": { "type": "object", - "description": "Configuration for an RPC endpoint.", + "description": "Configuration for an RPC endpoint.\n\nThis struct contains only persistent configuration (URL and weight).\nHealth metadata (failures, pause state) is managed separately via `RpcHealthStore`.", "required": [ - "url" + "url", + "weight" ], "properties": { "url": { @@ -6522,10 +7117,29 @@ "type": "integer", "format": "int32", "description": "The weight of this endpoint in the weighted round-robin selection.\nDefaults to DEFAULT_RPC_WEIGHT (100). Should be between 0 and 100.", + "default": 100, + "maximum": 100, "minimum": 0 } + }, + "example": { + "url": "https://rpc.example.com", + "weight": 100 } }, + "RpcUrlEntry": { + "oneOf": [ + { + "type": "string", + "description": "Simple string format (e.g., \"https://rpc.example.com\")\nDefaults to weight 100." + }, + { + "$ref": "#/components/schemas/RpcConfig", + "description": "Extended object format with explicit weight" + } + ], + "description": "Schema-only type representing a flexible RPC URL entry.\nUsed for OpenAPI documentation to show that rpc_urls can accept\neither strings or RpcConfig objects.\n\nThis is NOT used for actual deserialization - the custom deserializer\nhandles the conversion. This type exists solely for schema generation." + }, "SignAndSendTransactionRequestParams": { "type": "object", "required": [ @@ -8291,6 +8905,26 @@ }, "additionalProperties": false }, + "UpdateNetworkRequest": { + "type": "object", + "description": "Request structure for updating a network configuration.\nCurrently supports updating RPC URLs only. Can be extended to support other fields.", + "properties": { + "rpc_urls": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RpcUrlEntry" + }, + "description": "List of RPC endpoint configurations for connecting to the network.\nSupports multiple formats:\n- Array of strings: `[\"https://rpc.example.com\"]` (defaults to weight 100)\n- Array of RpcConfig objects: `[{\"url\": \"https://rpc.example.com\", \"weight\": 100}]`\n- Mixed array: `[\"https://rpc1.com\", {\"url\": \"https://rpc2.com\", \"weight\": 200}]`\nMust be non-empty and contain valid HTTP/HTTPS URLs if provided.", + "example": [ + { + "url": "https://rpc.example.com", + "weight": 100 + } + ] + } + }, + "additionalProperties": false + }, "UpdateRelayerRequest": { "type": "object", "properties": { @@ -8440,6 +9074,10 @@ "name": "Signers", "description": "Signers are responsible for signing the transactions related to the relayers." }, + { + "name": "Networks", + "description": "Networks represent blockchain network configurations including RPC endpoints and network-specific settings." + }, { "name": "Metrics", "description": "Metrics are responsible for showing the metrics related to the relayers." @@ -8449,4 +9087,4 @@ "description": "Health is responsible for showing the health of the relayers." } ] -} +} \ No newline at end of file diff --git a/src/api/controllers/mod.rs b/src/api/controllers/mod.rs index 4e5558928..e317400ca 100644 --- a/src/api/controllers/mod.rs +++ b/src/api/controllers/mod.rs @@ -11,6 +11,7 @@ //! * `signers` - Signer management endpoints pub mod api_key; +pub mod network; pub mod notification; pub mod plugin; pub mod relayer; diff --git a/src/api/controllers/network.rs b/src/api/controllers/network.rs new file mode 100644 index 000000000..b783e9386 --- /dev/null +++ b/src/api/controllers/network.rs @@ -0,0 +1,202 @@ +//! # Network Controller +//! +//! Handles HTTP endpoints for network operations including: +//! - Listing networks +//! - Getting network details +//! - Updating network RPC URLs + +use crate::{ + config::{EvmNetworkConfig, SolanaNetworkConfig, StellarNetworkConfig}, + jobs::JobProducerTrait, + models::{ + NetworkConfigData, NetworkRepoModel, NetworkResponse, UpdateNetworkRequest, + ApiError, ApiResponse, PaginationMeta, PaginationQuery, ThinDataAppState, + }, + repositories::{ + ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, + Repository, TransactionCounterTrait, TransactionRepository, + }, +}; +use actix_web::HttpResponse; +use eyre::Result; + +/// Lists all networks with pagination support. +/// +/// # Arguments +/// +/// * `query` - The pagination query parameters. +/// * `state` - The application state containing the network repository. +/// +/// # Returns +/// +/// A paginated list of networks. +pub async fn list_networks( + query: PaginationQuery, + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, +{ + let networks = state.network_repository.list_paginated(query).await?; + + let mapped_networks: Vec = + networks.items.into_iter().map(|n| n.into()).collect(); + + Ok(HttpResponse::Ok().json(ApiResponse::paginated( + mapped_networks, + PaginationMeta { + total_items: networks.total, + current_page: networks.page, + per_page: networks.per_page, + }, + ))) +} + +/// Retrieves details of a specific network by ID. +/// +/// # Arguments +/// +/// * `network_id` - The ID of the network (e.g., "evm:sepolia", "solana:mainnet"). +/// * `state` - The application state containing the network repository. +/// +/// # Returns +/// +/// The details of the specified network or 404 if not found. +pub async fn get_network( + network_id: String, + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, +{ + let network = state + .network_repository + .get_by_id(network_id.clone()) + .await + .map_err(|e| match e { + crate::models::RepositoryError::NotFound(_) => { + ApiError::NotFound(format!("Network with ID '{}' not found", network_id)) + } + _ => ApiError::InternalError(format!("Failed to retrieve network: {}", e)), + })?; + + let network_response: NetworkResponse = network.into(); + + Ok(HttpResponse::Ok().json(ApiResponse::success(network_response))) +} + +/// Updates a network's configuration. +/// Currently supports updating RPC URLs only. Can be extended to support other fields. +/// +/// # Arguments +/// +/// * `network_id` - The ID of the network (e.g., "evm:sepolia", "solana:mainnet"). +/// * `request` - The update request containing fields to update. +/// * `state` - The application state containing the network repository. +/// +/// # Returns +/// +/// The updated network or an error if update fails. +pub async fn update_network( + network_id: String, + request: UpdateNetworkRequest, + state: ThinDataAppState, +) -> Result +where + J: JobProducerTrait + Send + Sync + 'static, + RR: RelayerRepository + Repository + Send + Sync + 'static, + TR: TransactionRepository + Repository + Send + Sync + 'static, + NR: NetworkRepository + Repository + Send + Sync + 'static, + NFR: Repository + Send + Sync + 'static, + SR: Repository + Send + Sync + 'static, + TCR: TransactionCounterTrait + Send + Sync + 'static, + PR: PluginRepositoryTrait + Send + Sync + 'static, + AKR: ApiKeyRepositoryTrait + Send + Sync + 'static, +{ + // Validate request + request.validate()?; + + // Ensure at least one field is provided for update + if request.rpc_urls.is_none() { + return Err(ApiError::BadRequest( + "At least one field must be provided for update".to_string(), + )); + } + + // Get existing network + let mut network = state + .network_repository + .get_by_id(network_id.clone()) + .await + .map_err(|e| match e { + crate::models::RepositoryError::NotFound(_) => { + ApiError::NotFound(format!("Network with ID '{}' not found", network_id)) + } + _ => ApiError::InternalError(format!("Failed to retrieve network: {}", e)), + })?; + + // Update fields in the network config + let common = network.common(); + let mut updated_common = common.clone(); + + // Update RPC URLs if provided + if let Some(rpc_urls) = request.rpc_urls { + updated_common.rpc_urls = Some(rpc_urls); + } + + // Update the network config based on type + let updated_config = match network.config { + NetworkConfigData::Evm(evm_config) => { + NetworkConfigData::Evm(EvmNetworkConfig { + common: updated_common, + chain_id: evm_config.chain_id, + required_confirmations: evm_config.required_confirmations, + features: evm_config.features, + symbol: evm_config.symbol, + gas_price_cache: evm_config.gas_price_cache, + }) + } + NetworkConfigData::Solana(_) => { + NetworkConfigData::Solana(SolanaNetworkConfig { + common: updated_common, + }) + } + NetworkConfigData::Stellar(stellar_config) => { + NetworkConfigData::Stellar(StellarNetworkConfig { + common: updated_common, + passphrase: stellar_config.passphrase, + horizon_url: stellar_config.horizon_url, + }) + } + }; + + // Update the network model + network.config = updated_config; + + // Save the updated network + let saved_network = state + .network_repository + .update(network.id.clone(), network) + .await?; + + let network_response: NetworkResponse = saved_network.into(); + + Ok(HttpResponse::Ok().json(ApiResponse::success(network_response))) +} + diff --git a/src/api/routes/docs/mod.rs b/src/api/routes/docs/mod.rs index daed0da76..127cbb9dd 100644 --- a/src/api/routes/docs/mod.rs +++ b/src/api/routes/docs/mod.rs @@ -1,3 +1,4 @@ +pub mod network_docs; pub mod notification_docs; pub mod plugin_docs; pub mod relayer_docs; diff --git a/src/api/routes/docs/network_docs.rs b/src/api/routes/docs/network_docs.rs new file mode 100644 index 000000000..ec48b9737 --- /dev/null +++ b/src/api/routes/docs/network_docs.rs @@ -0,0 +1,198 @@ +//! # Network Documentation +//! +//! This module contains the OpenAPI documentation for the network API endpoints. +//! +//! ## Endpoints +//! +//! - `GET /api/v1/networks`: List all networks +//! - `GET /api/v1/networks/{network_id}`: Get a network by ID (e.g., evm:sepolia) +//! - `PATCH /api/v1/networks/{network_id}`: Update a network configuration + +use crate::models::{ApiResponse, NetworkResponse, UpdateNetworkRequest}; + +/// Network routes implementation +/// +/// Note: OpenAPI documentation for these endpoints can be found in the `openapi.rs` file +/// +/// Lists all networks with pagination support. +#[utoipa::path( + get, + path = "/api/v1/networks", + tag = "Networks", + operation_id = "listNetworks", + security( + ("bearer_auth" = []) + ), + params( + ("page" = Option, Query, description = "Page number for pagination (starts at 1)"), + ("per_page" = Option, Query, description = "Number of items per page (default: 10)") + ), + responses( + ( + status = 200, + description = "Network list retrieved successfully", + body = ApiResponse> + ), + ( + status = 400, + description = "Bad Request", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Bad Request", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +#[allow(dead_code)] +fn doc_list_networks() {} + +/// Retrieves details of a specific network by ID. +#[utoipa::path( + get, + path = "/api/v1/networks/{network_id}", + tag = "Networks", + operation_id = "getNetwork", + security( + ("bearer_auth" = []) + ), + params( + ("network_id" = String, Path, description = "Network ID (e.g., evm:sepolia, solana:mainnet)") + ), + responses( + ( + status = 200, + description = "Network retrieved successfully", + body = ApiResponse + ), + ( + status = 400, + description = "Bad Request - invalid network type", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Bad Request", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 404, + description = "Not Found", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Network with ID 'evm:sepolia' not found", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +#[allow(dead_code)] +fn doc_get_network() {} + +/// Updates a network's configuration. +/// Currently supports updating RPC URLs only. Can be extended to support other fields. +#[utoipa::path( + patch, + path = "/api/v1/networks/{network_id}", + tag = "Networks", + operation_id = "updateNetwork", + security( + ("bearer_auth" = []) + ), + params( + ("network_id" = String, Path, description = "Network ID (e.g., evm:sepolia, solana:mainnet)") + ), + request_body = UpdateNetworkRequest, + responses( + ( + status = 200, + description = "Network updated successfully", + body = ApiResponse + ), + ( + status = 400, + description = "Bad Request - invalid network type or request data", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Bad Request", + "data": null + }) + ), + ( + status = 401, + description = "Unauthorized", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Unauthorized", + "data": null + }) + ), + ( + status = 404, + description = "Not Found", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Network with ID 'evm:sepolia' not found", + "data": null + }) + ), + ( + status = 500, + description = "Internal Server Error", + body = ApiResponse, + example = json!({ + "success": false, + "message": "Internal Server Error", + "data": null + }) + ) + ) +)] +#[allow(dead_code)] +fn doc_update_network() {} + diff --git a/src/api/routes/mod.rs b/src/api/routes/mod.rs index bdd883aa6..7767a2657 100644 --- a/src/api/routes/mod.rs +++ b/src/api/routes/mod.rs @@ -13,6 +13,7 @@ pub mod api_keys; pub mod docs; pub mod health; pub mod metrics; +pub mod network; pub mod notification; pub mod plugin; pub mod relayer; @@ -26,5 +27,6 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) { .configure(metrics::init) .configure(notification::init) .configure(signer::init) - .configure(api_keys::init); + .configure(api_keys::init) + .configure(network::init); } diff --git a/src/api/routes/network.rs b/src/api/routes/network.rs new file mode 100644 index 000000000..633eb0d39 --- /dev/null +++ b/src/api/routes/network.rs @@ -0,0 +1,47 @@ +//! This module defines the HTTP routes for network operations. +//! It includes handlers for listing, retrieving, and updating network configurations. +//! The routes are integrated with the Actix-web framework and interact with the network controller. + +use crate::{ + api::controllers::network, + models::{DefaultAppState, PaginationQuery, UpdateNetworkRequest}, +}; +use actix_web::{get, patch, web, Responder}; + +/// Lists all networks with pagination support. +#[get("/networks")] +async fn list_networks( + query: web::Query, + data: web::ThinData, +) -> impl Responder { + network::list_networks(query.into_inner(), data).await +} + +/// Retrieves details of a specific network by ID. +#[get("/networks/{network_id}")] +async fn get_network( + network_id: web::Path, + data: web::ThinData, +) -> impl Responder { + network::get_network(network_id.into_inner(), data).await +} + +/// Updates a network's configuration. +/// Currently supports updating RPC URLs only. Can be extended to support other fields. +#[patch("/networks/{network_id}")] +async fn update_network( + network_id: web::Path, + request: web::Json, + data: web::ThinData, +) -> impl Responder { + network::update_network(network_id.into_inner(), request.into_inner(), data).await +} + +/// Initializes the routes for the network module. +pub fn init(cfg: &mut web::ServiceConfig) { + // Register routes with literal segments before routes with path parameters + cfg.service(update_network); // /networks/{network_id} + cfg.service(get_network); // /networks/{network_id} + cfg.service(list_networks); // /networks +} + diff --git a/src/models/network/mod.rs b/src/models/network/mod.rs index 50f824767..58d72432e 100644 --- a/src/models/network/mod.rs +++ b/src/models/network/mod.rs @@ -1,9 +1,13 @@ mod evm; mod repository; +mod request; +mod response; mod solana; mod stellar; pub use evm::*; pub use repository::*; +pub use request::*; +pub use response::*; pub use solana::*; pub use stellar::*; diff --git a/src/models/network/request.rs b/src/models/network/request.rs new file mode 100644 index 000000000..849346632 --- /dev/null +++ b/src/models/network/request.rs @@ -0,0 +1,249 @@ +//! API request models and validation for network endpoints. +//! +//! This module handles incoming HTTP requests for network operations, providing: +//! +//! - **Request Models**: Structures for updating network configurations via API +//! - **Input Validation**: Sanitization and validation of user-provided data +//! +//! Serves as the entry point for network data from external clients, ensuring +//! all input is properly validated before reaching the core business logic. + +use crate::models::{ApiError, RpcConfig}; +use serde::{ + de::Error as DeError, + Deserialize, Deserializer, Serialize, +}; +use serde_json; +use utoipa::ToSchema; + +/// Schema-only type representing a flexible RPC URL entry. +/// Used for OpenAPI documentation to show that rpc_urls can accept +/// either strings or RpcConfig objects. +/// +/// This is NOT used for actual deserialization - the custom deserializer +/// handles the conversion. This type exists solely for schema generation. +#[derive(Serialize, ToSchema)] +#[serde(untagged)] +#[schema(as = RpcUrlEntry)] +#[allow(dead_code)] // Only used for schema generation +pub enum RpcUrlEntry { + /// Simple string format (e.g., "https://rpc.example.com") + /// Defaults to weight 100. + String(String), + /// Extended object format with explicit weight + Config(RpcConfig), +} + +/// Custom deserializer for rpc_urls that supports: +/// - Simple format: array of strings (e.g., ["https://rpc.example.com"]) +/// - Extended format: array of RpcConfig objects (e.g., [{"url": "https://rpc.example.com", "weight": 100}]) +/// - Mixed format: array containing both strings and RpcConfig objects +/// +/// When a string URL is provided, it defaults to weight 100. +fn deserialize_rpc_urls<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + // First, deserialize as a generic Value to check what we have + let value: Option = Option::deserialize(deserializer)?; + + match value { + None => Ok(None), + Some(serde_json::Value::Array(arr)) => { + let mut configs = Vec::new(); + for item in arr { + match item { + serde_json::Value::String(url) => { + // Simple format: string -> convert to RpcConfig with default weight (100) + configs.push(RpcConfig::new(url)); + } + serde_json::Value::Object(obj) => { + // Extended format: object -> deserialize as RpcConfig + let config: RpcConfig = + serde_json::from_value(serde_json::Value::Object(obj)) + .map_err(DeError::custom)?; + configs.push(config); + } + _ => { + return Err(DeError::custom( + "rpc_urls must be an array of strings or RpcConfig objects", + )); + } + } + } + Ok(Some(configs)) + } + Some(_) => Err(DeError::custom( + "rpc_urls must be an array of strings or RpcConfig objects", + )), + } +} + +/// Request structure for updating a network configuration. +/// Currently supports updating RPC URLs only. Can be extended to support other fields. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct UpdateNetworkRequest { + /// List of RPC endpoint configurations for connecting to the network. + /// Supports multiple formats: + /// - Array of strings: `["https://rpc.example.com"]` (defaults to weight 100) + /// - Array of RpcConfig objects: `[{"url": "https://rpc.example.com", "weight": 100}]` + /// - Mixed array: `["https://rpc1.com", {"url": "https://rpc2.com", "weight": 200}]` + /// Must be non-empty and contain valid HTTP/HTTPS URLs if provided. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_rpc_urls" + )] + #[schema( + nullable = false, + example = json!([{"url": "https://rpc.example.com", "weight": 100}]), + value_type = Vec + )] + pub rpc_urls: Option>, +} + +impl UpdateNetworkRequest { + /// Validates the request data. + /// + /// # Returns + /// - `Ok(())` if the request is valid + /// - `Err(ApiError)` if validation fails + pub fn validate(&self) -> Result<(), ApiError> { + // Validate RPC URLs if provided + if let Some(ref rpc_urls) = self.rpc_urls { + // Check that rpc_urls is not empty + if rpc_urls.is_empty() { + return Err(ApiError::BadRequest( + "rpc_urls must contain at least one RPC endpoint".to_string(), + )); + } + + // Validate all RPC URLs + RpcConfig::validate_list(rpc_urls).map_err(|e| { + ApiError::BadRequest(format!("Invalid RPC URL configuration: {}", e)) + })?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_update_network_request_validation_empty_rpc_urls() { + let request = UpdateNetworkRequest { + rpc_urls: Some(vec![]), + }; + assert!(request.validate().is_err()); + } + + #[test] + fn test_update_network_request_validation_valid() { + let request = UpdateNetworkRequest { + rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]), + }; + assert!(request.validate().is_ok()); + } + + #[test] + fn test_update_network_request_validation_invalid_url() { + let request = UpdateNetworkRequest { + rpc_urls: Some(vec![RpcConfig::new("ftp://invalid.com".to_string())]), + }; + assert!(request.validate().is_err()); + } + + #[test] + fn test_update_network_request_validation_none_rpc_urls() { + let request = UpdateNetworkRequest { rpc_urls: None }; + assert!(request.validate().is_ok()); + } + + #[test] + fn test_deserialize_rpc_urls_simple_format() { + let json = r#"{"rpc_urls": ["https://rpc1.com", "https://rpc2.com"]}"#; + let request: UpdateNetworkRequest = serde_json::from_str(json).unwrap(); + assert_eq!(request.rpc_urls.as_ref().unwrap().len(), 2); + assert_eq!( + request.rpc_urls.as_ref().unwrap()[0].url, + "https://rpc1.com" + ); + assert_eq!( + request.rpc_urls.as_ref().unwrap()[0].weight, + 100u8 + ); // Default weight + assert_eq!( + request.rpc_urls.as_ref().unwrap()[1].url, + "https://rpc2.com" + ); + assert_eq!( + request.rpc_urls.as_ref().unwrap()[1].weight, + 100u8 + ); // Default weight + } + + #[test] + fn test_deserialize_rpc_urls_extended_format() { + let json = r#"{"rpc_urls": [{"url": "https://rpc1.com", "weight": 50}, {"url": "https://rpc2.com", "weight": 75}]}"#; + let request: UpdateNetworkRequest = serde_json::from_str(json).unwrap(); + assert_eq!(request.rpc_urls.as_ref().unwrap().len(), 2); + assert_eq!( + request.rpc_urls.as_ref().unwrap()[0].url, + "https://rpc1.com" + ); + assert_eq!( + request.rpc_urls.as_ref().unwrap()[0].weight, + 50u8 + ); + assert_eq!( + request.rpc_urls.as_ref().unwrap()[1].url, + "https://rpc2.com" + ); + assert_eq!( + request.rpc_urls.as_ref().unwrap()[1].weight, + 75u8 + ); + } + + #[test] + fn test_deserialize_rpc_urls_mixed_format() { + let json = r#"{"rpc_urls": ["https://rpc1.com", {"url": "https://rpc2.com", "weight": 50}]}"#; + let request: UpdateNetworkRequest = serde_json::from_str(json).unwrap(); + assert_eq!(request.rpc_urls.as_ref().unwrap().len(), 2); + assert_eq!( + request.rpc_urls.as_ref().unwrap()[0].url, + "https://rpc1.com" + ); + assert_eq!( + request.rpc_urls.as_ref().unwrap()[0].weight, + 100u8 + ); // Default weight for string + assert_eq!( + request.rpc_urls.as_ref().unwrap()[1].url, + "https://rpc2.com" + ); + assert_eq!( + request.rpc_urls.as_ref().unwrap()[1].weight, + 50u8 + ); // Explicit weight from object + } + + #[test] + fn test_deserialize_rpc_urls_none() { + let json = r#"{}"#; + let request: UpdateNetworkRequest = serde_json::from_str(json).unwrap(); + assert!(request.rpc_urls.is_none()); + } + + #[test] + fn test_deserialize_rpc_urls_invalid_format() { + let json = r#"{"rpc_urls": [123, 456]}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } +} + diff --git a/src/models/network/response.rs b/src/models/network/response.rs new file mode 100644 index 000000000..566c9cf30 --- /dev/null +++ b/src/models/network/response.rs @@ -0,0 +1,167 @@ +//! API response models for network endpoints. +//! +//! This module provides response structures for network operations, converting +//! internal repository models to API-friendly formats. + +use crate::models::{ + network::{NetworkConfigData, NetworkRepoModel}, + NetworkType, RpcConfig, +}; +use serde::Serialize; +use utoipa::ToSchema; + +/// Network response model for API endpoints. +/// +/// This flattens the internal NetworkRepoModel structure for API responses, +/// making network-type-specific fields available at the top level. +#[derive(Debug, Serialize, Clone, PartialEq, ToSchema)] +pub struct NetworkResponse { + /// Unique identifier composed of network_type and name, e.g., "evm:mainnet" + pub id: String, + /// Name of the network (e.g., "mainnet", "sepolia") + pub name: String, + /// Type of the network (EVM, Solana, Stellar) + pub network_type: NetworkType, + /// List of RPC endpoint configurations + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub rpc_urls: Option>, + /// List of Explorer endpoint URLs + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub explorer_urls: Option>, + /// Estimated average time between blocks in milliseconds + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub average_blocktime_ms: Option, + /// Flag indicating if the network is a testnet + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub is_testnet: Option, + /// List of arbitrary tags for categorizing or filtering networks + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub tags: Option>, + /// EVM-specific: Chain ID + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub chain_id: Option, + /// EVM-specific: Required confirmations + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub required_confirmations: Option, + /// EVM-specific: Network features (e.g., "eip1559") + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub features: Option>, + /// EVM-specific: Native token symbol + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub symbol: Option, + /// Stellar-specific: Network passphrase + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub passphrase: Option, + /// Stellar-specific: Horizon URL + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub horizon_url: Option, +} + +impl From for NetworkResponse { + fn from(model: NetworkRepoModel) -> Self { + let id = model.id.clone(); + let name = model.name.clone(); + let network_type = model.network_type; + let common = model.common(); + let mut response = NetworkResponse { + id, + name, + network_type, + rpc_urls: common.rpc_urls.clone(), + explorer_urls: common.explorer_urls.clone(), + average_blocktime_ms: common.average_blocktime_ms, + is_testnet: common.is_testnet, + tags: common.tags.clone(), + chain_id: None, + required_confirmations: None, + features: None, + symbol: None, + passphrase: None, + horizon_url: None, + }; + + // Add network-type-specific fields + match model.config { + NetworkConfigData::Evm(evm_config) => { + response.chain_id = evm_config.chain_id; + response.required_confirmations = evm_config.required_confirmations; + response.features = evm_config.features.clone(); + response.symbol = evm_config.symbol.clone(); + } + NetworkConfigData::Solana(_) => { + // Solana doesn't have additional fields beyond common + } + NetworkConfigData::Stellar(stellar_config) => { + response.passphrase = stellar_config.passphrase.clone(); + response.horizon_url = stellar_config.horizon_url.clone(); + } + } + + response + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::RpcConfig; + + fn create_test_evm_network() -> NetworkRepoModel { + use crate::config::EvmNetworkConfig; + use crate::config::NetworkConfigCommon; + NetworkRepoModel::new_evm(EvmNetworkConfig { + common: NetworkConfigCommon { + network: "mainnet".to_string(), + from: None, + rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]), + explorer_urls: Some(vec!["https://explorer.example.com".to_string()]), + average_blocktime_ms: Some(12000), + is_testnet: Some(false), + tags: Some(vec!["mainnet".to_string()]), + }, + chain_id: Some(1), + required_confirmations: Some(12), + features: Some(vec!["eip1559".to_string()]), + symbol: Some("ETH".to_string()), + gas_price_cache: None, + }) + } + + #[test] + fn test_from_network_repo_model_evm() { + let model = create_test_evm_network(); + let response = NetworkResponse::from(model); + + assert_eq!(response.id, "evm:mainnet"); + assert_eq!(response.name, "mainnet"); + assert_eq!(response.network_type, NetworkType::Evm); + assert_eq!(response.chain_id, Some(1)); + assert_eq!(response.required_confirmations, Some(12)); + assert_eq!(response.symbol, Some("ETH".to_string())); + assert_eq!(response.features, Some(vec!["eip1559".to_string()])); + } + + #[test] + fn test_from_network_repo_model_common_fields() { + let model = create_test_evm_network(); + let response = NetworkResponse::from(model); + + assert!(response.rpc_urls.is_some()); + assert!(response.explorer_urls.is_some()); + assert_eq!(response.average_blocktime_ms, Some(12000)); + assert_eq!(response.is_testnet, Some(false)); + assert!(response.tags.is_some()); + } +} + diff --git a/src/openapi.rs b/src/openapi.rs index 29df0a064..a75b31b59 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -1,6 +1,6 @@ use crate::{ api::routes::{ - docs::{notification_docs, plugin_docs, relayer_docs, signer_docs}, + docs::{network_docs, notification_docs, plugin_docs, relayer_docs, signer_docs}, health, metrics, }, domain, models, @@ -44,6 +44,7 @@ impl Modify for SecurityAddon { (name = "Plugins", description = "Plugins are TypeScript functions that can be used to extend the OpenZeppelin Relayer API functionality."), (name = "Notifications", description = "Notifications are responsible for showing the notifications related to the relayers."), (name = "Signers", description = "Signers are responsible for signing the transactions related to the relayers."), + (name = "Networks", description = "Networks represent blockchain network configurations including RPC endpoints and network-specific settings."), (name = "Metrics", description = "Metrics are responsible for showing the metrics related to the relayers."), (name = "Health", description = "Health is responsible for showing the health of the relayers.") ), @@ -92,6 +93,9 @@ impl Modify for SecurityAddon { signer_docs::doc_create_signer, signer_docs::doc_update_signer, signer_docs::doc_delete_signer, + network_docs::doc_list_networks, + network_docs::doc_get_network, + network_docs::doc_update_network, ), components(schemas( models::RelayerResponse, @@ -101,6 +105,9 @@ impl Modify for SecurityAddon { models::SolanaPolicyResponse, models::StellarPolicyResponse, models::UpdateRelayerRequest, + models::NetworkResponse, + models::UpdateNetworkRequest, + models::RpcUrlEntry, domain::SignDataRequest, domain::SignTypedDataRequest, domain::SignTransactionRequest, From 037a56a6c6a150824a4481caa9f4178b3657bc41 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 30 Dec 2025 23:46:34 +0100 Subject: [PATCH 3/9] chore: format --- src/api/controllers/network.rs | 69 ++++++++++++++++++----------- src/api/routes/docs/network_docs.rs | 1 - src/api/routes/network.rs | 1 - src/models/network/request.rs | 39 ++++------------ src/models/network/response.rs | 1 - 5 files changed, 53 insertions(+), 58 deletions(-) diff --git a/src/api/controllers/network.rs b/src/api/controllers/network.rs index b783e9386..62fbad9f2 100644 --- a/src/api/controllers/network.rs +++ b/src/api/controllers/network.rs @@ -9,8 +9,8 @@ use crate::{ config::{EvmNetworkConfig, SolanaNetworkConfig, StellarNetworkConfig}, jobs::JobProducerTrait, models::{ - NetworkConfigData, NetworkRepoModel, NetworkResponse, UpdateNetworkRequest, - ApiError, ApiResponse, PaginationMeta, PaginationQuery, ThinDataAppState, + ApiError, ApiResponse, NetworkConfigData, NetworkRepoModel, NetworkResponse, + PaginationMeta, PaginationQuery, ThinDataAppState, UpdateNetworkRequest, }, repositories::{ ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, @@ -36,8 +36,16 @@ pub async fn list_networks( ) -> Result where J: JobProducerTrait + Send + Sync + 'static, - RR: RelayerRepository + Repository + Send + Sync + 'static, - TR: TransactionRepository + Repository + Send + Sync + 'static, + RR: RelayerRepository + + Repository + + Send + + Sync + + 'static, + TR: TransactionRepository + + Repository + + Send + + Sync + + 'static, NR: NetworkRepository + Repository + Send + Sync + 'static, NFR: Repository + Send + Sync + 'static, SR: Repository + Send + Sync + 'static, @@ -76,8 +84,16 @@ pub async fn get_network( ) -> Result where J: JobProducerTrait + Send + Sync + 'static, - RR: RelayerRepository + Repository + Send + Sync + 'static, - TR: TransactionRepository + Repository + Send + Sync + 'static, + RR: RelayerRepository + + Repository + + Send + + Sync + + 'static, + TR: TransactionRepository + + Repository + + Send + + Sync + + 'static, NR: NetworkRepository + Repository + Send + Sync + 'static, NFR: Repository + Send + Sync + 'static, SR: Repository + Send + Sync + 'static, @@ -120,8 +136,16 @@ pub async fn update_network( ) -> Result where J: JobProducerTrait + Send + Sync + 'static, - RR: RelayerRepository + Repository + Send + Sync + 'static, - TR: TransactionRepository + Repository + Send + Sync + 'static, + RR: RelayerRepository + + Repository + + Send + + Sync + + 'static, + TR: TransactionRepository + + Repository + + Send + + Sync + + 'static, NR: NetworkRepository + Repository + Send + Sync + 'static, NFR: Repository + Send + Sync + 'static, SR: Repository + Send + Sync + 'static, @@ -154,7 +178,7 @@ where // Update fields in the network config let common = network.common(); let mut updated_common = common.clone(); - + // Update RPC URLs if provided if let Some(rpc_urls) = request.rpc_urls { updated_common.rpc_urls = Some(rpc_urls); @@ -162,21 +186,17 @@ where // Update the network config based on type let updated_config = match network.config { - NetworkConfigData::Evm(evm_config) => { - NetworkConfigData::Evm(EvmNetworkConfig { - common: updated_common, - chain_id: evm_config.chain_id, - required_confirmations: evm_config.required_confirmations, - features: evm_config.features, - symbol: evm_config.symbol, - gas_price_cache: evm_config.gas_price_cache, - }) - } - NetworkConfigData::Solana(_) => { - NetworkConfigData::Solana(SolanaNetworkConfig { - common: updated_common, - }) - } + NetworkConfigData::Evm(evm_config) => NetworkConfigData::Evm(EvmNetworkConfig { + common: updated_common, + chain_id: evm_config.chain_id, + required_confirmations: evm_config.required_confirmations, + features: evm_config.features, + symbol: evm_config.symbol, + gas_price_cache: evm_config.gas_price_cache, + }), + NetworkConfigData::Solana(_) => NetworkConfigData::Solana(SolanaNetworkConfig { + common: updated_common, + }), NetworkConfigData::Stellar(stellar_config) => { NetworkConfigData::Stellar(StellarNetworkConfig { common: updated_common, @@ -199,4 +219,3 @@ where Ok(HttpResponse::Ok().json(ApiResponse::success(network_response))) } - diff --git a/src/api/routes/docs/network_docs.rs b/src/api/routes/docs/network_docs.rs index ec48b9737..171daa755 100644 --- a/src/api/routes/docs/network_docs.rs +++ b/src/api/routes/docs/network_docs.rs @@ -195,4 +195,3 @@ fn doc_get_network() {} )] #[allow(dead_code)] fn doc_update_network() {} - diff --git a/src/api/routes/network.rs b/src/api/routes/network.rs index 633eb0d39..7d10f550f 100644 --- a/src/api/routes/network.rs +++ b/src/api/routes/network.rs @@ -44,4 +44,3 @@ pub fn init(cfg: &mut web::ServiceConfig) { cfg.service(get_network); // /networks/{network_id} cfg.service(list_networks); // /networks } - diff --git a/src/models/network/request.rs b/src/models/network/request.rs index 849346632..2c0c8b1cc 100644 --- a/src/models/network/request.rs +++ b/src/models/network/request.rs @@ -9,10 +9,7 @@ //! all input is properly validated before reaching the core business logic. use crate::models::{ApiError, RpcConfig}; -use serde::{ - de::Error as DeError, - Deserialize, Deserializer, Serialize, -}; +use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize}; use serde_json; use utoipa::ToSchema; @@ -172,18 +169,12 @@ mod tests { request.rpc_urls.as_ref().unwrap()[0].url, "https://rpc1.com" ); - assert_eq!( - request.rpc_urls.as_ref().unwrap()[0].weight, - 100u8 - ); // Default weight + assert_eq!(request.rpc_urls.as_ref().unwrap()[0].weight, 100u8); // Default weight assert_eq!( request.rpc_urls.as_ref().unwrap()[1].url, "https://rpc2.com" ); - assert_eq!( - request.rpc_urls.as_ref().unwrap()[1].weight, - 100u8 - ); // Default weight + assert_eq!(request.rpc_urls.as_ref().unwrap()[1].weight, 100u8); // Default weight } #[test] @@ -195,41 +186,30 @@ mod tests { request.rpc_urls.as_ref().unwrap()[0].url, "https://rpc1.com" ); - assert_eq!( - request.rpc_urls.as_ref().unwrap()[0].weight, - 50u8 - ); + assert_eq!(request.rpc_urls.as_ref().unwrap()[0].weight, 50u8); assert_eq!( request.rpc_urls.as_ref().unwrap()[1].url, "https://rpc2.com" ); - assert_eq!( - request.rpc_urls.as_ref().unwrap()[1].weight, - 75u8 - ); + assert_eq!(request.rpc_urls.as_ref().unwrap()[1].weight, 75u8); } #[test] fn test_deserialize_rpc_urls_mixed_format() { - let json = r#"{"rpc_urls": ["https://rpc1.com", {"url": "https://rpc2.com", "weight": 50}]}"#; + let json = + r#"{"rpc_urls": ["https://rpc1.com", {"url": "https://rpc2.com", "weight": 50}]}"#; let request: UpdateNetworkRequest = serde_json::from_str(json).unwrap(); assert_eq!(request.rpc_urls.as_ref().unwrap().len(), 2); assert_eq!( request.rpc_urls.as_ref().unwrap()[0].url, "https://rpc1.com" ); - assert_eq!( - request.rpc_urls.as_ref().unwrap()[0].weight, - 100u8 - ); // Default weight for string + assert_eq!(request.rpc_urls.as_ref().unwrap()[0].weight, 100u8); // Default weight for string assert_eq!( request.rpc_urls.as_ref().unwrap()[1].url, "https://rpc2.com" ); - assert_eq!( - request.rpc_urls.as_ref().unwrap()[1].weight, - 50u8 - ); // Explicit weight from object + assert_eq!(request.rpc_urls.as_ref().unwrap()[1].weight, 50u8); // Explicit weight from object } #[test] @@ -246,4 +226,3 @@ mod tests { assert!(result.is_err()); } } - diff --git a/src/models/network/response.rs b/src/models/network/response.rs index 566c9cf30..273452979 100644 --- a/src/models/network/response.rs +++ b/src/models/network/response.rs @@ -164,4 +164,3 @@ mod tests { assert!(response.tags.is_some()); } } - From 53e8e53492431486aa2199278859f1743a765e5e Mon Sep 17 00:00:00 2001 From: Zeljko Date: Tue, 30 Dec 2025 23:48:50 +0100 Subject: [PATCH 4/9] chore: clippy --- src/api/controllers/network.rs | 8 ++++---- src/models/network/request.rs | 7 +++---- src/utils/error_sanitization.rs | 4 ++-- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/api/controllers/network.rs b/src/api/controllers/network.rs index 62fbad9f2..346406773 100644 --- a/src/api/controllers/network.rs +++ b/src/api/controllers/network.rs @@ -107,9 +107,9 @@ where .await .map_err(|e| match e { crate::models::RepositoryError::NotFound(_) => { - ApiError::NotFound(format!("Network with ID '{}' not found", network_id)) + ApiError::NotFound(format!("Network with ID '{network_id}' not found")) } - _ => ApiError::InternalError(format!("Failed to retrieve network: {}", e)), + _ => ApiError::InternalError(format!("Failed to retrieve network: {e}")), })?; let network_response: NetworkResponse = network.into(); @@ -170,9 +170,9 @@ where .await .map_err(|e| match e { crate::models::RepositoryError::NotFound(_) => { - ApiError::NotFound(format!("Network with ID '{}' not found", network_id)) + ApiError::NotFound(format!("Network with ID '{network_id}' not found")) } - _ => ApiError::InternalError(format!("Failed to retrieve network: {}", e)), + _ => ApiError::InternalError(format!("Failed to retrieve network: {e}")), })?; // Update fields in the network config diff --git a/src/models/network/request.rs b/src/models/network/request.rs index 2c0c8b1cc..a7395844a 100644 --- a/src/models/network/request.rs +++ b/src/models/network/request.rs @@ -86,7 +86,7 @@ pub struct UpdateNetworkRequest { /// - Array of strings: `["https://rpc.example.com"]` (defaults to weight 100) /// - Array of RpcConfig objects: `[{"url": "https://rpc.example.com", "weight": 100}]` /// - Mixed array: `["https://rpc1.com", {"url": "https://rpc2.com", "weight": 200}]` - /// Must be non-empty and contain valid HTTP/HTTPS URLs if provided. + /// Must be non-empty and contain valid HTTP/HTTPS URLs if provided. #[serde( default, skip_serializing_if = "Option::is_none", @@ -117,9 +117,8 @@ impl UpdateNetworkRequest { } // Validate all RPC URLs - RpcConfig::validate_list(rpc_urls).map_err(|e| { - ApiError::BadRequest(format!("Invalid RPC URL configuration: {}", e)) - })?; + RpcConfig::validate_list(rpc_urls) + .map_err(|e| ApiError::BadRequest(format!("Invalid RPC URL configuration: {e}")))?; } Ok(()) diff --git a/src/utils/error_sanitization.rs b/src/utils/error_sanitization.rs index 1d684517d..b0c278f14 100644 --- a/src/utils/error_sanitization.rs +++ b/src/utils/error_sanitization.rs @@ -80,10 +80,10 @@ pub fn sanitize_error_description(error: &ProviderError) -> String { "Service temporarily unavailable. Please try again later".to_string() } ProviderError::RequestError { status_code, .. } => { - format!("Request failed with status code {}", status_code) + format!("Request failed with status code {status_code}") } ProviderError::RpcErrorCode { code, .. } => { - format!("RPC error occurred (code: {})", code) + format!("RPC error occurred (code: {code})") } ProviderError::TransportError(_) => { "Network error occurred. Please try again later".to_string() From 0e5b3426450c7d3e7b930752fb8dcaef305dc79c Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 31 Dec 2025 00:22:34 +0100 Subject: [PATCH 5/9] chore: Improvements --- src/config/config_file/network/inheritance.rs | 2 +- src/models/network/request.rs | 2 +- src/services/provider/mod.rs | 12 +- src/services/provider/retry.rs | 20 +- src/services/provider/rpc_selector.rs | 228 ++++++++++++------ 5 files changed, 171 insertions(+), 93 deletions(-) diff --git a/src/config/config_file/network/inheritance.rs b/src/config/config_file/network/inheritance.rs index b4455eb2e..274c7295e 100644 --- a/src/config/config_file/network/inheritance.rs +++ b/src/config/config_file/network/inheritance.rs @@ -713,7 +713,7 @@ mod tests { let resolved = result.unwrap(); assert_eq!(resolved.common.network, "child"); - // Child's RPC URLs are merged with parent's (parent first, then child) + // Child's RPC URLs override parent's assert_eq!( resolved.common.rpc_urls, Some(vec![crate::models::RpcConfig::new( diff --git a/src/models/network/request.rs b/src/models/network/request.rs index a7395844a..35f9c6e92 100644 --- a/src/models/network/request.rs +++ b/src/models/network/request.rs @@ -85,7 +85,7 @@ pub struct UpdateNetworkRequest { /// Supports multiple formats: /// - Array of strings: `["https://rpc.example.com"]` (defaults to weight 100) /// - Array of RpcConfig objects: `[{"url": "https://rpc.example.com", "weight": 100}]` - /// - Mixed array: `["https://rpc1.com", {"url": "https://rpc2.com", "weight": 200}]` + /// - Mixed array: `["https://rpc1.com", {"url": "https://rpc2.com", "weight": 100}]` /// Must be non-empty and contain valid HTTP/HTTPS URLs if provided. #[serde( default, diff --git a/src/services/provider/mod.rs b/src/services/provider/mod.rs index 4508f11e4..433432419 100644 --- a/src/services/provider/mod.rs +++ b/src/services/provider/mod.rs @@ -264,9 +264,7 @@ impl NetworkConfiguration for EvmNetwork { type Provider = EvmProvider; fn public_rpc_urls(&self) -> Vec { - self.public_rpc_urls() - .map(|configs| configs.to_vec()) - .unwrap_or_default() + self.rpc_urls.clone() } fn new_provider(config: ProviderConfig) -> Result { @@ -278,9 +276,7 @@ impl NetworkConfiguration for SolanaNetwork { type Provider = SolanaProvider; fn public_rpc_urls(&self) -> Vec { - self.public_rpc_urls() - .map(|configs| configs.to_vec()) - .unwrap_or_default() + self.rpc_urls.clone() } fn new_provider(config: ProviderConfig) -> Result { @@ -292,9 +288,7 @@ impl NetworkConfiguration for StellarNetwork { type Provider = StellarProvider; fn public_rpc_urls(&self) -> Vec { - self.public_rpc_urls() - .map(|configs| configs.to_vec()) - .unwrap_or_default() + self.rpc_urls.clone() } fn new_provider(config: ProviderConfig) -> Result { diff --git a/src/services/provider/retry.rs b/src/services/provider/retry.rs index ea7f416cb..014fbac43 100644 --- a/src/services/provider/retry.rs +++ b/src/services/provider/retry.rs @@ -222,8 +222,6 @@ where ); // Continue retrying as long as we haven't exceeded max failovers and there are providers to try - // Note: We use provider_count() instead of available_provider_count() because select_url() - // will now fall back to paused providers when no non-paused providers are available. while failover_count <= max_failovers && selector.provider_count() > 0 { // Try to get and initialize a provider let (provider, provider_url) = @@ -478,6 +476,7 @@ mod tests { use crate::models::RpcConfig; use crate::services::provider::rpc_health_store::RpcHealthStore; use lazy_static::lazy_static; + use serial_test::serial; use std::cmp::Ordering; use std::collections::HashSet; use std::env; @@ -1003,6 +1002,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_non_retriable_error_does_not_mark_provider_failed() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1051,6 +1051,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_retriable_error_marks_provider_failed_after_retries_exhausted() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1096,6 +1097,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_retry_rpc_call_success() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1139,6 +1141,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_retry_rpc_call_with_provider_failover() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1192,6 +1195,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_retry_rpc_call_all_providers_fail() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1259,6 +1263,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_retry_rpc_call_provider_initialization_failures() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1332,6 +1337,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_last_provider_never_marked_as_failed() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1377,6 +1383,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_last_provider_behavior_with_multiple_providers() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1425,6 +1432,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_non_retriable_error_should_mark_provider_failed() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1472,6 +1480,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_non_retriable_error_should_not_mark_provider_failed() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1519,6 +1528,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_retriable_error_ignores_should_mark_provider_failed() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1566,6 +1576,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_mixed_error_scenarios_with_different_marking_behavior() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1633,6 +1644,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_should_mark_provider_failed_respects_last_provider_protection() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1679,6 +1691,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_should_mark_provider_failed_with_multiple_providers_last_protection() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1732,6 +1745,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_tried_urls_tracking_prevents_duplicate_selection() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1774,6 +1788,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_all_providers_tried_returns_error() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); @@ -1812,6 +1827,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_tried_urls_passed_to_selector() { let _guard = setup_test_env(); RpcHealthStore::instance().clear_all(); diff --git a/src/services/provider/rpc_selector.rs b/src/services/provider/rpc_selector.rs index f95889698..7c8c04c74 100644 --- a/src/services/provider/rpc_selector.rs +++ b/src/services/provider/rpc_selector.rs @@ -242,6 +242,101 @@ impl RpcSelector { } } + /// Attempts weighted selection of a provider. + /// + /// # Arguments + /// * `configs` - RPC configurations + /// * `excluded_urls` - URLs of providers that have already been tried + /// * `allow_paused` - If true, allows selection of paused providers + /// * `health_store` - Health store instance + /// * `expiration` - Duration after which failures expire + /// + /// # Returns + /// * `Option<(usize, String)>` - Some(index, url) if a provider was selected, None otherwise + fn try_weighted_selection( + &self, + configs: &[RpcConfig], + excluded_urls: &std::collections::HashSet, + allow_paused: bool, + health_store: &RpcHealthStore, + expiration: chrono::Duration, + ) -> Option<(usize, String)> { + let dist = self.weights_dist.as_ref()?; + let mut rng = rand::rng(); + + const MAX_ATTEMPTS: usize = 10; + for _ in 0..MAX_ATTEMPTS { + let index = dist.sample(&mut rng); + // Skip providers with zero weight + if configs[index].get_weight() == 0 { + continue; + } + // Skip providers already tried in this failover cycle + if excluded_urls.contains(&configs[index].url) { + continue; + } + // Check health status unless paused providers are allowed + if !allow_paused + && health_store.is_paused(&configs[index].url, self.failure_threshold, expiration) + { + continue; + } + // Found a suitable provider + self.current_index.store(index, Ordering::Relaxed); + self.has_current.store(true, Ordering::Relaxed); + return Some((index, configs[index].url.clone())); + } + None + } + + /// Attempts round-robin selection of a provider. + /// + /// # Arguments + /// * `configs` - RPC configurations + /// * `excluded_urls` - URLs of providers that have already been tried + /// * `allow_paused` - If true, allows selection of paused providers + /// * `health_store` - Health store instance + /// * `expiration` - Duration after which failures expire + /// * `start_index` - Starting index for round-robin iteration + /// + /// # Returns + /// * `Option<(usize, String)>` - Some(index, url) if a provider was selected, None otherwise + fn try_round_robin_selection( + &self, + configs: &[RpcConfig], + excluded_urls: &std::collections::HashSet, + allow_paused: bool, + health_store: &RpcHealthStore, + expiration: chrono::Duration, + start_index: usize, + ) -> Option<(usize, String)> { + let len = configs.len(); + for i in 0..len { + let index = (start_index + i) % len; + // Skip providers with zero weight + if configs[index].get_weight() == 0 { + continue; + } + // Skip providers already tried in this failover cycle + if excluded_urls.contains(&configs[index].url) { + continue; + } + // Check health status unless paused providers are allowed + if !allow_paused + && health_store.is_paused(&configs[index].url, self.failure_threshold, expiration) + { + continue; + } + // Found a suitable provider + // Update the next_index atomically to point after this provider + self.next_index.store((index + 1) % len, Ordering::Relaxed); + self.current_index.store(index, Ordering::Relaxed); + self.has_current.store(true, Ordering::Relaxed); + return Some((index, configs[index].url.clone())); + } + None + } + /// Gets the URL of the next RPC endpoint based on the selection strategy. /// /// This method first tries to select non-paused providers. If no non-paused providers @@ -280,56 +375,27 @@ impl RpcSelector { // First, try to find a non-paused provider // Try weighted selection first if available - if let Some(dist) = &self.weights_dist { - let mut rng = rand::rng(); - - // Try a limited number of times to find a non-paused provider with weighted selection - const MAX_ATTEMPTS: usize = 10; - for _ in 0..MAX_ATTEMPTS { - let index = dist.sample(&mut rng); - // Skip providers with zero weight - if configs[index].get_weight() == 0 { - continue; - } - // Skip providers already tried in this failover cycle - if excluded_urls.contains(&configs[index].url) { - continue; - } - if !health_store.is_paused(&configs[index].url, self.failure_threshold, expiration) - { - self.current_index.store(index, Ordering::Relaxed); - self.has_current.store(true, Ordering::Relaxed); - return Ok(configs[index].url.clone()); - } - } + if let Some((_, url)) = self.try_weighted_selection( + &configs, + excluded_urls, + false, // allow_paused = false + &health_store, + expiration, + ) { + return Ok(url); } // Fall back to round-robin selection for non-paused providers - let len = configs.len(); - let start_index = self.next_index.load(Ordering::Relaxed) % len; - - // Find the next available (non-paused) provider - for i in 0..len { - let index = (start_index + i) % len; - // Skip providers with zero weight - if configs[index].get_weight() == 0 { - continue; - } - // Skip providers already tried in this failover cycle - if excluded_urls.contains(&configs[index].url) { - continue; - } - - if !health_store.is_paused(&configs[index].url, self.failure_threshold, expiration) { - // Update the next_index atomically to point after this provider - self.next_index.store((index + 1) % len, Ordering::Relaxed); - - // Set as current provider - self.current_index.store(index, Ordering::Relaxed); - self.has_current.store(true, Ordering::Relaxed); - - return Ok(configs[index].url.clone()); - } + let start_index = self.next_index.load(Ordering::Relaxed) % configs.len(); + if let Some((_, url)) = self.try_round_robin_selection( + &configs, + excluded_urls, + false, // allow_paused = false + &health_store, + expiration, + start_index, + ) { + return Ok(url); } // If we get here, no non-paused providers are available @@ -339,42 +405,26 @@ impl RpcSelector { ); // Try weighted selection for paused providers - if let Some(dist) = &self.weights_dist { - let mut rng = rand::rng(); - const MAX_ATTEMPTS: usize = 10; - for _ in 0..MAX_ATTEMPTS { - let index = dist.sample(&mut rng); - // Skip providers with zero weight - if configs[index].get_weight() == 0 { - continue; - } - // Skip providers already tried in this failover cycle - if excluded_urls.contains(&configs[index].url) { - continue; - } - // Accept paused providers as last resort - self.current_index.store(index, Ordering::Relaxed); - self.has_current.store(true, Ordering::Relaxed); - return Ok(configs[index].url.clone()); - } + if let Some((_, url)) = self.try_weighted_selection( + &configs, + excluded_urls, + true, // allow_paused = true + &health_store, + expiration, + ) { + return Ok(url); } // Fall back to round-robin for paused providers - for i in 0..len { - let index = (start_index + i) % len; - // Skip providers with zero weight - if configs[index].get_weight() == 0 { - continue; - } - // Skip providers already tried in this failover cycle - if excluded_urls.contains(&configs[index].url) { - continue; - } - // Accept paused providers as last resort - self.next_index.store((index + 1) % len, Ordering::Relaxed); - self.current_index.store(index, Ordering::Relaxed); - self.has_current.store(true, Ordering::Relaxed); - return Ok(configs[index].url.clone()); + if let Some((_, url)) = self.try_round_robin_selection( + &configs, + excluded_urls, + true, // allow_paused = true + &health_store, + expiration, + start_index, + ) { + return Ok(url); } // If we get here, all providers have zero weight (shouldn't happen in practice) @@ -449,6 +499,7 @@ impl Clone for RpcSelector { mod tests { use super::*; use crate::services::provider::rpc_health_store::RpcHealthStore; + use serial_test::serial; use std::sync::Arc; use std::thread; @@ -727,6 +778,7 @@ mod tests { } #[test] + #[serial] fn test_mark_current_as_failed_single_provider() { // Clear health store to ensure clean state RpcHealthStore::instance().clear_all(); @@ -759,6 +811,7 @@ mod tests { } #[test] + #[serial] fn test_mark_current_as_failed_multiple_providers() { // Clear health store to ensure clean state RpcHealthStore::instance().clear_all(); @@ -825,6 +878,7 @@ mod tests { } #[test] + #[serial] fn test_mark_current_as_failed_weighted() { // Clear health store to ensure clean state RpcHealthStore::instance().clear_all(); @@ -911,6 +965,7 @@ mod tests { } #[test] + #[serial] fn test_available_provider_count() { // Clear health store to ensure clean state RpcHealthStore::instance().clear_all(); @@ -971,6 +1026,7 @@ mod tests { } #[test] + #[serial] fn test_concurrent_usage() { // Clear health store to ensure clean state RpcHealthStore::instance().clear_all(); @@ -1047,6 +1103,7 @@ mod tests { } #[test] + #[serial] fn test_weighted_to_round_robin_fallback() { // Clear health store to ensure clean state RpcHealthStore::instance().clear_all(); @@ -1140,6 +1197,7 @@ mod tests { } #[test] + #[serial] fn test_extreme_weight_differences() { let configs = vec![ RpcConfig { @@ -1241,6 +1299,7 @@ mod tests { /// 3. The selector should switch to the second RPC /// 4. The first RPC should be marked as failed and excluded from selection #[test] + #[serial] fn test_rpc_selector_switches_on_rate_limit() { RpcHealthStore::instance().clear_all(); let configs = vec![ @@ -1306,6 +1365,7 @@ mod tests { /// This test verifies that even with weighted selection, a rate-limited provider /// is excluded and the selector falls back to the other provider. #[test] + #[serial] fn test_rpc_selector_rate_limit_with_weighted_selection() { RpcHealthStore::instance().clear_all(); let configs = vec![ @@ -1368,6 +1428,7 @@ mod tests { /// This test verifies that failed providers remain failed, /// which simulates persistence of health state. #[test] + #[serial] fn test_rpc_selector_rate_limit_recovery() { RpcHealthStore::instance().clear_all(); let configs = vec![ @@ -1409,6 +1470,7 @@ mod tests { /// Test that when both providers are rate-limited, the selector handles it gracefully. #[test] + #[serial] fn test_rpc_selector_both_providers_rate_limited() { RpcHealthStore::instance().clear_all(); let configs = vec![ @@ -1452,6 +1514,7 @@ mod tests { /// This test verifies that when weighted selection fails due to rate limiting, /// the selector correctly falls back to round-robin selection. #[test] + #[serial] fn test_rpc_selector_rate_limit_round_robin_fallback() { RpcHealthStore::instance().clear_all(); let configs = vec![ @@ -1509,6 +1572,7 @@ mod tests { } #[test] + #[serial] fn test_select_url_excludes_tried_providers() { RpcHealthStore::instance().clear_all(); let configs = vec![ @@ -1543,6 +1607,7 @@ mod tests { } #[test] + #[serial] fn test_select_url_fallback_to_paused_providers() { RpcHealthStore::instance().clear_all(); let configs = vec![ @@ -1613,6 +1678,7 @@ mod tests { } #[test] + #[serial] fn test_select_url_single_provider_excluded() { RpcHealthStore::instance().clear_all(); let configs = vec![RpcConfig { @@ -1637,6 +1703,7 @@ mod tests { } #[test] + #[serial] fn test_select_url_all_providers_excluded() { RpcHealthStore::instance().clear_all(); let configs = vec![ @@ -1669,6 +1736,7 @@ mod tests { } #[test] + #[serial] fn test_select_url_excluded_providers_with_weighted_selection() { RpcHealthStore::instance().clear_all(); let configs = vec![ From 9e33ade63d0e41a693f42cb8a52e5f5769e6f03d Mon Sep 17 00:00:00 2001 From: Zeljko Date: Wed, 31 Dec 2025 00:54:12 +0100 Subject: [PATCH 6/9] chore: add intergration tests --- src/models/network/response.rs | 4 +- src/services/provider/rpc_selector.rs | 8 +- tests/integration/common/client.rs | 134 +++++++ tests/integration/tests/authorization.rs | 6 + tests/integration/tests/mod.rs | 1 + tests/integration/tests/network_api.rs | 467 +++++++++++++++++++++++ 6 files changed, 614 insertions(+), 6 deletions(-) create mode 100644 tests/integration/tests/network_api.rs diff --git a/src/models/network/response.rs b/src/models/network/response.rs index 273452979..bf1ba741e 100644 --- a/src/models/network/response.rs +++ b/src/models/network/response.rs @@ -7,14 +7,14 @@ use crate::models::{ network::{NetworkConfigData, NetworkRepoModel}, NetworkType, RpcConfig, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use utoipa::ToSchema; /// Network response model for API endpoints. /// /// This flattens the internal NetworkRepoModel structure for API responses, /// making network-type-specific fields available at the top level. -#[derive(Debug, Serialize, Clone, PartialEq, ToSchema)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] pub struct NetworkResponse { /// Unique identifier composed of network_type and name, e.g., "evm:mainnet" pub id: String, diff --git a/src/services/provider/rpc_selector.rs b/src/services/provider/rpc_selector.rs index 7c8c04c74..b8a28d6e9 100644 --- a/src/services/provider/rpc_selector.rs +++ b/src/services/provider/rpc_selector.rs @@ -379,7 +379,7 @@ impl RpcSelector { &configs, excluded_urls, false, // allow_paused = false - &health_store, + health_store, expiration, ) { return Ok(url); @@ -391,7 +391,7 @@ impl RpcSelector { &configs, excluded_urls, false, // allow_paused = false - &health_store, + health_store, expiration, start_index, ) { @@ -409,7 +409,7 @@ impl RpcSelector { &configs, excluded_urls, true, // allow_paused = true - &health_store, + health_store, expiration, ) { return Ok(url); @@ -420,7 +420,7 @@ impl RpcSelector { &configs, excluded_urls, true, // allow_paused = true - &health_store, + health_store, expiration, start_index, ) { diff --git a/tests/integration/common/client.rs b/tests/integration/common/client.rs index 098ea4a1c..9dc337951 100644 --- a/tests/integration/common/client.rs +++ b/tests/integration/common/client.rs @@ -418,6 +418,140 @@ impl RelayerClient { Ok(()) } + + /// Lists all networks with pagination support + /// + /// GET /api/v1/networks + pub async fn list_networks( + &self, + page: Option, + per_page: Option, + ) -> Result> { + let mut url = format!("{}/api/v1/networks", self.base_url); + let mut query_params = Vec::new(); + if let Some(p) = page { + query_params.push(format!("page={}", p)); + } + if let Some(pp) = per_page { + query_params.push(format!("per_page={}", pp)); + } + if !query_params.is_empty() { + url.push('?'); + url.push_str(&query_params.join("&")); + } + + let response = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .send() + .await + .wrap_err_with(|| format!("Failed to send request to {}", url))?; + + let status = response.status(); + let body = response + .text() + .await + .wrap_err("Failed to read response body")?; + + if !status.is_success() { + return Err(eyre::eyre!( + "API request failed with status {}: {}", + status, + body + )); + } + + let api_response: ApiResponse> = + serde_json::from_str(&body) + .wrap_err_with(|| format!("Failed to parse response: {}", body))?; + + api_response + .data + .ok_or_else(|| eyre::eyre!("API response missing data field")) + } + + /// Retrieves details of a specific network by ID + /// + /// GET /api/v1/networks/{network_id} + pub async fn get_network( + &self, + network_id: &str, + ) -> Result { + let url = format!("{}/api/v1/networks/{}", self.base_url, network_id); + + let response = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .send() + .await + .wrap_err_with(|| format!("Failed to send request to {}", url))?; + + let status = response.status(); + let body = response + .text() + .await + .wrap_err("Failed to read response body")?; + + if !status.is_success() { + return Err(eyre::eyre!( + "API request failed with status {}: {}", + status, + body + )); + } + + let api_response: ApiResponse = + serde_json::from_str(&body) + .wrap_err_with(|| format!("Failed to parse response: {}", body))?; + + api_response + .data + .ok_or_else(|| eyre::eyre!("API response missing data field")) + } + + /// Updates a network's configuration + /// + /// PATCH /api/v1/networks/{network_id} + pub async fn update_network( + &self, + network_id: &str, + request: openzeppelin_relayer::models::UpdateNetworkRequest, + ) -> Result { + let url = format!("{}/api/v1/networks/{}", self.base_url, network_id); + + let response = self + .client + .patch(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&request) + .send() + .await + .wrap_err_with(|| format!("Failed to send request to {}", url))?; + + let status = response.status(); + let body = response + .text() + .await + .wrap_err("Failed to read response body")?; + + if !status.is_success() { + return Err(eyre::eyre!( + "API request failed with status {}: {}", + status, + body + )); + } + + let api_response: ApiResponse = + serde_json::from_str(&body) + .wrap_err_with(|| format!("Failed to parse response: {}", body))?; + + api_response + .data + .ok_or_else(|| eyre::eyre!("API response missing data field")) + } } // ============================================================================ diff --git a/tests/integration/tests/authorization.rs b/tests/integration/tests/authorization.rs index 3ada8b8bd..dec5c6014 100644 --- a/tests/integration/tests/authorization.rs +++ b/tests/integration/tests/authorization.rs @@ -32,6 +32,9 @@ async fn test_authorization_middleware_success() { reset_storage_on_start: false, storage_encryption_key: None, transaction_expiration_hours: 4, + provider_failure_expiration_secs: 60, + provider_failure_threshold: 3, + provider_pause_duration_secs: 60, }); let app = test::init_service( @@ -90,6 +93,9 @@ async fn test_authorization_middleware_failure() { reset_storage_on_start: false, storage_encryption_key: None, transaction_expiration_hours: 4, + provider_failure_expiration_secs: 60, + provider_failure_threshold: 3, + provider_pause_duration_secs: 60, }); let app = test::init_service( diff --git a/tests/integration/tests/mod.rs b/tests/integration/tests/mod.rs index 2f0b01be8..2e1ca8f11 100644 --- a/tests/integration/tests/mod.rs +++ b/tests/integration/tests/mod.rs @@ -4,4 +4,5 @@ mod authorization; pub mod evm; +mod network_api; mod relayer_api; diff --git a/tests/integration/tests/network_api.rs b/tests/integration/tests/network_api.rs new file mode 100644 index 000000000..855a3f14b --- /dev/null +++ b/tests/integration/tests/network_api.rs @@ -0,0 +1,467 @@ +//! Network API integration tests +//! +//! Tests for the network REST API endpoints including listing, retrieving, +//! and updating network configurations. + +use crate::integration::common::client::RelayerClient; +use openzeppelin_relayer::models::{RpcConfig, UpdateNetworkRequest}; +use serial_test::serial; +use std::time::Duration; +use tokio::time::sleep; + +// ============================================================================= +// List Networks Tests +// ============================================================================= + +/// Tests that listing networks returns a successful response with networks +#[tokio::test] +#[serial] +async fn test_list_networks() { + let client = RelayerClient::from_env().expect("Failed to create client"); + let networks = client + .list_networks(None, None) + .await + .expect("Failed to list networks"); + + assert!(!networks.is_empty(), "Should have at least one network"); + + // Verify network structure + let network = &networks[0]; + assert!(!network.id.is_empty(), "Network ID should not be empty"); + assert!(!network.name.is_empty(), "Network name should not be empty"); +} + +/// Tests pagination for listing networks +#[tokio::test] +#[serial] +async fn test_list_networks_pagination() { + let client = RelayerClient::from_env().expect("Failed to create client"); + + // Get first page + let page1 = client + .list_networks(Some(1), Some(2)) + .await + .expect("Failed to list networks page 1"); + + // Get second page + let page2 = client + .list_networks(Some(2), Some(2)) + .await + .expect("Failed to list networks page 2"); + + // Pages should not overlap (if we have enough networks) + if !page1.is_empty() && !page2.is_empty() { + let page1_ids: std::collections::HashSet<_> = page1.iter().map(|n| &n.id).collect(); + let page2_ids: std::collections::HashSet<_> = page2.iter().map(|n| &n.id).collect(); + assert!( + page1_ids.is_disjoint(&page2_ids), + "Page 1 and Page 2 should not have overlapping networks" + ); + } +} + +// ============================================================================= +// Get Network Tests +// ============================================================================= + +/// Tests that getting a network by ID returns the correct network +#[tokio::test] +#[serial] +async fn test_get_network_by_id() { + let client = RelayerClient::from_env().expect("Failed to create client"); + + // First, list networks to get a valid network ID + let networks = client + .list_networks(None, None) + .await + .expect("Failed to list networks"); + assert!(!networks.is_empty(), "Should have at least one network"); + + let network_id = &networks[0].id; + + // Get the network by ID + let network = client + .get_network(network_id) + .await + .expect("Failed to get network"); + + assert_eq!(network.id, *network_id, "Network ID should match"); + assert_eq!(network.name, networks[0].name, "Network name should match"); + assert_eq!( + network.network_type, networks[0].network_type, + "Network type should match" + ); +} + +/// Tests that getting a non-existent network returns 404 +#[tokio::test] +#[serial] +async fn test_get_network_not_found() { + let client = RelayerClient::from_env().expect("Failed to create client"); + + // Try to get a network that doesn't exist + let result = client.get_network("nonexistent:network").await; + + assert!( + result.is_err(), + "Should return an error for non-existent network" + ); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("404") || error_msg.contains("not found"), + "Error should indicate network not found: {}", + error_msg + ); +} + +// ============================================================================= +// Update Network Tests +// ============================================================================= + +/// Helper function to find a network that is not actively being used by relayers +/// Returns None if no suitable network is found +/// +/// This helps prevent network API tests from interfering with relayer tests by +/// avoiding modification of networks that relayers depend on. +async fn find_unused_network( + client: &RelayerClient, +) -> eyre::Result> { + let networks = client.list_networks(None, None).await?; + + // If no networks exist, return None + if networks.is_empty() { + return Ok(None); + } + + let relayers = client.list_relayers().await.unwrap_or_default(); + + // Extract network names from relayers (format: "network_name" from relayer.network) + let used_networks: std::collections::HashSet = + relayers.iter().map(|r| r.network.clone()).collect(); + + // Find a network that's not being used by any relayers + for network in &networks { + // Extract network name from ID (format: "evm:sepolia" -> "sepolia") + let network_name = network.id.split(':').nth(1).unwrap_or(&network.id); + if !used_networks.contains(network_name) { + return Ok(Some(network.clone())); + } + } + + // If all networks are in use, return None to skip the test rather than risk breaking relayers + Ok(None) +} + +/// Helper function to wait for relayers using a network to recover after RPC URL restore +async fn wait_for_relayer_recovery(client: &RelayerClient, network_name: &str) { + // Wait a bit for health checks to potentially re-enable relayers + sleep(Duration::from_secs(2)).await; + + // Check if any relayers using this network are disabled + if let Ok(relayers) = client.list_relayers().await { + let disabled_relayers: Vec<_> = relayers + .iter() + .filter(|r| r.network == network_name && r.system_disabled == Some(true)) + .collect(); + + if !disabled_relayers.is_empty() { + // Wait a bit more for health checks to complete + sleep(Duration::from_secs(3)).await; + } + } +} + +/// Tests updating a network's RPC URLs with simple string format +#[tokio::test] +#[serial] +async fn test_update_network_rpc_urls_simple_format() { + let client = RelayerClient::from_env().expect("Failed to create client"); + + // Find a network that's not actively being used by relayers + // Skip this test if all networks are in use to avoid interfering with relayer tests + let network = match find_unused_network(&client).await { + Ok(Some(network)) => network, + Ok(None) => { + return; // Skip test if all networks are in use + } + Err(e) => { + panic!("Failed to find network: {}", e); + } + }; + + let network_id = &network.id; + let network_name = network.id.split(':').nth(1).unwrap_or(&network.id); + let original_network = client + .get_network(network_id) + .await + .expect("Failed to get original network"); + + // Update with simple string format + let new_rpc_urls = vec![ + "https://rpc1.example.com".to_string(), + "https://rpc2.example.com".to_string(), + ]; + + // Create update request with simple string format + let update_request = UpdateNetworkRequest { + rpc_urls: Some( + new_rpc_urls + .iter() + .map(|url| RpcConfig::new(url.clone())) + .collect(), + ), + }; + + let updated_network = client + .update_network(network_id, update_request) + .await + .expect("Failed to update network"); + + // Verify the update + assert_eq!( + updated_network.id, *network_id, + "Network ID should not change" + ); + assert!( + updated_network.rpc_urls.is_some(), + "RPC URLs should be present" + ); + let updated_urls: Vec = updated_network + .rpc_urls + .unwrap() + .iter() + .map(|config| config.url.clone()) + .collect(); + assert_eq!(updated_urls.len(), 2, "Should have 2 RPC URLs"); + assert!( + updated_urls.contains(&"https://rpc1.example.com".to_string()), + "Should contain rpc1.example.com" + ); + assert!( + updated_urls.contains(&"https://rpc2.example.com".to_string()), + "Should contain rpc2.example.com" + ); + + // Restore original RPC URLs if they existed + if let Some(original_urls) = original_network.rpc_urls { + let restore_request = UpdateNetworkRequest { + rpc_urls: Some(original_urls), + }; + let _ = client + .update_network(network_id, restore_request) + .await + .expect("Failed to restore original RPC URLs"); + + // Wait for relayers using this network to recover if any were disabled + wait_for_relayer_recovery(&client, network_name).await; + } +} + +/// Tests updating a network's RPC URLs with extended format (with weights) +#[tokio::test] +#[serial] +async fn test_update_network_rpc_urls_extended_format() { + let client = RelayerClient::from_env().expect("Failed to create client"); + + // Find a network that's not actively being used by relayers + // Skip this test if all networks are in use to avoid interfering with relayer tests + let network = match find_unused_network(&client).await { + Ok(Some(network)) => network, + Ok(None) => { + return; // Skip test if all networks are in use + } + Err(e) => { + panic!("Failed to find network: {}", e); + } + }; + + let network_id = &network.id; + let network_name = network.id.split(':').nth(1).unwrap_or(&network.id); + let original_network = client + .get_network(network_id) + .await + .expect("Failed to get original network"); + + // Update with extended format (with weights) + let update_request = UpdateNetworkRequest { + rpc_urls: Some(vec![ + RpcConfig { + url: "https://rpc-weighted1.example.com".to_string(), + weight: 80, + }, + RpcConfig { + url: "https://rpc-weighted2.example.com".to_string(), + weight: 20, + }, + ]), + }; + + let updated_network = client + .update_network(network_id, update_request) + .await + .expect("Failed to update network"); + + // Verify the update + assert_eq!( + updated_network.id, *network_id, + "Network ID should not change" + ); + assert!( + updated_network.rpc_urls.is_some(), + "RPC URLs should be present" + ); + let updated_configs = updated_network.rpc_urls.unwrap(); + assert_eq!(updated_configs.len(), 2, "Should have 2 RPC configs"); + + // Verify weights + let config1 = updated_configs + .iter() + .find(|c| c.url == "https://rpc-weighted1.example.com") + .expect("Should find rpc-weighted1"); + assert_eq!(config1.weight, 80, "First RPC should have weight 80"); + + let config2 = updated_configs + .iter() + .find(|c| c.url == "https://rpc-weighted2.example.com") + .expect("Should find rpc-weighted2"); + assert_eq!(config2.weight, 20, "Second RPC should have weight 20"); + + // Restore original RPC URLs if they existed + if let Some(original_urls) = original_network.rpc_urls { + let restore_request = UpdateNetworkRequest { + rpc_urls: Some(original_urls), + }; + let _ = client + .update_network(network_id, restore_request) + .await + .expect("Failed to restore original RPC URLs"); + + // Wait for relayers using this network to recover if any were disabled + wait_for_relayer_recovery(&client, network_name).await; + } +} + +/// Tests updating a network with empty RPC URLs (should fail validation) +#[tokio::test] +#[serial] +async fn test_update_network_empty_rpc_urls() { + let client = RelayerClient::from_env().expect("Failed to create client"); + + // Get an existing network + let networks = client + .list_networks(None, None) + .await + .expect("Failed to list networks"); + assert!(!networks.is_empty(), "Should have at least one network"); + + let network_id = &networks[0].id; + + // Try to update with empty RPC URLs + let update_request = UpdateNetworkRequest { + rpc_urls: Some(vec![]), + }; + + let result = client.update_network(network_id, update_request).await; + + assert!(result.is_err(), "Should fail validation for empty RPC URLs"); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("400") + || error_msg.contains("validation") + || error_msg.contains("empty"), + "Error should indicate validation failure: {}", + error_msg + ); +} + +/// Tests updating a network with invalid RPC URL format +#[tokio::test] +#[serial] +async fn test_update_network_invalid_rpc_url() { + let client = RelayerClient::from_env().expect("Failed to create client"); + + // Get an existing network + let networks = client + .list_networks(None, None) + .await + .expect("Failed to list networks"); + assert!(!networks.is_empty(), "Should have at least one network"); + + let network_id = &networks[0].id; + + // Try to update with invalid RPC URL (not HTTP/HTTPS) + let update_request = UpdateNetworkRequest { + rpc_urls: Some(vec![RpcConfig::new("invalid-url".to_string())]), + }; + + let result = client.update_network(network_id, update_request).await; + + assert!( + result.is_err(), + "Should fail validation for invalid RPC URL" + ); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("400") + || error_msg.contains("validation") + || error_msg.contains("invalid"), + "Error should indicate validation failure: {}", + error_msg + ); +} + +/// Tests updating a non-existent network returns 404 +#[tokio::test] +#[serial] +async fn test_update_network_not_found() { + let client = RelayerClient::from_env().expect("Failed to create client"); + + // Try to update a network that doesn't exist + let update_request = UpdateNetworkRequest { + rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]), + }; + + let result = client + .update_network("nonexistent:network", update_request) + .await; + + assert!( + result.is_err(), + "Should return an error for non-existent network" + ); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("404") || error_msg.contains("not found"), + "Error should indicate network not found: {}", + error_msg + ); +} + +/// Tests updating a network with empty request (should fail) +#[tokio::test] +#[serial] +async fn test_update_network_empty_request() { + let client = RelayerClient::from_env().expect("Failed to create client"); + + // Get an existing network + let networks = client + .list_networks(None, None) + .await + .expect("Failed to list networks"); + assert!(!networks.is_empty(), "Should have at least one network"); + + let network_id = &networks[0].id; + + // Try to update with empty request (no fields provided) + let update_request = UpdateNetworkRequest { rpc_urls: None }; + + let result = client.update_network(network_id, update_request).await; + + assert!(result.is_err(), "Should fail when no fields are provided"); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("400") || error_msg.contains("field") || error_msg.contains("required"), + "Error should indicate that at least one field is required: {}", + error_msg + ); +} From 52928a7c61b38bd863ed2ff0c52c594b1b3fac70 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 12 Jan 2026 00:11:58 +0100 Subject: [PATCH 7/9] chore: Improvements --- openapi.json | 4 +- src/config/config_file/network/common.rs | 44 +-- src/models/network/request.rs | 50 +-- src/models/relayer/rpc_config.rs | 352 +++++++++++++++++++++- src/services/provider/rpc_health_store.rs | 187 ++++++++---- 5 files changed, 486 insertions(+), 151 deletions(-) diff --git a/openapi.json b/openapi.json index 10e0daa34..88a73092a 100644 --- a/openapi.json +++ b/openapi.json @@ -7116,7 +7116,7 @@ "weight": { "type": "integer", "format": "int32", - "description": "The weight of this endpoint in the weighted round-robin selection.\nDefaults to DEFAULT_RPC_WEIGHT (100). Should be between 0 and 100.", + "description": "The weight of this endpoint in the weighted round-robin selection.\nDefaults to [`DEFAULT_RPC_WEIGHT`]. Should be between 0 and 100.", "default": 100, "maximum": 100, "minimum": 0 @@ -8914,7 +8914,7 @@ "items": { "$ref": "#/components/schemas/RpcUrlEntry" }, - "description": "List of RPC endpoint configurations for connecting to the network.\nSupports multiple formats:\n- Array of strings: `[\"https://rpc.example.com\"]` (defaults to weight 100)\n- Array of RpcConfig objects: `[{\"url\": \"https://rpc.example.com\", \"weight\": 100}]`\n- Mixed array: `[\"https://rpc1.com\", {\"url\": \"https://rpc2.com\", \"weight\": 200}]`\nMust be non-empty and contain valid HTTP/HTTPS URLs if provided.", + "description": "List of RPC endpoint configurations for connecting to the network.\nSupports multiple formats:\n- Array of strings: `[\"https://rpc.example.com\"]` (defaults to weight 100)\n- Array of RpcConfig objects: `[{\"url\": \"https://rpc.example.com\", \"weight\": 100}]`\n- Mixed array: `[\"https://rpc1.com\", {\"url\": \"https://rpc2.com\", \"weight\": 100}]`\n Must be non-empty and contain valid HTTP/HTTPS URLs if provided.", "example": [ { "url": "https://rpc.example.com", diff --git a/src/config/config_file/network/common.rs b/src/config/config_file/network/common.rs index cd76729d7..43c50eeb3 100644 --- a/src/config/config_file/network/common.rs +++ b/src/config/config_file/network/common.rs @@ -10,51 +10,9 @@ //! - **Validation**: Required field checks and URL format validation use crate::config::ConfigFileError; -use crate::models::RpcConfig; -use serde::de::Error as DeError; +use crate::models::{deserialize_rpc_urls, RpcConfig}; use serde::{Deserialize, Deserializer, Serialize}; -/// Custom deserializer for rpc_urls that supports both simple format (array of strings) -/// and extended format (array of RpcConfig objects). -fn deserialize_rpc_urls<'de, D>(deserializer: D) -> Result>, D::Error> -where - D: Deserializer<'de>, -{ - // First, deserialize as a generic Value to check what we have - let value: Option = Option::deserialize(deserializer)?; - - match value { - None => Ok(None), - Some(serde_json::Value::Array(arr)) => { - let mut configs = Vec::new(); - for item in arr { - match item { - serde_json::Value::String(url) => { - // Simple format: string -> convert to RpcConfig with default weight - configs.push(RpcConfig::new(url)); - } - serde_json::Value::Object(obj) => { - // Extended format: object -> deserialize as RpcConfig - let config: RpcConfig = - serde_json::from_value(serde_json::Value::Object(obj)) - .map_err(DeError::custom)?; - configs.push(config); - } - _ => { - return Err(DeError::custom( - "rpc_urls must be an array of strings or RpcConfig objects", - )); - } - } - } - Ok(Some(configs)) - } - Some(_) => Err(DeError::custom( - "rpc_urls must be an array of strings or RpcConfig objects", - )), - } -} - #[derive(Debug, Serialize, Clone)] pub struct NetworkConfigCommon { /// Unique network identifier (e.g., "mainnet", "sepolia", "custom-devnet"). diff --git a/src/models/network/request.rs b/src/models/network/request.rs index 35f9c6e92..8c3867b99 100644 --- a/src/models/network/request.rs +++ b/src/models/network/request.rs @@ -8,9 +8,8 @@ //! Serves as the entry point for network data from external clients, ensuring //! all input is properly validated before reaching the core business logic. -use crate::models::{ApiError, RpcConfig}; -use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize}; -use serde_json; +use crate::models::{deserialize_rpc_urls, ApiError, RpcConfig}; +use serde::{Deserialize, Serialize}; use utoipa::ToSchema; /// Schema-only type representing a flexible RPC URL entry. @@ -31,51 +30,6 @@ pub enum RpcUrlEntry { Config(RpcConfig), } -/// Custom deserializer for rpc_urls that supports: -/// - Simple format: array of strings (e.g., ["https://rpc.example.com"]) -/// - Extended format: array of RpcConfig objects (e.g., [{"url": "https://rpc.example.com", "weight": 100}]) -/// - Mixed format: array containing both strings and RpcConfig objects -/// -/// When a string URL is provided, it defaults to weight 100. -fn deserialize_rpc_urls<'de, D>(deserializer: D) -> Result>, D::Error> -where - D: Deserializer<'de>, -{ - // First, deserialize as a generic Value to check what we have - let value: Option = Option::deserialize(deserializer)?; - - match value { - None => Ok(None), - Some(serde_json::Value::Array(arr)) => { - let mut configs = Vec::new(); - for item in arr { - match item { - serde_json::Value::String(url) => { - // Simple format: string -> convert to RpcConfig with default weight (100) - configs.push(RpcConfig::new(url)); - } - serde_json::Value::Object(obj) => { - // Extended format: object -> deserialize as RpcConfig - let config: RpcConfig = - serde_json::from_value(serde_json::Value::Object(obj)) - .map_err(DeError::custom)?; - configs.push(config); - } - _ => { - return Err(DeError::custom( - "rpc_urls must be an array of strings or RpcConfig objects", - )); - } - } - } - Ok(Some(configs)) - } - Some(_) => Err(DeError::custom( - "rpc_urls must be an array of strings or RpcConfig objects", - )), - } -} - /// Request structure for updating a network configuration. /// Currently supports updating RPC URLs only. Can be extended to support other fields. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default, ToSchema)] diff --git a/src/models/relayer/rpc_config.rs b/src/models/relayer/rpc_config.rs index aca7ddc16..b0e4bb5bf 100644 --- a/src/models/relayer/rpc_config.rs +++ b/src/models/relayer/rpc_config.rs @@ -5,7 +5,9 @@ use crate::constants::DEFAULT_RPC_WEIGHT; use eyre::eyre; -use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer}; +use serde::{ + de::Error as DeError, ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer, +}; use std::hash::{Hash, Hasher}; use thiserror::Error; use utoipa::ToSchema; @@ -16,6 +18,11 @@ pub enum RpcConfigError { InvalidWeight { value: u8 }, } +/// Returns the default RPC weight for OpenAPI schema generation. +fn default_rpc_weight() -> u8 { + DEFAULT_RPC_WEIGHT +} + /// Configuration for an RPC endpoint. /// /// This struct contains only persistent configuration (URL and weight). @@ -26,8 +33,8 @@ pub struct RpcConfig { /// The RPC endpoint URL. pub url: String, /// The weight of this endpoint in the weighted round-robin selection. - /// Defaults to DEFAULT_RPC_WEIGHT (100). Should be between 0 and 100. - #[schema(default = 100, minimum = 0, maximum = 100)] + /// Defaults to [`DEFAULT_RPC_WEIGHT`]. Should be between 0 and 100. + #[schema(default = default_rpc_weight, minimum = 0, maximum = 100)] pub weight: u8, } @@ -148,6 +155,70 @@ impl RpcConfig { } } +/// Custom deserializer for `Option>` that supports multiple input formats. +/// +/// This function is designed to be used with `#[serde(deserialize_with = "...")]` and supports: +/// +/// - **Simple format**: Array of strings, e.g., `["https://rpc1.com", "https://rpc2.com"]` +/// Each string is converted to an `RpcConfig` with default weight (100). +/// +/// - **Extended format**: Array of objects, e.g., `[{"url": "https://rpc.com", "weight": 50}]` +/// Each object is deserialized directly as an `RpcConfig`. +/// +/// - **Mixed format**: Array containing both strings and objects +/// e.g., `["https://rpc1.com", {"url": "https://rpc2.com", "weight": 50}]` +/// +/// # Example Usage +/// +/// ```rust,ignore +/// use serde::Deserialize; +/// use crate::models::RpcConfig; +/// +/// #[derive(Deserialize)] +/// struct MyConfig { +/// #[serde(default, deserialize_with = "crate::models::deserialize_rpc_urls")] +/// rpc_urls: Option>, +/// } +/// ``` +pub fn deserialize_rpc_urls<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + // First, deserialize as a generic Value to check what we have + let value: Option = Option::deserialize(deserializer)?; + + match value { + None => Ok(None), + Some(serde_json::Value::Array(arr)) => { + let mut configs = Vec::with_capacity(arr.len()); + for item in arr { + match item { + serde_json::Value::String(url) => { + // Simple format: string -> convert to RpcConfig with default weight + configs.push(RpcConfig::new(url)); + } + serde_json::Value::Object(obj) => { + // Extended format: object -> deserialize as RpcConfig + let config: RpcConfig = + serde_json::from_value(serde_json::Value::Object(obj)) + .map_err(DeError::custom)?; + configs.push(config); + } + _ => { + return Err(DeError::custom( + "rpc_urls must be an array of strings or RpcConfig objects", + )); + } + } + } + Ok(Some(configs)) + } + Some(_) => Err(DeError::custom( + "rpc_urls must be an array of strings or RpcConfig objects", + )), + } +} + #[cfg(test)] mod tests { use super::*; @@ -284,4 +355,279 @@ mod tests { let result = RpcConfig::validate_list(&configs); assert!(result.is_err(), "Should fail with all invalid URLs"); } + + // ========================================================================= + // Tests for deserialize_rpc_urls function + // ========================================================================= + + /// Helper struct to test the deserialize_rpc_urls function via serde + #[derive(Deserialize, Debug)] + struct TestRpcUrlsContainer { + #[serde(default, deserialize_with = "super::deserialize_rpc_urls")] + rpc_urls: Option>, + } + + #[test] + fn test_deserialize_rpc_urls_simple_format_single_url() { + let json = r#"{"rpc_urls": ["https://rpc.example.com"]}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + let urls = result.rpc_urls.unwrap(); + assert_eq!(urls.len(), 1); + assert_eq!(urls[0].url, "https://rpc.example.com"); + assert_eq!(urls[0].weight, DEFAULT_RPC_WEIGHT); + } + + #[test] + fn test_deserialize_rpc_urls_simple_format_multiple_urls() { + let json = r#"{"rpc_urls": ["https://rpc1.com", "https://rpc2.com", "https://rpc3.com"]}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + let urls = result.rpc_urls.unwrap(); + assert_eq!(urls.len(), 3); + assert_eq!(urls[0].url, "https://rpc1.com"); + assert_eq!(urls[1].url, "https://rpc2.com"); + assert_eq!(urls[2].url, "https://rpc3.com"); + // All should have default weight + for url in &urls { + assert_eq!(url.weight, DEFAULT_RPC_WEIGHT); + } + } + + #[test] + fn test_deserialize_rpc_urls_extended_format_single_config() { + let json = r#"{"rpc_urls": [{"url": "https://rpc.example.com", "weight": 50}]}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + let urls = result.rpc_urls.unwrap(); + assert_eq!(urls.len(), 1); + assert_eq!(urls[0].url, "https://rpc.example.com"); + assert_eq!(urls[0].weight, 50); + } + + #[test] + fn test_deserialize_rpc_urls_extended_format_multiple_configs() { + let json = r#"{"rpc_urls": [ + {"url": "https://primary.com", "weight": 80}, + {"url": "https://secondary.com", "weight": 15}, + {"url": "https://fallback.com", "weight": 5} + ]}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + let urls = result.rpc_urls.unwrap(); + assert_eq!(urls.len(), 3); + assert_eq!(urls[0].url, "https://primary.com"); + assert_eq!(urls[0].weight, 80); + assert_eq!(urls[1].url, "https://secondary.com"); + assert_eq!(urls[1].weight, 15); + assert_eq!(urls[2].url, "https://fallback.com"); + assert_eq!(urls[2].weight, 5); + } + + #[test] + fn test_deserialize_rpc_urls_extended_format_without_weight() { + // When weight is omitted in extended format, it should default + let json = r#"{"rpc_urls": [{"url": "https://rpc.example.com"}]}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + let urls = result.rpc_urls.unwrap(); + assert_eq!(urls.len(), 1); + assert_eq!(urls[0].url, "https://rpc.example.com"); + assert_eq!(urls[0].weight, DEFAULT_RPC_WEIGHT); + } + + #[test] + fn test_deserialize_rpc_urls_mixed_format() { + let json = r#"{"rpc_urls": [ + "https://simple.com", + {"url": "https://weighted.com", "weight": 75}, + "https://another-simple.com" + ]}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + let urls = result.rpc_urls.unwrap(); + assert_eq!(urls.len(), 3); + + // First: simple string format + assert_eq!(urls[0].url, "https://simple.com"); + assert_eq!(urls[0].weight, DEFAULT_RPC_WEIGHT); + + // Second: extended object format + assert_eq!(urls[1].url, "https://weighted.com"); + assert_eq!(urls[1].weight, 75); + + // Third: simple string format + assert_eq!(urls[2].url, "https://another-simple.com"); + assert_eq!(urls[2].weight, DEFAULT_RPC_WEIGHT); + } + + #[test] + fn test_deserialize_rpc_urls_none_when_field_missing() { + let json = r#"{}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + assert!(result.rpc_urls.is_none()); + } + + #[test] + fn test_deserialize_rpc_urls_none_when_null() { + let json = r#"{"rpc_urls": null}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + assert!(result.rpc_urls.is_none()); + } + + #[test] + fn test_deserialize_rpc_urls_empty_array() { + let json = r#"{"rpc_urls": []}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + let urls = result.rpc_urls.unwrap(); + assert!(urls.is_empty()); + } + + #[test] + fn test_deserialize_rpc_urls_weight_zero() { + let json = r#"{"rpc_urls": [{"url": "https://disabled.com", "weight": 0}]}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + let urls = result.rpc_urls.unwrap(); + assert_eq!(urls[0].weight, 0); + } + + #[test] + fn test_deserialize_rpc_urls_weight_max() { + let json = r#"{"rpc_urls": [{"url": "https://max.com", "weight": 100}]}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + let urls = result.rpc_urls.unwrap(); + assert_eq!(urls[0].weight, 100); + } + + #[test] + fn test_deserialize_rpc_urls_invalid_not_array() { + let json = r#"{"rpc_urls": "https://not-an-array.com"}"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("rpc_urls must be an array"), + "Error should mention array requirement: {}", + err + ); + } + + #[test] + fn test_deserialize_rpc_urls_invalid_number_in_array() { + let json = r#"{"rpc_urls": [123, 456]}"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("rpc_urls must be an array of strings or RpcConfig objects"), + "Error should mention valid types: {}", + err + ); + } + + #[test] + fn test_deserialize_rpc_urls_invalid_boolean_in_array() { + let json = r#"{"rpc_urls": [true, false]}"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + } + + #[test] + fn test_deserialize_rpc_urls_invalid_nested_array() { + let json = r#"{"rpc_urls": [["nested", "array"]]}"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + } + + #[test] + fn test_deserialize_rpc_urls_invalid_object_in_array() { + let json = r#"{"rpc_urls": {"not": "an_array"}}"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + } + + #[test] + fn test_deserialize_rpc_urls_invalid_object_missing_url() { + // Object format requires 'url' field + let json = r#"{"rpc_urls": [{"weight": 50}]}"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("url") || err.contains("missing field"), + "Error should mention missing url field: {}", + err + ); + } + + #[test] + fn test_deserialize_rpc_urls_mixed_valid_and_invalid() { + // One valid string followed by an invalid number + let json = r#"{"rpc_urls": ["https://valid.com", 12345]}"#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + } + + #[test] + fn test_deserialize_rpc_urls_preserves_url_with_special_chars() { + let json = r#"{"rpc_urls": ["https://rpc.example.com/v1?api_key=abc123&network=mainnet"]}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + let urls = result.rpc_urls.unwrap(); + assert_eq!( + urls[0].url, + "https://rpc.example.com/v1?api_key=abc123&network=mainnet" + ); + } + + #[test] + fn test_deserialize_rpc_urls_preserves_url_with_port() { + let json = r#"{"rpc_urls": ["http://localhost:8545"]}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + let urls = result.rpc_urls.unwrap(); + assert_eq!(urls[0].url, "http://localhost:8545"); + } + + #[test] + fn test_deserialize_rpc_urls_unicode_url() { + let json = r#"{"rpc_urls": ["https://测试.example.com"]}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + let urls = result.rpc_urls.unwrap(); + assert_eq!(urls[0].url, "https://测试.example.com"); + } + + #[test] + fn test_deserialize_rpc_urls_empty_string_url() { + // Empty string is technically valid JSON, deserialization should succeed + // (validation happens at a different layer) + let json = r#"{"rpc_urls": [""]}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + let urls = result.rpc_urls.unwrap(); + assert_eq!(urls[0].url, ""); + } + + #[test] + fn test_deserialize_rpc_urls_whitespace_url() { + let json = r#"{"rpc_urls": [" https://rpc.example.com "]}"#; + let result: TestRpcUrlsContainer = serde_json::from_str(json).unwrap(); + + let urls = result.rpc_urls.unwrap(); + // Whitespace is preserved (trimming is a validation concern) + assert_eq!(urls[0].url, " https://rpc.example.com "); + } } diff --git a/src/services/provider/rpc_health_store.rs b/src/services/provider/rpc_health_store.rs index 58d33416f..03c9b646a 100644 --- a/src/services/provider/rpc_health_store.rs +++ b/src/services/provider/rpc_health_store.rs @@ -4,11 +4,11 @@ //! Health state is shared across all relayers using the same RPC URL. use std::collections::HashMap; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; use chrono::{DateTime, Utc}; use once_cell::sync::Lazy; -use tracing::debug; +use tracing::{debug, warn}; /// Metadata for tracking RPC endpoint health. #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -39,6 +39,34 @@ impl RpcHealthStore { &HEALTH_STORE } + /// Acquires a read lock, recovering from poison if necessary. + /// + /// If the lock is poisoned (a thread panicked while holding it), we recover + /// by extracting the inner data. This prevents cascading failures. + fn acquire_read_lock(&self) -> RwLockReadGuard<'_, HashMap> { + match self.metadata.read() { + Ok(guard) => guard, + Err(poisoned) => { + warn!("RpcHealthStore read lock was poisoned, recovering"); + poisoned.into_inner() + } + } + } + + /// Acquires a write lock, recovering from poison if necessary. + /// + /// If the lock is poisoned (a thread panicked while holding it), we recover + /// by extracting the inner data. This prevents cascading failures. + fn acquire_write_lock(&self) -> RwLockWriteGuard<'_, HashMap> { + match self.metadata.write() { + Ok(guard) => guard, + Err(poisoned) => { + warn!("RpcHealthStore write lock was poisoned, recovering"); + poisoned.into_inner() + } + } + } + /// Gets metadata for a given RPC URL. /// /// Returns default (empty) metadata if the URL is not in the store. @@ -49,8 +77,8 @@ impl RpcHealthStore { /// # Returns /// * `RpcConfigMetadata` - The metadata for the URL, or default if not found pub fn get_metadata(&self, url: &str) -> RpcConfigMetadata { - let metadata = self.metadata.read().unwrap(); - metadata.get(url).cloned().unwrap_or_default() + let store = self.acquire_read_lock(); + store.get(url).cloned().unwrap_or_default() } /// Updates metadata for a given RPC URL. @@ -59,7 +87,7 @@ impl RpcHealthStore { /// * `url` - The RPC endpoint URL /// * `metadata` - The metadata to store pub fn update_metadata(&self, url: &str, metadata: RpcConfigMetadata) { - let mut store = self.metadata.write().unwrap(); + let mut store = self.acquire_write_lock(); store.insert(url.to_string(), metadata); } @@ -79,7 +107,7 @@ impl RpcHealthStore { pause_duration: chrono::Duration, failure_expiration: chrono::Duration, ) { - let mut store = self.metadata.write().unwrap(); + let mut store = self.acquire_write_lock(); let mut metadata = store.get(url).cloned().unwrap_or_default(); let now = Utc::now(); @@ -140,33 +168,21 @@ impl RpcHealthStore { /// # Arguments /// * `url` - The RPC endpoint URL pub fn reset_failures(&self, url: &str) { - let mut store = self.metadata.write().unwrap(); + let mut store = self.acquire_write_lock(); store.remove(url); } /// Resets the failure count only if the provider has failures recorded. - /// This is more efficient than `reset_failures` as it avoids write lock - /// acquisition when there are no failures. /// /// # Arguments /// * `url` - The RPC endpoint URL /// /// # Returns /// * `bool` - True if failures were reset, false if no failures existed + #[must_use] pub fn reset_failures_if_exists(&self, url: &str) -> bool { - // Fast path: check if entry exists with read lock first - let has_failures = { - let store = self.metadata.read().unwrap(); - store.contains_key(url) - }; - - if has_failures { - let mut store = self.metadata.write().unwrap(); - store.remove(url); - true - } else { - false - } + let mut store = self.acquire_write_lock(); + store.remove(url).is_some() } /// Checks if an RPC endpoint is currently paused. @@ -178,6 +194,10 @@ impl RpcHealthStore { /// Stale failures (older than failure_expiration) are automatically removed /// to allow the provider to be retried. /// + /// This method uses a read-lock-first pattern for better concurrency: + /// - First acquires a read lock to check if modification is needed + /// - Only upgrades to write lock if cleanup is required + /// /// # Arguments /// * `url` - The RPC endpoint URL /// * `threshold` - The failure threshold to check against @@ -191,49 +211,106 @@ impl RpcHealthStore { threshold: u32, failure_expiration: chrono::Duration, ) -> bool { - let mut metadata_guard = self.metadata.write().unwrap(); - if let Some(meta) = metadata_guard.get_mut(url) { - let now = Utc::now(); - - // Remove stale failures (older than expiration window) - meta.failure_timestamps - .retain(|&ts| now - ts <= failure_expiration); + let now = Utc::now(); - // If pause has expired, clear it - if let Some(paused_until) = meta.paused_until { - if now >= paused_until { - // Pause expired - clear pause (but keep failure timestamps for tracking) - debug!( - provider_url = %url, - paused_until = %paused_until, - current_time = %now, - remaining_failures = %meta.failure_timestamps.len(), - "RPC provider pause expired, provider available again" - ); - meta.paused_until = None; - // If no recent failures remain, clear everything - if meta.failure_timestamps.is_empty() { - metadata_guard.remove(url); + // Fast path: check with read lock first + let needs_write = { + let store = self.acquire_read_lock(); + match store.get(url) { + None => return false, // No entry, definitely not paused + Some(meta) => { + // Check if we need to modify anything + let has_stale_failures = meta + .failure_timestamps + .iter() + .any(|&ts| now - ts > failure_expiration); + let pause_expired = meta + .paused_until + .is_some_and(|paused_until| now >= paused_until); + let needs_cleanup = + meta.failure_timestamps.is_empty() && meta.paused_until.is_none(); + + if has_stale_failures || pause_expired || needs_cleanup { + // Need write lock to clean up + true + } else { + // No cleanup needed, can determine pause status with read lock + let recent_failures = meta.failure_timestamps.len() as u32; + if recent_failures >= threshold { + if let Some(paused_until) = meta.paused_until { + return now < paused_until; + } + } + return false; } - return false; } } + }; - // Check if paused: must have reached threshold AND be within pause window - let recent_failures = meta.failure_timestamps.len() as u32; - if recent_failures >= threshold { - if let Some(paused_until) = meta.paused_until { - return now < paused_until; + // Slow path: need write lock for cleanup + if needs_write { + self.is_paused_with_cleanup(url, threshold, failure_expiration, now) + } else { + false + } + } + + /// Internal helper that performs pause check with cleanup (requires write lock). + /// + /// This is called when the read-lock check determined that modifications are needed. + fn is_paused_with_cleanup( + &self, + url: &str, + threshold: u32, + failure_expiration: chrono::Duration, + now: DateTime, + ) -> bool { + let mut store = self.acquire_write_lock(); + + // Re-check after acquiring write lock (state may have changed) + let Some(meta) = store.get_mut(url) else { + return false; + }; + + // Remove stale failures (older than expiration window) + meta.failure_timestamps + .retain(|&ts| now - ts <= failure_expiration); + + // If pause has expired, clear it + if let Some(paused_until) = meta.paused_until { + if now >= paused_until { + // Pause expired - clear pause (but keep failure timestamps for tracking) + debug!( + provider_url = %url, + paused_until = %paused_until, + current_time = %now, + remaining_failures = %meta.failure_timestamps.len(), + "RPC provider pause expired, provider available again" + ); + meta.paused_until = None; + // If no recent failures remain, clear everything + if meta.failure_timestamps.is_empty() { + store.remove(url); } - // If we've reached threshold but no pause_until is set, not paused return false; } + } - // If no recent failures remain, remove the entry - if meta.failure_timestamps.is_empty() && meta.paused_until.is_none() { - metadata_guard.remove(url); + // Check if paused: must have reached threshold AND be within pause window + let recent_failures = meta.failure_timestamps.len() as u32; + if recent_failures >= threshold { + if let Some(paused_until) = meta.paused_until { + return now < paused_until; } + // If we've reached threshold but no pause_until is set, not paused + return false; + } + + // If no recent failures remain, remove the entry + if meta.failure_timestamps.is_empty() && meta.paused_until.is_none() { + store.remove(url); } + false } @@ -241,7 +318,7 @@ impl RpcHealthStore { /// Primarily useful for testing. #[cfg(test)] pub fn clear_all(&self) { - let mut store = self.metadata.write().unwrap(); + let mut store = self.acquire_write_lock(); store.clear(); } } From 7176e4156411c9037e119abc92fe646b69464501 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 12 Jan 2026 10:31:03 +0100 Subject: [PATCH 8/9] chore: Improvements --- openapi.json | 40 +- src/api/controllers/network.rs | 425 ++++++++++++++++++ src/api/routes/network.rs | 204 +++++++++ src/models/relayer/response.rs | 24 +- src/models/relayer/rpc_config.rs | 212 +++++++++ src/repositories/network/network_in_memory.rs | 118 ++++- src/services/provider/rpc_health_store.rs | 183 +++++++- 7 files changed, 1159 insertions(+), 47 deletions(-) diff --git a/openapi.json b/openapi.json index 88a73092a..eb24f1c73 100644 --- a/openapi.json +++ b/openapi.json @@ -4253,8 +4253,9 @@ "custom_rpc_urls": { "type": "array", "items": { - "$ref": "#/components/schemas/RpcConfig" - } + "$ref": "#/components/schemas/MaskedRpcConfig" + }, + "description": "Custom RPC URLs with sensitive path/query parameters masked for security.\nThe domain is visible to identify providers (e.g., Alchemy, Infura) but\nAPI keys embedded in paths are hidden." }, "disabled_reason": { "$ref": "#/components/schemas/DisabledReason" @@ -4885,8 +4886,9 @@ "custom_rpc_urls": { "type": "array", "items": { - "$ref": "#/components/schemas/RpcConfig" - } + "$ref": "#/components/schemas/MaskedRpcConfig" + }, + "description": "Custom RPC URLs with sensitive path/query parameters masked for security.\nThe domain is visible to identify providers (e.g., Alchemy, Infura) but\nAPI keys embedded in paths are hidden." }, "disabled_reason": { "$ref": "#/components/schemas/DisabledReason" @@ -6058,6 +6060,31 @@ "result" ] }, + "MaskedRpcConfig": { + "type": "object", + "description": "RPC configuration with masked URL for API responses.\n\nThis type is used in API responses to prevent exposing sensitive API keys\nthat are often embedded in RPC endpoint URLs (e.g., Alchemy, Infura, QuickNode).\nThe URL path and query parameters are masked while keeping the host visible,\nallowing users to identify which provider is configured.", + "required": [ + "url", + "weight" + ], + "properties": { + "url": { + "type": "string", + "description": "The RPC endpoint URL with path/query masked." + }, + "weight": { + "type": "integer", + "format": "int32", + "description": "The weight of this endpoint in the weighted round-robin selection.", + "maximum": 100, + "minimum": 0 + } + }, + "example": { + "url": "https://eth-mainnet.g.alchemy.com/***", + "weight": 100 + } + }, "MemoSpec": { "oneOf": [ { @@ -6734,8 +6761,9 @@ "custom_rpc_urls": { "type": "array", "items": { - "$ref": "#/components/schemas/RpcConfig" - } + "$ref": "#/components/schemas/MaskedRpcConfig" + }, + "description": "Custom RPC URLs with sensitive path/query parameters masked for security.\nThe domain is visible to identify providers (e.g., Alchemy, Infura) but\nAPI keys embedded in paths are hidden." }, "disabled_reason": { "$ref": "#/components/schemas/DisabledReason" diff --git a/src/api/controllers/network.rs b/src/api/controllers/network.rs index 346406773..585fa10c3 100644 --- a/src/api/controllers/network.rs +++ b/src/api/controllers/network.rs @@ -219,3 +219,428 @@ where Ok(HttpResponse::Ok().json(ApiResponse::success(network_response))) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + config::{NetworkConfigCommon, StellarNetworkConfig}, + models::{NetworkType, PaginationQuery, RpcConfig}, + utils::mocks::mockutils::{ + create_mock_app_state, create_mock_network, create_mock_solana_network, + }, + }; + use actix_web::web::ThinData; + + /// Helper function to create a mock Stellar network for testing + fn create_mock_stellar_network() -> NetworkRepoModel { + NetworkRepoModel { + id: "stellar:testnet".to_string(), + name: "Stellar Testnet".to_string(), + network_type: NetworkType::Stellar, + config: NetworkConfigData::Stellar(StellarNetworkConfig { + common: NetworkConfigCommon { + network: "stellar-testnet".to_string(), + from: None, + rpc_urls: Some(vec![RpcConfig::new( + "https://soroban-testnet.stellar.org".to_string(), + )]), + explorer_urls: None, + average_blocktime_ms: Some(5000), + is_testnet: Some(true), + tags: None, + }, + passphrase: Some("Test SDF Network ; September 2015".to_string()), + horizon_url: Some("https://horizon-testnet.stellar.org".to_string()), + }), + } + } + + /// Helper function to create a mock EVM network with a specific ID + fn create_mock_evm_network_with_id(id: &str, name: &str) -> NetworkRepoModel { + let mut network = create_mock_network(); + network.id = id.to_string(); + network.name = name.to_string(); + network + } + + // ============================================ + // Tests for list_networks + // ============================================ + + #[actix_web::test] + async fn test_list_networks_empty() { + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + + let result = list_networks(query, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_list_networks_with_single_network() { + let network = create_mock_network(); + let app_state = + create_mock_app_state(None, None, None, Some(vec![network]), None, None).await; + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + + let result = list_networks(query, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_list_networks_with_multiple_networks() { + let evm_network = create_mock_evm_network_with_id("evm:sepolia", "Sepolia"); + let solana_network = create_mock_solana_network(); + let stellar_network = create_mock_stellar_network(); + + let app_state = create_mock_app_state( + None, + None, + None, + Some(vec![evm_network, solana_network, stellar_network]), + None, + None, + ) + .await; + + let query = PaginationQuery { + page: 1, + per_page: 10, + }; + + let result = list_networks(query, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_list_networks_pagination() { + let network1 = create_mock_evm_network_with_id("evm:network1", "Network 1"); + let network2 = create_mock_evm_network_with_id("evm:network2", "Network 2"); + let network3 = create_mock_evm_network_with_id("evm:network3", "Network 3"); + + let app_state = create_mock_app_state( + None, + None, + None, + Some(vec![network1, network2, network3]), + None, + None, + ) + .await; + + // Request first page with 2 items per page + let query = PaginationQuery { + page: 1, + per_page: 2, + }; + + let result = list_networks(query, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + // ============================================ + // Tests for get_network + // ============================================ + + #[actix_web::test] + async fn test_get_network_success() { + let network = create_mock_network(); + let network_id = network.id.clone(); + let app_state = + create_mock_app_state(None, None, None, Some(vec![network]), None, None).await; + + let result = get_network(network_id, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_get_network_not_found() { + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + + let result = get_network("nonexistent-network".to_string(), ThinData(app_state)).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + ApiError::NotFound(msg) => { + assert!(msg.contains("nonexistent-network")); + } + _ => panic!("Expected NotFound error, got {:?}", error), + } + } + + #[actix_web::test] + async fn test_get_network_evm() { + let network = create_mock_network(); + let network_id = network.id.clone(); + let app_state = + create_mock_app_state(None, None, None, Some(vec![network]), None, None).await; + + let result = get_network(network_id, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_get_network_solana() { + let mut network = create_mock_solana_network(); + network.id = "solana:devnet".to_string(); + let network_id = network.id.clone(); + let app_state = + create_mock_app_state(None, None, None, Some(vec![network]), None, None).await; + + let result = get_network(network_id, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_get_network_stellar() { + let network = create_mock_stellar_network(); + let network_id = network.id.clone(); + let app_state = + create_mock_app_state(None, None, None, Some(vec![network]), None, None).await; + + let result = get_network(network_id, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + // ============================================ + // Tests for update_network + // ============================================ + + #[actix_web::test] + async fn test_update_network_evm_success() { + let network = create_mock_network(); + let network_id = network.id.clone(); + let app_state = + create_mock_app_state(None, None, None, Some(vec![network]), None, None).await; + + let request = UpdateNetworkRequest { + rpc_urls: Some(vec![ + RpcConfig::new("https://new-rpc1.example.com".to_string()), + RpcConfig::new("https://new-rpc2.example.com".to_string()), + ]), + }; + + let result = update_network(network_id, request, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_update_network_solana_success() { + let mut network = create_mock_solana_network(); + network.id = "solana:devnet".to_string(); + let network_id = network.id.clone(); + let app_state = + create_mock_app_state(None, None, None, Some(vec![network]), None, None).await; + + let request = UpdateNetworkRequest { + rpc_urls: Some(vec![RpcConfig::new( + "https://api.devnet.solana.com".to_string(), + )]), + }; + + let result = update_network(network_id, request, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_update_network_stellar_success() { + let network = create_mock_stellar_network(); + let network_id = network.id.clone(); + let app_state = + create_mock_app_state(None, None, None, Some(vec![network]), None, None).await; + + let request = UpdateNetworkRequest { + rpc_urls: Some(vec![RpcConfig::new( + "https://new-soroban-testnet.stellar.org".to_string(), + )]), + }; + + let result = update_network(network_id, request, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_update_network_not_found() { + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + + let request = UpdateNetworkRequest { + rpc_urls: Some(vec![RpcConfig::new("https://rpc.example.com".to_string())]), + }; + + let result = update_network( + "nonexistent-network".to_string(), + request, + ThinData(app_state), + ) + .await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + ApiError::NotFound(msg) => { + assert!(msg.contains("nonexistent-network")); + } + _ => panic!("Expected NotFound error, got {:?}", error), + } + } + + #[actix_web::test] + async fn test_update_network_no_fields_provided() { + let network = create_mock_network(); + let network_id = network.id.clone(); + let app_state = + create_mock_app_state(None, None, None, Some(vec![network]), None, None).await; + + let request = UpdateNetworkRequest { rpc_urls: None }; + + let result = update_network(network_id, request, ThinData(app_state)).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + ApiError::BadRequest(msg) => { + assert!(msg.contains("At least one field must be provided")); + } + _ => panic!("Expected BadRequest error, got {:?}", error), + } + } + + #[actix_web::test] + async fn test_update_network_empty_rpc_urls() { + let network = create_mock_network(); + let network_id = network.id.clone(); + let app_state = + create_mock_app_state(None, None, None, Some(vec![network]), None, None).await; + + let request = UpdateNetworkRequest { + rpc_urls: Some(vec![]), + }; + + let result = update_network(network_id, request, ThinData(app_state)).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + ApiError::BadRequest(msg) => { + assert!(msg.contains("at least one RPC endpoint")); + } + _ => panic!("Expected BadRequest error, got {:?}", error), + } + } + + #[actix_web::test] + async fn test_update_network_invalid_rpc_url() { + let network = create_mock_network(); + let network_id = network.id.clone(); + let app_state = + create_mock_app_state(None, None, None, Some(vec![network]), None, None).await; + + let request = UpdateNetworkRequest { + rpc_urls: Some(vec![RpcConfig::new( + "ftp://invalid-protocol.com".to_string(), + )]), + }; + + let result = update_network(network_id, request, ThinData(app_state)).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + ApiError::BadRequest(msg) => { + assert!(msg.contains("Invalid RPC URL")); + } + _ => panic!("Expected BadRequest error, got {:?}", error), + } + } + + #[actix_web::test] + async fn test_update_network_with_weighted_rpc_urls() { + let network = create_mock_network(); + let network_id = network.id.clone(); + let app_state = + create_mock_app_state(None, None, None, Some(vec![network]), None, None).await; + + let request = UpdateNetworkRequest { + rpc_urls: Some(vec![ + RpcConfig { + url: "https://primary-rpc.example.com".to_string(), + weight: 80, + }, + RpcConfig { + url: "https://backup-rpc.example.com".to_string(), + weight: 20, + }, + ]), + }; + + let result = update_network(network_id, request, ThinData(app_state)).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[actix_web::test] + async fn test_update_network_preserves_other_evm_fields() { + // This test verifies that updating RPC URLs doesn't affect other EVM-specific fields + let network = create_mock_network(); + let network_id = network.id.clone(); + let app_state = + create_mock_app_state(None, None, None, Some(vec![network]), None, None).await; + + let request = UpdateNetworkRequest { + rpc_urls: Some(vec![RpcConfig::new( + "https://new-rpc.example.com".to_string(), + )]), + }; + + let result = update_network(network_id.clone(), request, ThinData(app_state.clone())).await; + + assert!(result.is_ok()); + + // Verify the network was updated by fetching it again + let get_result = get_network(network_id, ThinData(app_state)).await; + assert!(get_result.is_ok()); + } +} diff --git a/src/api/routes/network.rs b/src/api/routes/network.rs index 7d10f550f..e59f463c6 100644 --- a/src/api/routes/network.rs +++ b/src/api/routes/network.rs @@ -44,3 +44,207 @@ pub fn init(cfg: &mut web::ServiceConfig) { cfg.service(get_network); // /networks/{network_id} cfg.service(list_networks); // /networks } + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::mocks::mockutils::create_mock_app_state; + use actix_web::{http::StatusCode, test, App}; + + // ============================================ + // Route Registration Tests + // ============================================ + + #[actix_web::test] + async fn test_network_routes_are_registered() { + // Arrange - Create app with network routes + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_state)) + .configure(init), + ) + .await; + + // Test GET /networks - should not return 404 (route exists) + let req = test::TestRequest::get().uri("/networks").to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "GET /networks route not registered" + ); + + // Test GET /networks/{network_id} - should not return 404 + let req = test::TestRequest::get() + .uri("/networks/evm:sepolia") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "GET /networks/{{network_id}} route not registered" + ); + + // Test PATCH /networks/{network_id} - should not return 404 + let req = test::TestRequest::patch() + .uri("/networks/evm:sepolia") + .set_json(serde_json::json!({ + "rpc_urls": ["https://rpc.example.com"] + })) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "PATCH /networks/{{network_id}} route not registered" + ); + } + + #[actix_web::test] + async fn test_network_routes_with_query_params() { + // Arrange - Create app with network routes + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_state)) + .configure(init), + ) + .await; + + // Test GET /networks with pagination parameters + let req = test::TestRequest::get() + .uri("/networks?page=1&per_page=10") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "GET /networks with query params route not registered" + ); + } + + #[actix_web::test] + async fn test_network_routes_with_special_characters_in_path() { + // Network IDs use format like "evm:sepolia" which contains a colon + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_state)) + .configure(init), + ) + .await; + + // Test that route handles colons in path parameters + let req = test::TestRequest::get() + .uri("/networks/evm:mainnet") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "GET /networks/evm:mainnet route should handle colon in path" + ); + + let req = test::TestRequest::get() + .uri("/networks/solana:devnet") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "GET /networks/solana:devnet route should handle colon in path" + ); + + let req = test::TestRequest::get() + .uri("/networks/stellar:testnet") + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "GET /networks/stellar:testnet route should handle colon in path" + ); + } + + #[actix_web::test] + async fn test_patch_network_route_accepts_json() { + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_state)) + .configure(init), + ) + .await; + + // Test PATCH with valid JSON payload structure + let req = test::TestRequest::patch() + .uri("/networks/evm:sepolia") + .set_json(serde_json::json!({ + "rpc_urls": ["https://rpc1.example.com", "https://rpc2.example.com"] + })) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "PATCH /networks/{{network_id}} with JSON body route not registered" + ); + } + + #[actix_web::test] + async fn test_patch_network_route_accepts_weighted_rpc_urls() { + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_state)) + .configure(init), + ) + .await; + + // Test PATCH with weighted RPC URL format + let req = test::TestRequest::patch() + .uri("/networks/evm:sepolia") + .set_json(serde_json::json!({ + "rpc_urls": [ + {"url": "https://primary.example.com", "weight": 80}, + {"url": "https://backup.example.com", "weight": 20} + ] + })) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "PATCH /networks/{{network_id}} with weighted RPC URLs route not registered" + ); + } + + #[actix_web::test] + async fn test_patch_network_route_accepts_mixed_rpc_url_formats() { + let app_state = create_mock_app_state(None, None, None, None, None, None).await; + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_state)) + .configure(init), + ) + .await; + + // Test PATCH with mixed RPC URL formats (strings and objects) + let req = test::TestRequest::patch() + .uri("/networks/evm:sepolia") + .set_json(serde_json::json!({ + "rpc_urls": [ + "https://simple.example.com", + {"url": "https://weighted.example.com", "weight": 50} + ] + })) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_ne!( + resp.status(), + StatusCode::NOT_FOUND, + "PATCH /networks/{{network_id}} with mixed RPC URL formats route not registered" + ); + } +} diff --git a/src/models/relayer/response.rs b/src/models/relayer/response.rs index 9dd34417a..beec941d4 100644 --- a/src/models/relayer/response.rs +++ b/src/models/relayer/response.rs @@ -12,10 +12,10 @@ //! with the domain model for business logic. use super::{ - DisabledReason, Relayer, RelayerEvmPolicy, RelayerNetworkPolicy, RelayerNetworkType, - RelayerRepoModel, RelayerSolanaPolicy, RelayerSolanaSwapConfig, RelayerStellarPolicy, - RelayerStellarSwapConfig, RpcConfig, SolanaAllowedTokensPolicy, SolanaFeePaymentStrategy, - StellarAllowedTokensPolicy, StellarFeePaymentStrategy, + DisabledReason, MaskedRpcConfig, Relayer, RelayerEvmPolicy, RelayerNetworkPolicy, + RelayerNetworkType, RelayerRepoModel, RelayerSolanaPolicy, RelayerSolanaSwapConfig, + RelayerStellarPolicy, RelayerStellarSwapConfig, SolanaAllowedTokensPolicy, + SolanaFeePaymentStrategy, StellarAllowedTokensPolicy, StellarFeePaymentStrategy, }; use crate::constants::{ DEFAULT_EVM_GAS_LIMIT_ESTIMATION, DEFAULT_EVM_MIN_BALANCE, DEFAULT_SOLANA_MAX_TX_DATA_SIZE, @@ -79,9 +79,12 @@ pub struct RelayerResponse { #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] pub notification_id: Option, + /// Custom RPC URLs with sensitive path/query parameters masked for security. + /// The domain is visible to identify providers (e.g., Alchemy, Infura) but + /// API keys embedded in paths are hidden. #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] - pub custom_rpc_urls: Option>, + pub custom_rpc_urls: Option>, // Runtime fields from repository model #[schema(nullable = false)] pub address: Option, @@ -185,7 +188,9 @@ impl From for RelayerResponse { .map(|policy| convert_policy_to_response(policy, relayer.network_type)), signer_id: relayer.signer_id, notification_id: relayer.notification_id, - custom_rpc_urls: relayer.custom_rpc_urls, + custom_rpc_urls: relayer + .custom_rpc_urls + .map(|urls| urls.into_iter().map(MaskedRpcConfig::from).collect()), address: None, system_disabled: None, disabled_reason: None, @@ -214,7 +219,9 @@ impl From for RelayerResponse { policies, signer_id: model.signer_id, notification_id: model.notification_id, - custom_rpc_urls: model.custom_rpc_urls, + custom_rpc_urls: model + .custom_rpc_urls + .map(|urls| urls.into_iter().map(MaskedRpcConfig::from).collect()), address: Some(model.address), system_disabled: Some(model.system_disabled), disabled_reason: model.disabled_reason, @@ -600,7 +607,8 @@ mod tests { ); assert_eq!(response.signer_id, relayer.signer_id); assert_eq!(response.notification_id, relayer.notification_id); - assert_eq!(response.custom_rpc_urls, relayer.custom_rpc_urls); + // custom_rpc_urls is None in this test + assert_eq!(response.custom_rpc_urls, None); assert_eq!(response.address, None); assert_eq!(response.system_disabled, None); } diff --git a/src/models/relayer/rpc_config.rs b/src/models/relayer/rpc_config.rs index b0e4bb5bf..7d600565a 100644 --- a/src/models/relayer/rpc_config.rs +++ b/src/models/relayer/rpc_config.rs @@ -155,6 +155,80 @@ impl RpcConfig { } } +/// Masks a URL by showing only the scheme and host, hiding the path and query parameters. +/// +/// This is used to safely display RPC URLs in API responses without exposing +/// sensitive API keys that are often embedded in the URL path or query string. +/// +/// # Examples +/// - `https://eth-mainnet.g.alchemy.com/v2/abc123` → `https://eth-mainnet.g.alchemy.com/***` +/// - `https://mainnet.infura.io/v3/PROJECT_ID` → `https://mainnet.infura.io/***` +/// - `http://localhost:8545` → `http://localhost:8545` (no path to mask) +/// - `invalid-url` → `***` (fallback for unparseable URLs) +pub fn mask_url(url: &str) -> String { + // Find the scheme separator "://" + let Some(scheme_end) = url.find("://") else { + // No valid scheme, mask entirely for safety + return "***".to_string(); + }; + + // Find where the host ends (first "/" after "://") + let host_start = scheme_end + 3; // Skip "://" + let rest = &url[host_start..]; + + // Find the first "/" which marks the start of the path + if let Some(path_start) = rest.find('/') { + // Check if there's actually content in the path (not just "/") + let path_and_beyond = &rest[path_start..]; + if path_and_beyond.len() > 1 || url.contains('?') { + // There's a path or query to mask + let host_end = host_start + path_start; + format!("{}/***", &url[..host_end]) + } else { + // Just a trailing "/" with no real path content + url.to_string() + } + } else if url.contains('?') { + // No path but has query parameters - mask those + let query_start = url.find('?').unwrap(); + format!("{}?***", &url[..query_start]) + } else { + // No path or query to mask, return original + url.to_string() + } +} + +/// RPC configuration with masked URL for API responses. +/// +/// This type is used in API responses to prevent exposing sensitive API keys +/// that are often embedded in RPC endpoint URLs (e.g., Alchemy, Infura, QuickNode). +/// The URL path and query parameters are masked while keeping the host visible, +/// allowing users to identify which provider is configured. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[schema(example = json!({"url": "https://eth-mainnet.g.alchemy.com/***", "weight": 100}))] +pub struct MaskedRpcConfig { + /// The RPC endpoint URL with path/query masked. + pub url: String, + /// The weight of this endpoint in the weighted round-robin selection. + #[schema(minimum = 0, maximum = 100)] + pub weight: u8, +} + +impl From<&RpcConfig> for MaskedRpcConfig { + fn from(config: &RpcConfig) -> Self { + Self { + url: mask_url(&config.url), + weight: config.weight, + } + } +} + +impl From for MaskedRpcConfig { + fn from(config: RpcConfig) -> Self { + Self::from(&config) + } +} + /// Custom deserializer for `Option>` that supports multiple input formats. /// /// This function is designed to be used with `#[serde(deserialize_with = "...")]` and supports: @@ -630,4 +704,142 @@ mod tests { // Whitespace is preserved (trimming is a validation concern) assert_eq!(urls[0].url, " https://rpc.example.com "); } + + // ========================================================================= + // Tests for mask_url function + // ========================================================================= + + #[test] + fn test_mask_url_alchemy_with_api_key() { + let url = "https://eth-mainnet.g.alchemy.com/v2/abc123xyz"; + let masked = super::mask_url(url); + assert_eq!(masked, "https://eth-mainnet.g.alchemy.com/***"); + } + + #[test] + fn test_mask_url_infura_with_project_id() { + let url = "https://mainnet.infura.io/v3/my-project-id"; + let masked = super::mask_url(url); + assert_eq!(masked, "https://mainnet.infura.io/***"); + } + + #[test] + fn test_mask_url_quicknode_with_api_key() { + let url = "https://my-node.quiknode.pro/secret-api-key/"; + let masked = super::mask_url(url); + assert_eq!(masked, "https://my-node.quiknode.pro/***"); + } + + #[test] + fn test_mask_url_localhost_no_path() { + // No path to mask, should return original + let url = "http://localhost:8545"; + let masked = super::mask_url(url); + assert_eq!(masked, "http://localhost:8545"); + } + + #[test] + fn test_mask_url_localhost_with_trailing_slash() { + // Just a trailing slash with no real path content + let url = "http://localhost:8545/"; + let masked = super::mask_url(url); + assert_eq!(masked, "http://localhost:8545/"); + } + + #[test] + fn test_mask_url_with_query_params() { + let url = "https://rpc.example.com/v1?api_key=secret123&network=mainnet"; + let masked = super::mask_url(url); + assert_eq!(masked, "https://rpc.example.com/***"); + } + + #[test] + fn test_mask_url_query_params_no_path() { + let url = "https://rpc.example.com?api_key=secret123"; + let masked = super::mask_url(url); + assert_eq!(masked, "https://rpc.example.com?***"); + } + + #[test] + fn test_mask_url_invalid_url_no_scheme() { + // Invalid URL without scheme should be fully masked for safety + let url = "invalid-url"; + let masked = super::mask_url(url); + assert_eq!(masked, "***"); + } + + #[test] + fn test_mask_url_empty_string() { + let url = ""; + let masked = super::mask_url(url); + assert_eq!(masked, "***"); + } + + #[test] + fn test_mask_url_with_port_and_path() { + let url = "https://rpc.example.com:8080/api/v1/secret"; + let masked = super::mask_url(url); + assert_eq!(masked, "https://rpc.example.com:8080/***"); + } + + #[test] + fn test_mask_url_ankr_with_api_key() { + let url = "https://rpc.ankr.com/eth/my-api-key-here"; + let masked = super::mask_url(url); + assert_eq!(masked, "https://rpc.ankr.com/***"); + } + + // ========================================================================= + // Tests for MaskedRpcConfig + // ========================================================================= + + #[test] + fn test_masked_rpc_config_from_rpc_config() { + let config = RpcConfig::new("https://eth-mainnet.g.alchemy.com/v2/secret-key".to_string()); + let masked: MaskedRpcConfig = config.into(); + + assert_eq!(masked.url, "https://eth-mainnet.g.alchemy.com/***"); + assert_eq!(masked.weight, DEFAULT_RPC_WEIGHT); + } + + #[test] + fn test_masked_rpc_config_preserves_weight() { + let config = + RpcConfig::with_weight("https://mainnet.infura.io/v3/project-id".to_string(), 75) + .unwrap(); + let masked: MaskedRpcConfig = config.into(); + + assert_eq!(masked.url, "https://mainnet.infura.io/***"); + assert_eq!(masked.weight, 75); + } + + #[test] + fn test_masked_rpc_config_from_reference() { + let config = RpcConfig::new("https://rpc.ankr.com/eth/secret".to_string()); + let masked = MaskedRpcConfig::from(&config); + + assert_eq!(masked.url, "https://rpc.ankr.com/***"); + assert_eq!(masked.weight, DEFAULT_RPC_WEIGHT); + } + + #[test] + fn test_masked_rpc_config_serialization() { + let masked = MaskedRpcConfig { + url: "https://eth-mainnet.g.alchemy.com/***".to_string(), + weight: 100, + }; + + let serialized = serde_json::to_string(&masked).unwrap(); + assert!(serialized.contains("https://eth-mainnet.g.alchemy.com/***")); + assert!(serialized.contains("100")); + } + + #[test] + fn test_masked_rpc_config_deserialization() { + let json = r#"{"url": "https://rpc.example.com/***", "weight": 50}"#; + let masked: MaskedRpcConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(masked.url, "https://rpc.example.com/***"); + assert_eq!(masked.weight, 50); + } } diff --git a/src/repositories/network/network_in_memory.rs b/src/repositories/network/network_in_memory.rs index 5a8b065ee..34b65ddd8 100644 --- a/src/repositories/network/network_in_memory.rs +++ b/src/repositories/network/network_in_memory.rs @@ -92,10 +92,20 @@ impl Repository for InMemoryNetworkRepository { async fn update( &self, - _id: String, - _network: NetworkRepoModel, + id: String, + network: NetworkRepoModel, ) -> Result { - Err(RepositoryError::NotSupported("Not supported".to_string())) + let mut store = Self::acquire_lock(&self.store).await?; + + if !store.contains_key(&id) { + return Err(RepositoryError::NotFound(format!( + "Network with id {} not found", + id + ))); + } + + store.insert(id, network.clone()); + Ok(network) } async fn delete_by_id(&self, _id: String) -> Result<(), RepositoryError> { @@ -110,9 +120,25 @@ impl Repository for InMemoryNetworkRepository { async fn list_paginated( &self, - _query: PaginationQuery, + query: PaginationQuery, ) -> Result, RepositoryError> { - Err(RepositoryError::NotSupported("Not supported".to_string())) + let total = self.count().await?; + let start = ((query.page - 1) * query.per_page) as usize; + + let items = Self::acquire_lock(&self.store) + .await? + .values() + .skip(start) + .take(query.per_page as usize) + .cloned() + .collect(); + + Ok(PaginatedResult { + items, + total: total as u64, + page: query.page, + per_page: query.per_page, + }) } async fn count(&self) -> Result { @@ -290,30 +316,86 @@ mod tests { #[tokio::test] async fn test_unsupported_operations() { let repo = InMemoryNetworkRepository::new(); - let network = create_test_network("test".to_string(), NetworkType::Evm); - - let update_result = repo.update("test".to_string(), network.clone()).await; - assert!(matches!( - update_result, - Err(RepositoryError::NotSupported(_)) - )); + // Delete is still unsupported let delete_result = repo.delete_by_id("test".to_string()).await; assert!(matches!( delete_result, Err(RepositoryError::NotSupported(_)) )); + } + + #[tokio::test] + async fn test_update_network() { + let repo = InMemoryNetworkRepository::new(); + let network = create_test_network("test".to_string(), NetworkType::Evm); + // Note: new_evm generates ID as "evm:{name}" -> "evm:test" + let network_id = network.id.clone(); + + // First create the network + repo.create(network.clone()).await.unwrap(); + + // Update should work now + let mut updated_network = network.clone(); + updated_network.name = "Updated Name".to_string(); - let pagination_result = repo + let update_result = repo + .update(network_id.clone(), updated_network.clone()) + .await; + assert!(update_result.is_ok()); + let updated = update_result.unwrap(); + assert_eq!(updated.name, "Updated Name"); + + // Verify it was persisted + let retrieved = repo.get_by_id(network_id).await.unwrap(); + assert_eq!(retrieved.name, "Updated Name"); + } + + #[tokio::test] + async fn test_update_network_not_found() { + let repo = InMemoryNetworkRepository::new(); + let network = create_test_network("test".to_string(), NetworkType::Evm); + + // Update on non-existent network should fail + let update_result = repo.update("nonexistent".to_string(), network).await; + assert!(matches!(update_result, Err(RepositoryError::NotFound(_)))); + } + + #[tokio::test] + async fn test_list_paginated() { + let repo = InMemoryNetworkRepository::new(); + + // Create multiple networks + for i in 0..5 { + let network = create_test_network(format!("network-{}", i), NetworkType::Evm); + repo.create(network).await.unwrap(); + } + + // Test pagination + let result = repo .list_paginated(PaginationQuery { page: 1, - per_page: 10, + per_page: 2, }) .await; - assert!(matches!( - pagination_result, - Err(RepositoryError::NotSupported(_)) - )); + assert!(result.is_ok()); + let paginated = result.unwrap(); + assert_eq!(paginated.items.len(), 2); + assert_eq!(paginated.total, 5); + assert_eq!(paginated.page, 1); + assert_eq!(paginated.per_page, 2); + + // Test second page + let result2 = repo + .list_paginated(PaginationQuery { + page: 2, + per_page: 2, + }) + .await; + assert!(result2.is_ok()); + let paginated2 = result2.unwrap(); + assert_eq!(paginated2.items.len(), 2); + assert_eq!(paginated2.page, 2); } #[tokio::test] diff --git a/src/services/provider/rpc_health_store.rs b/src/services/provider/rpc_health_store.rs index 03c9b646a..ae9b498ef 100644 --- a/src/services/provider/rpc_health_store.rs +++ b/src/services/provider/rpc_health_store.rs @@ -330,7 +330,6 @@ mod tests { #[test] fn test_get_metadata_returns_default_when_not_found() { let store = RpcHealthStore::instance(); - store.clear_all(); // Use a unique URL to avoid interference from other tests let url = "https://test-get-metadata.example.com"; @@ -343,7 +342,6 @@ mod tests { #[test] fn test_update_and_get_metadata() { let store = RpcHealthStore::instance(); - store.clear_all(); let url = "https://test-update-metadata.example.com"; let mut metadata = RpcConfigMetadata::default(); @@ -363,7 +361,6 @@ mod tests { #[test] fn test_mark_failed_increments_count() { let store = RpcHealthStore::instance(); - store.clear_all(); // Use a unique URL to avoid interference let url = "https://test-increment-count.example.com"; @@ -443,7 +440,6 @@ mod tests { #[test] fn test_reset_failures() { let store = RpcHealthStore::instance(); - store.clear_all(); let url = "https://test-reset-failures.example.com"; let expiration = chrono::Duration::seconds(60); @@ -464,7 +460,6 @@ mod tests { #[test] fn test_is_paused_with_failure_count_below_threshold() { let store = RpcHealthStore::instance(); - store.clear_all(); let url = "https://test-below-threshold.example.com"; let expiration = chrono::Duration::seconds(60); @@ -479,7 +474,6 @@ mod tests { #[test] fn test_is_paused_with_time_based_pause() { let store = RpcHealthStore::instance(); - store.clear_all(); let url = "https://test-time-based-pause.example.com"; let expiration = chrono::Duration::seconds(60); @@ -497,7 +491,6 @@ mod tests { #[test] fn test_is_paused_expires_after_time() { let store = RpcHealthStore::instance(); - store.clear_all(); let url = "https://test-expires-after-time.example.com"; let expiration = chrono::Duration::seconds(60); @@ -514,7 +507,6 @@ mod tests { fn test_shared_state_across_instances() { let store1 = RpcHealthStore::instance(); let store2 = RpcHealthStore::instance(); - store1.clear_all(); let url = "https://test-shared-state.example.com"; let expiration = chrono::Duration::seconds(60); @@ -540,7 +532,6 @@ mod tests { #[test] fn test_stale_failures_are_expired() { let store = RpcHealthStore::instance(); - store.clear_all(); let url = "https://test-stale-failures.example.com"; let expiration = chrono::Duration::seconds(60); @@ -566,7 +557,6 @@ mod tests { #[test] fn test_failure_timestamps_size_limit() { let store = RpcHealthStore::instance(); - store.clear_all(); let url = "https://test-size-limit.example.com"; let expiration = chrono::Duration::seconds(60); @@ -585,7 +575,6 @@ mod tests { #[test] fn test_mixed_stale_and_recent_failures() { let store = RpcHealthStore::instance(); - store.clear_all(); let url = "https://test-mixed-failures.example.com"; let expiration = chrono::Duration::seconds(60); @@ -614,7 +603,6 @@ mod tests { #[test] fn test_pause_extension_when_already_paused() { let store = RpcHealthStore::instance(); - store.clear_all(); // Use a unique URL to avoid interference let url = "https://test-pause-extension.example.com"; @@ -685,7 +673,6 @@ mod tests { #[test] fn test_stale_failures_removed_during_mark_failed() { let store = RpcHealthStore::instance(); - store.clear_all(); let url = "https://test-stale-removed.example.com"; let expiration = chrono::Duration::seconds(60); @@ -715,7 +702,6 @@ mod tests { #[test] fn test_pause_expiration_cleans_up_metadata() { let store = RpcHealthStore::instance(); - store.clear_all(); let url = "https://test-pause-expiration-cleanup.example.com"; let expiration = chrono::Duration::seconds(60); @@ -736,7 +722,6 @@ mod tests { #[test] fn test_pause_expiration_keeps_recent_failures() { let store = RpcHealthStore::instance(); - store.clear_all(); // Use a unique URL to avoid interference let url = "https://test-pause-expiration.example.com"; @@ -779,4 +764,172 @@ mod tests { "Pause should be cleared" ); } + + #[test] + fn test_reset_failures_if_exists_returns_true_when_entry_exists() { + let store = RpcHealthStore::instance(); + + let url = "https://test-reset-if-exists-true.example.com"; + let expiration = chrono::Duration::seconds(60); + let threshold = 3; + + // Add some failures + store.mark_failed(url, threshold, chrono::Duration::seconds(60), expiration); + store.mark_failed(url, threshold, chrono::Duration::seconds(60), expiration); + + // Should return true when entry exists + let result = store.reset_failures_if_exists(url); + assert!(result, "Should return true when entry existed"); + + // Verify entry was removed + let metadata = store.get_metadata(url); + assert_eq!(metadata, RpcConfigMetadata::default()); + } + + #[test] + fn test_reset_failures_if_exists_returns_false_when_no_entry() { + let store = RpcHealthStore::instance(); + + // Use a URL that was never used + let url = "https://test-reset-if-exists-false.example.com"; + + // Should return false when entry doesn't exist + let result = store.reset_failures_if_exists(url); + assert!(!result, "Should return false when entry doesn't exist"); + } + + #[test] + fn test_is_paused_fast_path_no_cleanup_needed() { + let store = RpcHealthStore::instance(); + + // This test verifies the fast path in is_paused (lines 237-244) + // where no cleanup is needed and we can return directly with read lock + let url = "https://test-fast-path.example.com"; + let expiration = chrono::Duration::seconds(60); + let threshold = 3; + + // Create metadata with recent failures (not stale) and future pause + let mut metadata = RpcConfigMetadata::default(); + metadata.failure_timestamps.push(Utc::now()); + metadata.failure_timestamps.push(Utc::now()); + metadata.failure_timestamps.push(Utc::now()); + metadata.paused_until = Some(Utc::now() + chrono::Duration::seconds(60)); + store.update_metadata(url, metadata); + + // This should hit the fast path and return true without needing write lock + assert!( + store.is_paused(url, threshold, expiration), + "Should be paused via fast path" + ); + + // Verify the metadata is unchanged (no cleanup performed) + let metadata_after = store.get_metadata(url); + assert_eq!( + metadata_after.failure_timestamps.len(), + 3, + "Should have 3 failures unchanged" + ); + assert!( + metadata_after.paused_until.is_some(), + "Pause should be unchanged" + ); + } + + #[test] + fn test_is_paused_fast_path_below_threshold_returns_false() { + let store = RpcHealthStore::instance(); + + // Test fast path when failures are below threshold + let url = "https://test-fast-path-below-threshold.example.com"; + let expiration = chrono::Duration::seconds(60); + let threshold = 3; + + // Create metadata with recent failures below threshold + let mut metadata = RpcConfigMetadata::default(); + metadata.failure_timestamps.push(Utc::now()); + metadata.failure_timestamps.push(Utc::now()); + // Only 2 failures, threshold is 3 + store.update_metadata(url, metadata); + + // Should hit fast path and return false + assert!( + !store.is_paused(url, threshold, expiration), + "Should not be paused - below threshold" + ); + } + + #[test] + fn test_is_paused_threshold_reached_but_no_pause_until() { + let store = RpcHealthStore::instance(); + + // Test edge case: threshold reached but no paused_until set + // This can happen if metadata is manipulated directly + let url = "https://test-threshold-no-pause.example.com"; + let expiration = chrono::Duration::seconds(60); + let threshold = 3; + + // Create metadata with failures at threshold but no paused_until + let mut metadata = RpcConfigMetadata::default(); + metadata.failure_timestamps.push(Utc::now()); + metadata.failure_timestamps.push(Utc::now()); + metadata.failure_timestamps.push(Utc::now()); + // Note: paused_until is None + store.update_metadata(url, metadata); + + // Should return false because no paused_until is set + // This tests line 305-306 in is_paused_with_cleanup + assert!( + !store.is_paused(url, threshold, expiration), + "Should not be paused - no paused_until set despite threshold reached" + ); + } + + #[test] + fn test_is_paused_cleans_up_empty_entry() { + let store = RpcHealthStore::instance(); + + // Test that empty entries get cleaned up + let url = "https://test-cleanup-empty.example.com"; + let expiration = chrono::Duration::seconds(60); + + // Create an empty metadata entry (simulating a state after all failures expired) + let metadata = RpcConfigMetadata::default(); + store.update_metadata(url, metadata); + + // Calling is_paused should clean up the empty entry + assert!(!store.is_paused(url, 3, expiration)); + + // Verify entry was removed (get_metadata returns default for non-existent entries) + // We can't directly verify removal, but the behavior is correct + } + + #[test] + fn test_mark_failed_logs_new_pause_vs_extended_pause() { + let store = RpcHealthStore::instance(); + + // This test exercises both logging branches in mark_failed: + // - Line 142-149: "RPC provider paused due to failures" (first pause) + // - Line 151-159: "RPC provider pause extended" (already paused) + let url = "https://test-pause-logging.example.com"; + let expiration = chrono::Duration::seconds(60); + let pause_duration = chrono::Duration::seconds(60); + let threshold = 3; + + // First pause (exercises lines 142-149) + store.mark_failed(url, threshold, pause_duration, expiration); + store.mark_failed(url, threshold, pause_duration, expiration); + store.mark_failed(url, threshold, pause_duration, expiration); + + let metadata1 = store.get_metadata(url); + assert!(metadata1.paused_until.is_some(), "Should be paused"); + + // Extended pause (exercises lines 151-159) + store.mark_failed(url, threshold, pause_duration, expiration); + + let metadata2 = store.get_metadata(url); + assert!( + metadata2.paused_until.is_some(), + "Should still be paused after extension" + ); + } } From 937ddc27ec522549d64ad843bb512e2f9ef06ad5 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Mon, 12 Jan 2026 10:45:14 +0100 Subject: [PATCH 9/9] chore: Improvements --- src/models/relayer/rpc_config.rs | 128 +---------------- src/repositories/network/network_in_memory.rs | 3 +- src/services/provider/evm/mod.rs | 5 +- src/utils/mod.rs | 3 + src/utils/url.rs | 132 ++++++++++++++++++ 5 files changed, 140 insertions(+), 131 deletions(-) create mode 100644 src/utils/url.rs diff --git a/src/models/relayer/rpc_config.rs b/src/models/relayer/rpc_config.rs index 7d600565a..7355ee4b5 100644 --- a/src/models/relayer/rpc_config.rs +++ b/src/models/relayer/rpc_config.rs @@ -4,6 +4,7 @@ //! including URLs and weights for load balancing. use crate::constants::DEFAULT_RPC_WEIGHT; +use crate::utils::mask_url; use eyre::eyre; use serde::{ de::Error as DeError, ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer, @@ -155,49 +156,6 @@ impl RpcConfig { } } -/// Masks a URL by showing only the scheme and host, hiding the path and query parameters. -/// -/// This is used to safely display RPC URLs in API responses without exposing -/// sensitive API keys that are often embedded in the URL path or query string. -/// -/// # Examples -/// - `https://eth-mainnet.g.alchemy.com/v2/abc123` → `https://eth-mainnet.g.alchemy.com/***` -/// - `https://mainnet.infura.io/v3/PROJECT_ID` → `https://mainnet.infura.io/***` -/// - `http://localhost:8545` → `http://localhost:8545` (no path to mask) -/// - `invalid-url` → `***` (fallback for unparseable URLs) -pub fn mask_url(url: &str) -> String { - // Find the scheme separator "://" - let Some(scheme_end) = url.find("://") else { - // No valid scheme, mask entirely for safety - return "***".to_string(); - }; - - // Find where the host ends (first "/" after "://") - let host_start = scheme_end + 3; // Skip "://" - let rest = &url[host_start..]; - - // Find the first "/" which marks the start of the path - if let Some(path_start) = rest.find('/') { - // Check if there's actually content in the path (not just "/") - let path_and_beyond = &rest[path_start..]; - if path_and_beyond.len() > 1 || url.contains('?') { - // There's a path or query to mask - let host_end = host_start + path_start; - format!("{}/***", &url[..host_end]) - } else { - // Just a trailing "/" with no real path content - url.to_string() - } - } else if url.contains('?') { - // No path but has query parameters - mask those - let query_start = url.find('?').unwrap(); - format!("{}?***", &url[..query_start]) - } else { - // No path or query to mask, return original - url.to_string() - } -} - /// RPC configuration with masked URL for API responses. /// /// This type is used in API responses to prevent exposing sensitive API keys @@ -705,90 +663,6 @@ mod tests { assert_eq!(urls[0].url, " https://rpc.example.com "); } - // ========================================================================= - // Tests for mask_url function - // ========================================================================= - - #[test] - fn test_mask_url_alchemy_with_api_key() { - let url = "https://eth-mainnet.g.alchemy.com/v2/abc123xyz"; - let masked = super::mask_url(url); - assert_eq!(masked, "https://eth-mainnet.g.alchemy.com/***"); - } - - #[test] - fn test_mask_url_infura_with_project_id() { - let url = "https://mainnet.infura.io/v3/my-project-id"; - let masked = super::mask_url(url); - assert_eq!(masked, "https://mainnet.infura.io/***"); - } - - #[test] - fn test_mask_url_quicknode_with_api_key() { - let url = "https://my-node.quiknode.pro/secret-api-key/"; - let masked = super::mask_url(url); - assert_eq!(masked, "https://my-node.quiknode.pro/***"); - } - - #[test] - fn test_mask_url_localhost_no_path() { - // No path to mask, should return original - let url = "http://localhost:8545"; - let masked = super::mask_url(url); - assert_eq!(masked, "http://localhost:8545"); - } - - #[test] - fn test_mask_url_localhost_with_trailing_slash() { - // Just a trailing slash with no real path content - let url = "http://localhost:8545/"; - let masked = super::mask_url(url); - assert_eq!(masked, "http://localhost:8545/"); - } - - #[test] - fn test_mask_url_with_query_params() { - let url = "https://rpc.example.com/v1?api_key=secret123&network=mainnet"; - let masked = super::mask_url(url); - assert_eq!(masked, "https://rpc.example.com/***"); - } - - #[test] - fn test_mask_url_query_params_no_path() { - let url = "https://rpc.example.com?api_key=secret123"; - let masked = super::mask_url(url); - assert_eq!(masked, "https://rpc.example.com?***"); - } - - #[test] - fn test_mask_url_invalid_url_no_scheme() { - // Invalid URL without scheme should be fully masked for safety - let url = "invalid-url"; - let masked = super::mask_url(url); - assert_eq!(masked, "***"); - } - - #[test] - fn test_mask_url_empty_string() { - let url = ""; - let masked = super::mask_url(url); - assert_eq!(masked, "***"); - } - - #[test] - fn test_mask_url_with_port_and_path() { - let url = "https://rpc.example.com:8080/api/v1/secret"; - let masked = super::mask_url(url); - assert_eq!(masked, "https://rpc.example.com:8080/***"); - } - - #[test] - fn test_mask_url_ankr_with_api_key() { - let url = "https://rpc.ankr.com/eth/my-api-key-here"; - let masked = super::mask_url(url); - assert_eq!(masked, "https://rpc.ankr.com/***"); - } - // ========================================================================= // Tests for MaskedRpcConfig // ========================================================================= diff --git a/src/repositories/network/network_in_memory.rs b/src/repositories/network/network_in_memory.rs index 34b65ddd8..817ab81e8 100644 --- a/src/repositories/network/network_in_memory.rs +++ b/src/repositories/network/network_in_memory.rs @@ -99,8 +99,7 @@ impl Repository for InMemoryNetworkRepository { if !store.contains_key(&id) { return Err(RepositoryError::NotFound(format!( - "Network with id {} not found", - id + "Network with id {id} not found" ))); } diff --git a/src/services/provider/evm/mod.rs b/src/services/provider/evm/mod.rs index dfae824df..5bd3b816a 100644 --- a/src/services/provider/evm/mod.rs +++ b/src/services/provider/evm/mod.rs @@ -32,7 +32,7 @@ use async_trait::async_trait; use eyre::Result; use reqwest::ClientBuilder as ReqwestClientBuilder; use serde_json; -use tracing::info; +use tracing::debug; use super::rpc_selector::RpcSelector; use super::{retry_rpc_call, ProviderConfig, RetryConfig}; @@ -41,6 +41,7 @@ use crate::{ BlockResponse, EvmTransactionData, RpcConfig, TransactionError, TransactionReceipt, U256, }, services::provider::{is_retriable_error, should_mark_provider_failed}, + utils::mask_url, }; #[cfg(test)] @@ -200,7 +201,7 @@ impl EvmProvider { /// Initialize a provider for a given URL fn initialize_provider(&self, url: &str) -> Result { - info!("Initializing provider for URL: {url}"); + debug!("Initializing provider for URL: {}", mask_url(url)); let rpc_url = url .parse() .map_err(|e| ProviderError::NetworkConfiguration(format!("Invalid URL format: {e}")))?; diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 9bbb58a9d..794a6ed1b 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -47,5 +47,8 @@ pub use json_rpc_error::*; mod error_sanitization; pub use error_sanitization::*; +mod url; +pub use url::*; + #[cfg(test)] pub mod mocks; diff --git a/src/utils/url.rs b/src/utils/url.rs new file mode 100644 index 000000000..9a86f36e1 --- /dev/null +++ b/src/utils/url.rs @@ -0,0 +1,132 @@ +//! URL utility functions. +//! +//! This module provides utility functions for working with URLs, +//! including masking sensitive information from URLs. + +/// Masks a URL by showing only the scheme and host, hiding the path and query parameters. +/// +/// This is used to safely display RPC URLs in API responses and logs without exposing +/// sensitive API keys that are often embedded in the URL path or query string. +/// +/// # Examples +/// - `https://eth-mainnet.g.alchemy.com/v2/abc123` → `https://eth-mainnet.g.alchemy.com/***` +/// - `https://mainnet.infura.io/v3/PROJECT_ID` → `https://mainnet.infura.io/***` +/// - `http://localhost:8545` → `http://localhost:8545` (no path to mask) +/// - `invalid-url` → `***` (fallback for unparseable URLs) +pub fn mask_url(url: &str) -> String { + // Find the scheme separator "://" + let Some(scheme_end) = url.find("://") else { + // No valid scheme, mask entirely for safety + return "***".to_string(); + }; + + // Find where the host ends (first "/" after "://") + let host_start = scheme_end + 3; // Skip "://" + let rest = &url[host_start..]; + + // Find the first "/" which marks the start of the path + if let Some(path_start) = rest.find('/') { + // Check if there's actually content in the path (not just "/") + let path_and_beyond = &rest[path_start..]; + if path_and_beyond.len() > 1 || url.contains('?') { + // There's a path or query to mask + let host_end = host_start + path_start; + format!("{}/***", &url[..host_end]) + } else { + // Just a trailing "/" with no real path content + url.to_string() + } + } else if url.contains('?') { + // No path but has query parameters - mask those + let query_start = url.find('?').unwrap(); + format!("{}?***", &url[..query_start]) + } else { + // No path or query to mask, return original + url.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mask_url_alchemy_with_api_key() { + let url = "https://eth-mainnet.g.alchemy.com/v2/abc123xyz"; + let masked = mask_url(url); + assert_eq!(masked, "https://eth-mainnet.g.alchemy.com/***"); + } + + #[test] + fn test_mask_url_infura_with_project_id() { + let url = "https://mainnet.infura.io/v3/my-project-id"; + let masked = mask_url(url); + assert_eq!(masked, "https://mainnet.infura.io/***"); + } + + #[test] + fn test_mask_url_quicknode_with_api_key() { + let url = "https://my-node.quiknode.pro/secret-api-key/"; + let masked = mask_url(url); + assert_eq!(masked, "https://my-node.quiknode.pro/***"); + } + + #[test] + fn test_mask_url_localhost_no_path() { + // No path to mask, should return original + let url = "http://localhost:8545"; + let masked = mask_url(url); + assert_eq!(masked, "http://localhost:8545"); + } + + #[test] + fn test_mask_url_localhost_with_trailing_slash() { + // Just a trailing slash with no real path content + let url = "http://localhost:8545/"; + let masked = mask_url(url); + assert_eq!(masked, "http://localhost:8545/"); + } + + #[test] + fn test_mask_url_with_query_params() { + let url = "https://rpc.example.com/v1?api_key=secret123&network=mainnet"; + let masked = mask_url(url); + assert_eq!(masked, "https://rpc.example.com/***"); + } + + #[test] + fn test_mask_url_query_params_no_path() { + let url = "https://rpc.example.com?api_key=secret123"; + let masked = mask_url(url); + assert_eq!(masked, "https://rpc.example.com?***"); + } + + #[test] + fn test_mask_url_invalid_url_no_scheme() { + // Invalid URL without scheme should be fully masked for safety + let url = "invalid-url"; + let masked = mask_url(url); + assert_eq!(masked, "***"); + } + + #[test] + fn test_mask_url_empty_string() { + let url = ""; + let masked = mask_url(url); + assert_eq!(masked, "***"); + } + + #[test] + fn test_mask_url_with_port_and_path() { + let url = "https://rpc.example.com:8080/api/v1/secret"; + let masked = mask_url(url); + assert_eq!(masked, "https://rpc.example.com:8080/***"); + } + + #[test] + fn test_mask_url_ankr_with_api_key() { + let url = "https://rpc.ankr.com/eth/my-api-key-here"; + let masked = mask_url(url); + assert_eq!(masked, "https://rpc.ankr.com/***"); + } +}