openzeppelin_relayer/services/provider/stellar/
mod.rs

1//! Stellar Provider implementation for interacting with Stellar blockchain networks.
2//!
3//! This module provides functionality to interact with Stellar networks through RPC calls.
4//! It implements common operations like getting accounts, sending transactions, and querying
5//! blockchain state and events.
6
7use async_trait::async_trait;
8use eyre::Result;
9use soroban_rs::stellar_rpc_client::Client;
10use soroban_rs::stellar_rpc_client::{
11    Error as StellarClientError, EventStart, EventType, GetEventsResponse, GetLatestLedgerResponse,
12    GetLedgerEntriesResponse, GetNetworkResponse, GetTransactionResponse, GetTransactionsRequest,
13    GetTransactionsResponse, SimulateTransactionResponse,
14};
15use soroban_rs::xdr::{AccountEntry, Hash, LedgerKey, TransactionEnvelope};
16#[cfg(test)]
17use soroban_rs::xdr::{AccountId, LedgerKeyAccount, PublicKey, Uint256};
18use soroban_rs::SorobanTransactionResponse;
19use std::sync::atomic::{AtomicU64, Ordering};
20
21#[cfg(test)]
22use mockall::automock;
23
24use crate::models::{JsonRpcId, RpcConfig};
25use crate::services::provider::is_retriable_error;
26use crate::services::provider::retry::retry_rpc_call;
27use crate::services::provider::rpc_selector::RpcSelector;
28use crate::services::provider::should_mark_provider_failed;
29use crate::services::provider::ProviderError;
30use crate::services::provider::RetryConfig;
31// Reqwest client is used for raw JSON-RPC HTTP requests. Alias to avoid name clash with the
32// soroban `Client` type imported above.
33use reqwest::Client as ReqwestClient;
34use std::sync::Arc;
35use std::time::Duration;
36
37/// Generates a unique JSON-RPC request ID.
38///
39/// This function returns a monotonically increasing ID for JSON-RPC requests.
40/// It's thread-safe and guarantees unique IDs across concurrent requests.
41///
42/// # Returns
43///
44/// A unique u64 ID that can be used for JSON-RPC requests
45fn generate_unique_rpc_id() -> u64 {
46    static NEXT_ID: AtomicU64 = AtomicU64::new(1);
47    NEXT_ID.fetch_add(1, Ordering::Relaxed)
48}
49
50/// Categorizes a Stellar client error into an appropriate `ProviderError` variant.
51///
52/// This function analyzes the given error and maps it to a specific `ProviderError` variant:
53/// - Handles StellarClientError variants directly (timeouts, JSON-RPC errors, etc.)
54/// - Extracts reqwest::Error from jsonrpsee Transport errors
55/// - Maps JSON-RPC error codes appropriately
56/// - Distinguishes between retriable network errors and non-retriable validation errors
57/// - Falls back to ProviderError::Other for unknown error types
58/// - Optionally prepends a context message to the error for better debugging
59///
60/// # Arguments
61///
62/// * `err` - The StellarClientError to categorize (takes ownership)
63/// * `context` - Optional context message to prepend (e.g., "Failed to get account")
64///
65/// # Returns
66///
67/// The appropriate `ProviderError` variant based on the error type
68fn categorize_stellar_error_with_context(
69    err: StellarClientError,
70    context: Option<&str>,
71) -> ProviderError {
72    let add_context = |msg: String| -> String {
73        match context {
74            Some(ctx) => format!("{ctx}: {msg}"),
75            None => msg,
76        }
77    };
78    match err {
79        // === Timeout Errors (Retriable) ===
80        StellarClientError::TransactionSubmissionTimeout => ProviderError::Timeout,
81
82        // === Address/Encoding Errors (Non-retriable, Client-side) ===
83        StellarClientError::InvalidAddress(decode_err) => ProviderError::InvalidAddress(
84            add_context(format!("Invalid Stellar address: {decode_err}")),
85        ),
86
87        // === XDR/Serialization Errors (Non-retriable, Client-side) ===
88        StellarClientError::Xdr(xdr_err) => {
89            ProviderError::Other(add_context(format!("XDR processing error: {xdr_err}")))
90        }
91
92        // === JSON Parsing Errors (Non-retriable, may indicate RPC response issue) ===
93        StellarClientError::Serde(serde_err) => {
94            ProviderError::Other(add_context(format!("JSON parsing error: {serde_err}")))
95        }
96
97        // === URL Configuration Errors (Non-retriable, Configuration issue) ===
98        StellarClientError::InvalidRpcUrl(uri_err) => {
99            ProviderError::NetworkConfiguration(add_context(format!("Invalid RPC URL: {uri_err}")))
100        }
101        StellarClientError::InvalidRpcUrlFromUriParts(uri_err) => {
102            ProviderError::NetworkConfiguration(add_context(format!(
103                "Invalid RPC URL parts: {uri_err}"
104            )))
105        }
106        StellarClientError::InvalidUrl(url) => {
107            ProviderError::NetworkConfiguration(add_context(format!("Invalid URL: {url}")))
108        }
109
110        // === Network Passphrase Mismatch (Non-retriable, Configuration issue) ===
111        StellarClientError::InvalidNetworkPassphrase { expected, server } => {
112            ProviderError::NetworkConfiguration(add_context(format!(
113                "Network passphrase mismatch: expected {expected:?}, server returned {server:?}"
114            )))
115        }
116
117        // === JSON-RPC Errors (May be retriable depending on the specific error) ===
118        StellarClientError::JsonRpc(jsonrpsee_err) => {
119            match jsonrpsee_err {
120                // Handle Call errors with error codes
121                jsonrpsee_core::error::Error::Call(err_obj) => {
122                    let code = err_obj.code() as i64;
123                    let message = add_context(err_obj.message().to_string());
124                    ProviderError::RpcErrorCode { code, message }
125                }
126
127                // Handle request timeouts
128                jsonrpsee_core::error::Error::RequestTimeout => ProviderError::Timeout,
129
130                // Handle transport errors (network-level issues)
131                jsonrpsee_core::error::Error::Transport(transport_err) => {
132                    // Check source chain for reqwest errors
133                    let mut source = transport_err.source();
134                    while let Some(s) = source {
135                        if let Some(reqwest_err) = s.downcast_ref::<reqwest::Error>() {
136                            return ProviderError::from(reqwest_err);
137                        }
138                        source = s.source();
139                    }
140
141                    ProviderError::TransportError(add_context(format!(
142                        "Transport error: {transport_err}"
143                    )))
144                }
145                // Catch-all for other jsonrpsee errors
146                other => ProviderError::Other(add_context(format!("JSON-RPC error: {other}"))),
147            }
148        }
149        // === Response Parsing/Validation Errors (May indicate RPC node issue) ===
150        StellarClientError::InvalidResponse => {
151            // This could be a temporary RPC node issue or malformed response
152            ProviderError::Other(add_context(
153                "Invalid response from Stellar RPC server".to_string(),
154            ))
155        }
156        StellarClientError::MissingResult => {
157            ProviderError::Other(add_context("Missing result in RPC response".to_string()))
158        }
159        StellarClientError::MissingError => ProviderError::Other(add_context(
160            "Failed to read error from RPC response".to_string(),
161        )),
162
163        // === Transaction Errors (Non-retriable, Transaction-specific issues) ===
164        StellarClientError::TransactionFailed(msg) => {
165            ProviderError::Other(add_context(format!("Transaction failed: {msg}")))
166        }
167        StellarClientError::TransactionSubmissionFailed(msg) => {
168            ProviderError::Other(add_context(format!("Transaction submission failed: {msg}")))
169        }
170        StellarClientError::TransactionSimulationFailed(msg) => {
171            ProviderError::Other(add_context(format!("Transaction simulation failed: {msg}")))
172        }
173        StellarClientError::UnexpectedTransactionStatus(status) => ProviderError::Other(
174            add_context(format!("Unexpected transaction status: {status}")),
175        ),
176
177        // === Resource Not Found Errors (Non-retriable) ===
178        StellarClientError::NotFound(resource, id) => {
179            ProviderError::Other(add_context(format!("{resource} not found: {id}")))
180        }
181
182        // === Client-side Validation Errors (Non-retriable) ===
183        StellarClientError::InvalidCursor => {
184            ProviderError::Other(add_context("Invalid cursor".to_string()))
185        }
186        StellarClientError::UnexpectedSimulateTransactionResultSize { length } => {
187            ProviderError::Other(add_context(format!(
188                "Unexpected simulate transaction result size: {length}"
189            )))
190        }
191        StellarClientError::UnexpectedOperationCount { count } => {
192            ProviderError::Other(add_context(format!("Unexpected operation count: {count}")))
193        }
194        StellarClientError::UnsupportedOperationType => {
195            ProviderError::Other(add_context("Unsupported operation type".to_string()))
196        }
197        StellarClientError::UnexpectedContractCodeDataType(data) => ProviderError::Other(
198            add_context(format!("Unexpected contract code data type: {data:?}")),
199        ),
200        StellarClientError::UnexpectedContractInstance(val) => ProviderError::Other(add_context(
201            format!("Unexpected contract instance: {val:?}"),
202        )),
203        StellarClientError::LargeFee(fee) => {
204            ProviderError::Other(add_context(format!("Fee too large: {fee}")))
205        }
206        StellarClientError::CannotAuthorizeRawTransaction => {
207            ProviderError::Other(add_context("Cannot authorize raw transaction".to_string()))
208        }
209        StellarClientError::MissingOp => {
210            ProviderError::Other(add_context("Missing operation in transaction".to_string()))
211        }
212        StellarClientError::MissingSignerForAddress { address } => ProviderError::Other(
213            add_context(format!("Missing signer for address: {address}")),
214        ),
215
216        // === Deprecated/Other Errors ===
217        #[allow(deprecated)]
218        StellarClientError::UnexpectedToken(entry) => {
219            ProviderError::Other(add_context(format!("Unexpected token: {entry:?}")))
220        }
221    }
222}
223
224/// Normalize a URL for logging by removing query strings, fragments and redacting userinfo.
225///
226/// Examples:
227/// - https://user:secret@api.example.com/path?api_key=XXX -> https://<redacted>@api.example.com/path
228/// - https://api.example.com/path?api_key=XXX -> https://api.example.com/path
229fn normalize_url_for_log(url: &str) -> String {
230    // Remove query and fragment first
231    let mut s = url.to_string();
232    if let Some(q) = s.find('?') {
233        s.truncate(q);
234    }
235    if let Some(h) = s.find('#') {
236        s.truncate(h);
237    }
238
239    // Redact userinfo if present (scheme://userinfo@host...)
240    if let Some(scheme_pos) = s.find("://") {
241        let start = scheme_pos + 3;
242        if let Some(at_pos) = s[start..].find('@') {
243            let after = &s[start + at_pos + 1..];
244            let prefix = &s[..start];
245            s = format!("{prefix}<redacted>@{after}");
246        }
247    }
248
249    s
250}
251#[derive(Debug, Clone)]
252pub struct GetEventsRequest {
253    pub start: EventStart,
254    pub event_type: Option<EventType>,
255    pub contract_ids: Vec<String>,
256    pub topics: Vec<String>,
257    pub limit: Option<usize>,
258}
259
260#[derive(Clone, Debug)]
261pub struct StellarProvider {
262    /// RPC selector for managing and selecting providers
263    selector: RpcSelector,
264    /// Timeout in seconds for RPC calls
265    timeout_seconds: Duration,
266    /// Configuration for retry behavior
267    retry_config: RetryConfig,
268}
269
270#[async_trait]
271#[cfg_attr(test, automock)]
272#[allow(dead_code)]
273pub trait StellarProviderTrait: Send + Sync {
274    async fn get_account(&self, account_id: &str) -> Result<AccountEntry, ProviderError>;
275    async fn simulate_transaction_envelope(
276        &self,
277        tx_envelope: &TransactionEnvelope,
278    ) -> Result<SimulateTransactionResponse, ProviderError>;
279    async fn send_transaction_polling(
280        &self,
281        tx_envelope: &TransactionEnvelope,
282    ) -> Result<SorobanTransactionResponse, ProviderError>;
283    async fn get_network(&self) -> Result<GetNetworkResponse, ProviderError>;
284    async fn get_latest_ledger(&self) -> Result<GetLatestLedgerResponse, ProviderError>;
285    async fn send_transaction(
286        &self,
287        tx_envelope: &TransactionEnvelope,
288    ) -> Result<Hash, ProviderError>;
289    async fn get_transaction(&self, tx_id: &Hash) -> Result<GetTransactionResponse, ProviderError>;
290    async fn get_transactions(
291        &self,
292        request: GetTransactionsRequest,
293    ) -> Result<GetTransactionsResponse, ProviderError>;
294    async fn get_ledger_entries(
295        &self,
296        keys: &[LedgerKey],
297    ) -> Result<GetLedgerEntriesResponse, ProviderError>;
298    async fn get_events(
299        &self,
300        request: GetEventsRequest,
301    ) -> Result<GetEventsResponse, ProviderError>;
302    async fn raw_request_dyn(
303        &self,
304        method: &str,
305        params: serde_json::Value,
306        id: Option<JsonRpcId>,
307    ) -> Result<serde_json::Value, ProviderError>;
308}
309
310impl StellarProvider {
311    // Create new StellarProvider instance
312    pub fn new(
313        mut rpc_configs: Vec<RpcConfig>,
314        timeout_seconds: u64,
315    ) -> Result<Self, ProviderError> {
316        if rpc_configs.is_empty() {
317            return Err(ProviderError::NetworkConfiguration(
318                "No RPC configurations provided for StellarProvider".to_string(),
319            ));
320        }
321
322        RpcConfig::validate_list(&rpc_configs)
323            .map_err(|e| ProviderError::NetworkConfiguration(e.to_string()))?;
324
325        rpc_configs.retain(|config| config.get_weight() > 0);
326
327        if rpc_configs.is_empty() {
328            return Err(ProviderError::NetworkConfiguration(
329                "No active RPC configurations provided (all weights are 0 or list was empty after filtering)".to_string(),
330            ));
331        }
332
333        let selector = RpcSelector::new(rpc_configs).map_err(|e| {
334            ProviderError::NetworkConfiguration(format!("Failed to create RPC selector: {e}"))
335        })?;
336
337        let retry_config = RetryConfig::from_env();
338
339        Ok(Self {
340            selector,
341            timeout_seconds: Duration::from_secs(timeout_seconds),
342            retry_config,
343        })
344    }
345
346    /// Initialize a Stellar client for a given URL
347    fn initialize_provider(&self, url: &str) -> Result<Client, ProviderError> {
348        Client::new(url).map_err(|e| {
349            ProviderError::NetworkConfiguration(format!(
350                "Failed to create Stellar RPC client: {e} - URL: '{url}'"
351            ))
352        })
353    }
354
355    /// Initialize a reqwest client for raw HTTP JSON-RPC calls.
356    ///
357    /// This centralizes client creation so we can configure timeouts and other options in one place.
358    fn initialize_raw_provider(&self, url: &str) -> Result<ReqwestClient, ProviderError> {
359        ReqwestClient::builder()
360            .timeout(self.timeout_seconds)
361            .build()
362            .map_err(|e| {
363                ProviderError::NetworkConfiguration(format!(
364                    "Failed to create HTTP client for raw RPC: {e} - URL: '{url}'"
365                ))
366            })
367    }
368
369    /// Helper method to retry RPC calls with exponential backoff
370    async fn retry_rpc_call<T, F, Fut>(
371        &self,
372        operation_name: &str,
373        operation: F,
374    ) -> Result<T, ProviderError>
375    where
376        F: Fn(Client) -> Fut,
377        Fut: std::future::Future<Output = Result<T, ProviderError>>,
378    {
379        let provider_url_raw = match self.selector.get_current_url() {
380            Ok(url) => url,
381            Err(e) => {
382                return Err(ProviderError::NetworkConfiguration(format!(
383                    "No RPC URL available for StellarProvider: {e}"
384                )));
385            }
386        };
387        let provider_url = normalize_url_for_log(&provider_url_raw);
388
389        tracing::debug!(
390            "Starting Stellar RPC operation '{}' with timeout: {}s, provider_url: {}",
391            operation_name,
392            self.timeout_seconds.as_secs(),
393            provider_url
394        );
395
396        retry_rpc_call(
397            &self.selector,
398            operation_name,
399            is_retriable_error,
400            should_mark_provider_failed,
401            |url| self.initialize_provider(url),
402            operation,
403            Some(self.retry_config.clone()),
404        )
405        .await
406    }
407
408    /// Retry helper for raw JSON-RPC requests
409    async fn retry_raw_request(
410        &self,
411        operation_name: &str,
412        request: serde_json::Value,
413    ) -> Result<serde_json::Value, ProviderError> {
414        let provider_url_raw = match self.selector.get_current_url() {
415            Ok(url) => url,
416            Err(e) => {
417                return Err(ProviderError::NetworkConfiguration(format!(
418                    "No RPC URL available for StellarProvider: {e}"
419                )));
420            }
421        };
422        let provider_url = normalize_url_for_log(&provider_url_raw);
423
424        tracing::debug!(
425            "Starting raw RPC operation '{}' with timeout: {}s, provider_url: {}",
426            operation_name,
427            self.timeout_seconds.as_secs(),
428            provider_url
429        );
430
431        let request_clone = request.clone();
432        retry_rpc_call(
433            &self.selector,
434            operation_name,
435            is_retriable_error,
436            should_mark_provider_failed,
437            |url| {
438                // Initialize an HTTP client for this URL and return it together with the URL string
439                self.initialize_raw_provider(url)
440                    .map(|client| (url.to_string(), client))
441            },
442            |(url, client): (String, ReqwestClient)| {
443                let request_for_call = request_clone.clone();
444                async move {
445                    let response = client
446                        .post(&url)
447                        .json(&request_for_call)
448                        // Keep a per-request timeout as a safeguard (client also has a default timeout)
449                        .timeout(self.timeout_seconds)
450                        .send()
451                        .await
452                        .map_err(ProviderError::from)?;
453
454                    let json_response: serde_json::Value =
455                        response.json().await.map_err(ProviderError::from)?;
456
457                    Ok(json_response)
458                }
459            },
460            Some(self.retry_config.clone()),
461        )
462        .await
463    }
464}
465
466#[async_trait]
467impl StellarProviderTrait for StellarProvider {
468    async fn get_account(&self, account_id: &str) -> Result<AccountEntry, ProviderError> {
469        let account_id = Arc::new(account_id.to_string());
470
471        self.retry_rpc_call("get_account", move |client| {
472            let account_id = Arc::clone(&account_id);
473            async move {
474                client.get_account(&account_id).await.map_err(|e| {
475                    categorize_stellar_error_with_context(e, Some("Failed to get account"))
476                })
477            }
478        })
479        .await
480    }
481
482    async fn simulate_transaction_envelope(
483        &self,
484        tx_envelope: &TransactionEnvelope,
485    ) -> Result<SimulateTransactionResponse, ProviderError> {
486        let tx_envelope = Arc::new(tx_envelope.clone());
487
488        self.retry_rpc_call("simulate_transaction_envelope", move |client| {
489            let tx_envelope = Arc::clone(&tx_envelope);
490            async move {
491                client
492                    .simulate_transaction_envelope(&tx_envelope, None)
493                    .await
494                    .map_err(|e| {
495                        categorize_stellar_error_with_context(
496                            e,
497                            Some("Failed to simulate transaction"),
498                        )
499                    })
500            }
501        })
502        .await
503    }
504
505    async fn send_transaction_polling(
506        &self,
507        tx_envelope: &TransactionEnvelope,
508    ) -> Result<SorobanTransactionResponse, ProviderError> {
509        let tx_envelope = Arc::new(tx_envelope.clone());
510
511        self.retry_rpc_call("send_transaction_polling", move |client| {
512            let tx_envelope = Arc::clone(&tx_envelope);
513            async move {
514                client
515                    .send_transaction_polling(&tx_envelope)
516                    .await
517                    .map(SorobanTransactionResponse::from)
518                    .map_err(|e| {
519                        categorize_stellar_error_with_context(
520                            e,
521                            Some("Failed to send transaction (polling)"),
522                        )
523                    })
524            }
525        })
526        .await
527    }
528
529    async fn get_network(&self) -> Result<GetNetworkResponse, ProviderError> {
530        self.retry_rpc_call("get_network", |client| async move {
531            client.get_network().await.map_err(|e| {
532                categorize_stellar_error_with_context(e, Some("Failed to get network"))
533            })
534        })
535        .await
536    }
537
538    async fn get_latest_ledger(&self) -> Result<GetLatestLedgerResponse, ProviderError> {
539        self.retry_rpc_call("get_latest_ledger", |client| async move {
540            client.get_latest_ledger().await.map_err(|e| {
541                categorize_stellar_error_with_context(e, Some("Failed to get latest ledger"))
542            })
543        })
544        .await
545    }
546
547    async fn send_transaction(
548        &self,
549        tx_envelope: &TransactionEnvelope,
550    ) -> Result<Hash, ProviderError> {
551        let tx_envelope = Arc::new(tx_envelope.clone());
552
553        self.retry_rpc_call("send_transaction", move |client| {
554            let tx_envelope = Arc::clone(&tx_envelope);
555            async move {
556                client.send_transaction(&tx_envelope).await.map_err(|e| {
557                    categorize_stellar_error_with_context(e, Some("Failed to send transaction"))
558                })
559            }
560        })
561        .await
562    }
563
564    async fn get_transaction(&self, tx_id: &Hash) -> Result<GetTransactionResponse, ProviderError> {
565        let tx_id = Arc::new(tx_id.clone());
566
567        self.retry_rpc_call("get_transaction", move |client| {
568            let tx_id = Arc::clone(&tx_id);
569            async move {
570                client.get_transaction(&tx_id).await.map_err(|e| {
571                    categorize_stellar_error_with_context(e, Some("Failed to get transaction"))
572                })
573            }
574        })
575        .await
576    }
577
578    async fn get_transactions(
579        &self,
580        request: GetTransactionsRequest,
581    ) -> Result<GetTransactionsResponse, ProviderError> {
582        let request = Arc::new(request);
583
584        self.retry_rpc_call("get_transactions", move |client| {
585            let request = Arc::clone(&request);
586            async move {
587                client
588                    .get_transactions((*request).clone())
589                    .await
590                    .map_err(|e| {
591                        categorize_stellar_error_with_context(e, Some("Failed to get transactions"))
592                    })
593            }
594        })
595        .await
596    }
597
598    async fn get_ledger_entries(
599        &self,
600        keys: &[LedgerKey],
601    ) -> Result<GetLedgerEntriesResponse, ProviderError> {
602        let keys = Arc::new(keys.to_vec());
603
604        self.retry_rpc_call("get_ledger_entries", move |client| {
605            let keys = Arc::clone(&keys);
606            async move {
607                client.get_ledger_entries(&keys).await.map_err(|e| {
608                    categorize_stellar_error_with_context(e, Some("Failed to get ledger entries"))
609                })
610            }
611        })
612        .await
613    }
614
615    async fn get_events(
616        &self,
617        request: GetEventsRequest,
618    ) -> Result<GetEventsResponse, ProviderError> {
619        let request = Arc::new(request);
620
621        self.retry_rpc_call("get_events", move |client| {
622            let request = Arc::clone(&request);
623            async move {
624                client
625                    .get_events(
626                        request.start.clone(),
627                        request.event_type,
628                        &request.contract_ids,
629                        &request.topics,
630                        request.limit,
631                    )
632                    .await
633                    .map_err(|e| {
634                        categorize_stellar_error_with_context(e, Some("Failed to get events"))
635                    })
636            }
637        })
638        .await
639    }
640
641    async fn raw_request_dyn(
642        &self,
643        method: &str,
644        params: serde_json::Value,
645        id: Option<JsonRpcId>,
646    ) -> Result<serde_json::Value, ProviderError> {
647        let id_value = match id {
648            Some(id) => serde_json::to_value(id)
649                .map_err(|e| ProviderError::Other(format!("Failed to serialize id: {e}")))?,
650            None => serde_json::json!(generate_unique_rpc_id()),
651        };
652
653        let request = serde_json::json!({
654            "jsonrpc": "2.0",
655            "id": id_value,
656            "method": method,
657            "params": params,
658        });
659
660        let response = self.retry_raw_request("raw_request_dyn", request).await?;
661
662        // Check for JSON-RPC error
663        if let Some(error) = response.get("error") {
664            if let Some(code) = error.get("code").and_then(|c| c.as_i64()) {
665                return Err(ProviderError::RpcErrorCode {
666                    code,
667                    message: error
668                        .get("message")
669                        .and_then(|m| m.as_str())
670                        .unwrap_or("Unknown error")
671                        .to_string(),
672                });
673            }
674            return Err(ProviderError::Other(format!("JSON-RPC error: {error}")));
675        }
676
677        // Extract result
678        response
679            .get("result")
680            .cloned()
681            .ok_or_else(|| ProviderError::Other("No result field in JSON-RPC response".to_string()))
682    }
683}
684
685#[cfg(test)]
686mod stellar_rpc_tests {
687    use super::*;
688    use crate::services::provider::stellar::{
689        GetEventsRequest, StellarProvider, StellarProviderTrait,
690    };
691    use futures::FutureExt;
692    use lazy_static::lazy_static;
693    use mockall::predicate as p;
694    use soroban_rs::stellar_rpc_client::{
695        EventStart, GetEventsResponse, GetLatestLedgerResponse, GetLedgerEntriesResponse,
696        GetNetworkResponse, GetTransactionEvents, GetTransactionResponse, GetTransactionsRequest,
697        GetTransactionsResponse, SimulateTransactionResponse,
698    };
699    use soroban_rs::xdr::{
700        AccountEntryExt, Hash, LedgerKey, OperationResult, String32, Thresholds,
701        TransactionEnvelope, TransactionResult, TransactionResultExt, TransactionResultResult,
702        VecM,
703    };
704    use soroban_rs::{create_mock_set_options_tx_envelope, SorobanTransactionResponse};
705    use std::str::FromStr;
706    use std::sync::Mutex;
707
708    lazy_static! {
709        static ref STELLAR_TEST_ENV_MUTEX: Mutex<()> = Mutex::new(());
710    }
711
712    struct StellarTestEnvGuard {
713        _mutex_guard: std::sync::MutexGuard<'static, ()>,
714    }
715
716    impl StellarTestEnvGuard {
717        fn new(mutex_guard: std::sync::MutexGuard<'static, ()>) -> Self {
718            std::env::set_var(
719                "API_KEY",
720                "test_api_key_for_evm_provider_new_this_is_long_enough_32_chars",
721            );
722            std::env::set_var("REDIS_URL", "redis://test-dummy-url-for-evm-provider");
723            // Set minimal retry config to avoid excessive retries and TCP exhaustion in concurrent tests
724            std::env::set_var("PROVIDER_MAX_RETRIES", "1");
725            std::env::set_var("PROVIDER_MAX_FAILOVERS", "0");
726            std::env::set_var("PROVIDER_RETRY_BASE_DELAY_MS", "0");
727            std::env::set_var("PROVIDER_RETRY_MAX_DELAY_MS", "0");
728
729            Self {
730                _mutex_guard: mutex_guard,
731            }
732        }
733    }
734
735    impl Drop for StellarTestEnvGuard {
736        fn drop(&mut self) {
737            std::env::remove_var("API_KEY");
738            std::env::remove_var("REDIS_URL");
739            std::env::remove_var("PROVIDER_MAX_RETRIES");
740            std::env::remove_var("PROVIDER_MAX_FAILOVERS");
741            std::env::remove_var("PROVIDER_RETRY_BASE_DELAY_MS");
742            std::env::remove_var("PROVIDER_RETRY_MAX_DELAY_MS");
743        }
744    }
745
746    // Helper function to set up the test environment
747    fn setup_test_env() -> StellarTestEnvGuard {
748        let guard = STELLAR_TEST_ENV_MUTEX
749            .lock()
750            .unwrap_or_else(|e| e.into_inner());
751        StellarTestEnvGuard::new(guard)
752    }
753
754    fn dummy_hash() -> Hash {
755        Hash([0u8; 32])
756    }
757
758    fn dummy_get_network_response() -> GetNetworkResponse {
759        GetNetworkResponse {
760            friendbot_url: Some("https://friendbot.testnet.stellar.org/".into()),
761            passphrase: "Test SDF Network ; September 2015".into(),
762            protocol_version: 20,
763        }
764    }
765
766    fn dummy_get_latest_ledger_response() -> GetLatestLedgerResponse {
767        GetLatestLedgerResponse {
768            id: "c73c5eac58a441d4eb733c35253ae85f783e018f7be5ef974258fed067aabb36".into(),
769            protocol_version: 20,
770            sequence: 2_539_605,
771        }
772    }
773
774    fn dummy_simulate() -> SimulateTransactionResponse {
775        SimulateTransactionResponse {
776            min_resource_fee: 100,
777            transaction_data: "test".to_string(),
778            ..Default::default()
779        }
780    }
781
782    fn create_success_tx_result() -> TransactionResult {
783        // Create empty operation results
784        let empty_vec: Vec<OperationResult> = Vec::new();
785        let op_results = empty_vec.try_into().unwrap_or_default();
786
787        TransactionResult {
788            fee_charged: 100,
789            result: TransactionResultResult::TxSuccess(op_results),
790            ext: TransactionResultExt::V0,
791        }
792    }
793
794    fn dummy_get_transaction_response() -> GetTransactionResponse {
795        GetTransactionResponse {
796            status: "SUCCESS".to_string(),
797            envelope: None,
798            result: Some(create_success_tx_result()),
799            result_meta: None,
800            events: GetTransactionEvents {
801                contract_events: vec![],
802                diagnostic_events: vec![],
803                transaction_events: vec![],
804            },
805            ledger: None,
806        }
807    }
808
809    fn dummy_soroban_tx() -> SorobanTransactionResponse {
810        SorobanTransactionResponse {
811            response: dummy_get_transaction_response(),
812        }
813    }
814
815    fn dummy_get_transactions_response() -> GetTransactionsResponse {
816        GetTransactionsResponse {
817            transactions: vec![],
818            latest_ledger: 0,
819            latest_ledger_close_time: 0,
820            oldest_ledger: 0,
821            oldest_ledger_close_time: 0,
822            cursor: 0,
823        }
824    }
825
826    fn dummy_get_ledger_entries_response() -> GetLedgerEntriesResponse {
827        GetLedgerEntriesResponse {
828            entries: None,
829            latest_ledger: 0,
830        }
831    }
832
833    fn dummy_get_events_response() -> GetEventsResponse {
834        GetEventsResponse {
835            events: vec![],
836            latest_ledger: 0,
837            latest_ledger_close_time: "0".to_string(),
838            oldest_ledger: 0,
839            oldest_ledger_close_time: "0".to_string(),
840            cursor: "0".to_string(),
841        }
842    }
843
844    fn dummy_transaction_envelope() -> TransactionEnvelope {
845        create_mock_set_options_tx_envelope()
846    }
847
848    fn dummy_ledger_key() -> LedgerKey {
849        LedgerKey::Account(LedgerKeyAccount {
850            account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
851        })
852    }
853
854    pub fn mock_account_entry(account_id: &str) -> AccountEntry {
855        AccountEntry {
856            account_id: AccountId(PublicKey::from_str(account_id).unwrap()),
857            balance: 0,
858            ext: AccountEntryExt::V0,
859            flags: 0,
860            home_domain: String32::default(),
861            inflation_dest: None,
862            seq_num: 0.into(),
863            num_sub_entries: 0,
864            signers: VecM::default(),
865            thresholds: Thresholds([0, 0, 0, 0]),
866        }
867    }
868
869    fn dummy_account_entry() -> AccountEntry {
870        mock_account_entry("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF")
871    }
872
873    // ---------------------------------------------------------------------
874    // Tests
875    // ---------------------------------------------------------------------
876
877    #[test]
878    fn test_new_provider() {
879        let _env_guard = setup_test_env();
880
881        let provider =
882            StellarProvider::new(vec![RpcConfig::new("http://localhost:8000".to_string())], 0);
883        assert!(provider.is_ok());
884
885        let provider_err = StellarProvider::new(vec![], 0);
886        assert!(provider_err.is_err());
887        match provider_err.unwrap_err() {
888            ProviderError::NetworkConfiguration(msg) => {
889                assert!(msg.contains("No RPC configurations provided"));
890            }
891            _ => panic!("Unexpected error type"),
892        }
893    }
894
895    #[test]
896    fn test_new_provider_selects_highest_weight() {
897        let _env_guard = setup_test_env();
898
899        let configs = vec![
900            RpcConfig::with_weight("http://rpc1.example.com".to_string(), 10).unwrap(),
901            RpcConfig::with_weight("http://rpc2.example.com".to_string(), 100).unwrap(), // Highest weight
902            RpcConfig::with_weight("http://rpc3.example.com".to_string(), 50).unwrap(),
903        ];
904        let provider = StellarProvider::new(configs, 0);
905        assert!(provider.is_ok());
906        // We can't directly inspect the client's URL easily without more complex mocking or changes.
907        // For now, we trust the sorting logic and that Client::new would fail for a truly bad URL if selection was wrong.
908        // A more robust test would involve a mock client or a way to inspect the chosen URL.
909    }
910
911    #[test]
912    fn test_new_provider_ignores_weight_zero() {
913        let _env_guard = setup_test_env();
914
915        let configs = vec![
916            RpcConfig::with_weight("http://rpc1.example.com".to_string(), 0).unwrap(), // Weight 0
917            RpcConfig::with_weight("http://rpc2.example.com".to_string(), 100).unwrap(), // Should be selected
918        ];
919        let provider = StellarProvider::new(configs, 0);
920        assert!(provider.is_ok());
921
922        let configs_only_zero =
923            vec![RpcConfig::with_weight("http://rpc1.example.com".to_string(), 0).unwrap()];
924        let provider_err = StellarProvider::new(configs_only_zero, 0);
925        assert!(provider_err.is_err());
926        match provider_err.unwrap_err() {
927            ProviderError::NetworkConfiguration(msg) => {
928                assert!(msg.contains("No active RPC configurations provided"));
929            }
930            _ => panic!("Unexpected error type"),
931        }
932    }
933
934    #[test]
935    fn test_new_provider_invalid_url_scheme() {
936        let configs = vec![RpcConfig::new("ftp://invalid.example.com".to_string())];
937        let provider_err = StellarProvider::new(configs, 0);
938        assert!(provider_err.is_err());
939        match provider_err.unwrap_err() {
940            ProviderError::NetworkConfiguration(msg) => {
941                assert!(msg.contains("Invalid URL scheme"));
942            }
943            _ => panic!("Unexpected error type"),
944        }
945    }
946
947    #[test]
948    fn test_new_provider_all_zero_weight_configs() {
949        let _env_guard = setup_test_env();
950
951        let configs = vec![
952            RpcConfig::with_weight("http://rpc1.example.com".to_string(), 0).unwrap(),
953            RpcConfig::with_weight("http://rpc2.example.com".to_string(), 0).unwrap(),
954        ];
955        let provider_err = StellarProvider::new(configs, 0);
956        assert!(provider_err.is_err());
957        match provider_err.unwrap_err() {
958            ProviderError::NetworkConfiguration(msg) => {
959                assert!(msg.contains("No active RPC configurations provided"));
960            }
961            _ => panic!("Unexpected error type"),
962        }
963    }
964
965    #[tokio::test]
966    async fn test_mock_basic_methods() {
967        let mut mock = MockStellarProviderTrait::new();
968
969        mock.expect_get_network()
970            .times(1)
971            .returning(|| async { Ok(dummy_get_network_response()) }.boxed());
972
973        mock.expect_get_latest_ledger()
974            .times(1)
975            .returning(|| async { Ok(dummy_get_latest_ledger_response()) }.boxed());
976
977        assert!(mock.get_network().await.is_ok());
978        assert!(mock.get_latest_ledger().await.is_ok());
979    }
980
981    #[tokio::test]
982    async fn test_mock_transaction_flow() {
983        let mut mock = MockStellarProviderTrait::new();
984
985        let envelope: TransactionEnvelope = dummy_transaction_envelope();
986        let hash = dummy_hash();
987
988        mock.expect_simulate_transaction_envelope()
989            .withf(|_| true)
990            .times(1)
991            .returning(|_| async { Ok(dummy_simulate()) }.boxed());
992
993        mock.expect_send_transaction()
994            .withf(|_| true)
995            .times(1)
996            .returning(|_| async { Ok(dummy_hash()) }.boxed());
997
998        mock.expect_send_transaction_polling()
999            .withf(|_| true)
1000            .times(1)
1001            .returning(|_| async { Ok(dummy_soroban_tx()) }.boxed());
1002
1003        mock.expect_get_transaction()
1004            .withf(|_| true)
1005            .times(1)
1006            .returning(|_| async { Ok(dummy_get_transaction_response()) }.boxed());
1007
1008        mock.simulate_transaction_envelope(&envelope).await.unwrap();
1009        mock.send_transaction(&envelope).await.unwrap();
1010        mock.send_transaction_polling(&envelope).await.unwrap();
1011        mock.get_transaction(&hash).await.unwrap();
1012    }
1013
1014    #[tokio::test]
1015    async fn test_mock_events_and_entries() {
1016        let mut mock = MockStellarProviderTrait::new();
1017
1018        mock.expect_get_events()
1019            .times(1)
1020            .returning(|_| async { Ok(dummy_get_events_response()) }.boxed());
1021
1022        mock.expect_get_ledger_entries()
1023            .times(1)
1024            .returning(|_| async { Ok(dummy_get_ledger_entries_response()) }.boxed());
1025
1026        let events_request = GetEventsRequest {
1027            start: EventStart::Ledger(1),
1028            event_type: None,
1029            contract_ids: vec![],
1030            topics: vec![],
1031            limit: Some(10),
1032        };
1033
1034        let dummy_key: LedgerKey = dummy_ledger_key();
1035        mock.get_events(events_request).await.unwrap();
1036        mock.get_ledger_entries(&[dummy_key]).await.unwrap();
1037    }
1038
1039    #[tokio::test]
1040    async fn test_mock_all_methods_ok() {
1041        let mut mock = MockStellarProviderTrait::new();
1042
1043        mock.expect_get_account()
1044            .with(p::eq("GTESTACCOUNTID"))
1045            .times(1)
1046            .returning(|_| async { Ok(dummy_account_entry()) }.boxed());
1047
1048        mock.expect_simulate_transaction_envelope()
1049            .times(1)
1050            .returning(|_| async { Ok(dummy_simulate()) }.boxed());
1051
1052        mock.expect_send_transaction_polling()
1053            .times(1)
1054            .returning(|_| async { Ok(dummy_soroban_tx()) }.boxed());
1055
1056        mock.expect_get_network()
1057            .times(1)
1058            .returning(|| async { Ok(dummy_get_network_response()) }.boxed());
1059
1060        mock.expect_get_latest_ledger()
1061            .times(1)
1062            .returning(|| async { Ok(dummy_get_latest_ledger_response()) }.boxed());
1063
1064        mock.expect_send_transaction()
1065            .times(1)
1066            .returning(|_| async { Ok(dummy_hash()) }.boxed());
1067
1068        mock.expect_get_transaction()
1069            .times(1)
1070            .returning(|_| async { Ok(dummy_get_transaction_response()) }.boxed());
1071
1072        mock.expect_get_transactions()
1073            .times(1)
1074            .returning(|_| async { Ok(dummy_get_transactions_response()) }.boxed());
1075
1076        mock.expect_get_ledger_entries()
1077            .times(1)
1078            .returning(|_| async { Ok(dummy_get_ledger_entries_response()) }.boxed());
1079
1080        mock.expect_get_events()
1081            .times(1)
1082            .returning(|_| async { Ok(dummy_get_events_response()) }.boxed());
1083
1084        let _ = mock.get_account("GTESTACCOUNTID").await.unwrap();
1085        let env: TransactionEnvelope = dummy_transaction_envelope();
1086        mock.simulate_transaction_envelope(&env).await.unwrap();
1087        mock.send_transaction_polling(&env).await.unwrap();
1088        mock.get_network().await.unwrap();
1089        mock.get_latest_ledger().await.unwrap();
1090        mock.send_transaction(&env).await.unwrap();
1091
1092        let h = dummy_hash();
1093        mock.get_transaction(&h).await.unwrap();
1094
1095        let req: GetTransactionsRequest = GetTransactionsRequest {
1096            start_ledger: None,
1097            pagination: None,
1098        };
1099        mock.get_transactions(req).await.unwrap();
1100
1101        let key: LedgerKey = dummy_ledger_key();
1102        mock.get_ledger_entries(&[key]).await.unwrap();
1103
1104        let ev_req = GetEventsRequest {
1105            start: EventStart::Ledger(0),
1106            event_type: None,
1107            contract_ids: vec![],
1108            topics: vec![],
1109            limit: None,
1110        };
1111        mock.get_events(ev_req).await.unwrap();
1112    }
1113
1114    #[tokio::test]
1115    async fn test_error_propagation() {
1116        let mut mock = MockStellarProviderTrait::new();
1117
1118        mock.expect_get_account()
1119            .returning(|_| async { Err(ProviderError::Other("boom".to_string())) }.boxed());
1120
1121        let res = mock.get_account("BAD").await;
1122        assert!(res.is_err());
1123        assert!(res.unwrap_err().to_string().contains("boom"));
1124    }
1125
1126    #[tokio::test]
1127    async fn test_get_events_edge_cases() {
1128        let mut mock = MockStellarProviderTrait::new();
1129
1130        mock.expect_get_events()
1131            .withf(|req| {
1132                req.contract_ids.is_empty() && req.topics.is_empty() && req.limit.is_none()
1133            })
1134            .times(1)
1135            .returning(|_| async { Ok(dummy_get_events_response()) }.boxed());
1136
1137        let ev_req = GetEventsRequest {
1138            start: EventStart::Ledger(0),
1139            event_type: None,
1140            contract_ids: vec![],
1141            topics: vec![],
1142            limit: None,
1143        };
1144
1145        mock.get_events(ev_req).await.unwrap();
1146    }
1147
1148    #[test]
1149    fn test_provider_send_sync_bounds() {
1150        fn assert_send_sync<T: Send + Sync>() {}
1151        assert_send_sync::<StellarProvider>();
1152    }
1153
1154    #[cfg(test)]
1155    mod concrete_tests {
1156        use super::*;
1157
1158        const NON_EXISTENT_URL: &str = "http://127.0.0.1:9998";
1159
1160        fn setup_provider() -> StellarProvider {
1161            StellarProvider::new(vec![RpcConfig::new(NON_EXISTENT_URL.to_string())], 0)
1162                .expect("Provider creation should succeed even with bad URL")
1163        }
1164
1165        #[tokio::test]
1166        async fn test_concrete_get_account_error() {
1167            let _env_guard = setup_test_env();
1168            let provider = setup_provider();
1169            let result = provider.get_account("SOME_ACCOUNT_ID").await;
1170            assert!(result.is_err());
1171            let err_str = result.unwrap_err().to_string();
1172            // Should contain the "Failed to..." context message
1173            assert!(
1174                err_str.contains("Failed to get account"),
1175                "Unexpected error message: {}",
1176                err_str
1177            );
1178        }
1179
1180        #[tokio::test]
1181        async fn test_concrete_simulate_transaction_envelope_error() {
1182            let _env_guard = setup_test_env();
1183
1184            let provider = setup_provider();
1185            let envelope: TransactionEnvelope = dummy_transaction_envelope();
1186            let result = provider.simulate_transaction_envelope(&envelope).await;
1187            assert!(result.is_err());
1188            let err_str = result.unwrap_err().to_string();
1189            // Should contain the "Failed to..." context message
1190            assert!(
1191                err_str.contains("Failed to simulate transaction"),
1192                "Unexpected error message: {}",
1193                err_str
1194            );
1195        }
1196
1197        #[tokio::test]
1198        async fn test_concrete_send_transaction_polling_error() {
1199            let _env_guard = setup_test_env();
1200
1201            let provider = setup_provider();
1202            let envelope: TransactionEnvelope = dummy_transaction_envelope();
1203            let result = provider.send_transaction_polling(&envelope).await;
1204            assert!(result.is_err());
1205            let err_str = result.unwrap_err().to_string();
1206            // Should contain the "Failed to..." context message
1207            assert!(
1208                err_str.contains("Failed to send transaction (polling)"),
1209                "Unexpected error message: {}",
1210                err_str
1211            );
1212        }
1213
1214        #[tokio::test]
1215        async fn test_concrete_get_network_error() {
1216            let _env_guard = setup_test_env();
1217
1218            let provider = setup_provider();
1219            let result = provider.get_network().await;
1220            assert!(result.is_err());
1221            let err_str = result.unwrap_err().to_string();
1222            // Should contain the "Failed to..." context message
1223            assert!(
1224                err_str.contains("Failed to get network"),
1225                "Unexpected error message: {}",
1226                err_str
1227            );
1228        }
1229
1230        #[tokio::test]
1231        async fn test_concrete_get_latest_ledger_error() {
1232            let _env_guard = setup_test_env();
1233
1234            let provider = setup_provider();
1235            let result = provider.get_latest_ledger().await;
1236            assert!(result.is_err());
1237            let err_str = result.unwrap_err().to_string();
1238            // Should contain the "Failed to..." context message
1239            assert!(
1240                err_str.contains("Failed to get latest ledger"),
1241                "Unexpected error message: {}",
1242                err_str
1243            );
1244        }
1245
1246        #[tokio::test]
1247        async fn test_concrete_send_transaction_error() {
1248            let _env_guard = setup_test_env();
1249
1250            let provider = setup_provider();
1251            let envelope: TransactionEnvelope = dummy_transaction_envelope();
1252            let result = provider.send_transaction(&envelope).await;
1253            assert!(result.is_err());
1254            let err_str = result.unwrap_err().to_string();
1255            // Should contain the "Failed to..." context message
1256            assert!(
1257                err_str.contains("Failed to send transaction"),
1258                "Unexpected error message: {}",
1259                err_str
1260            );
1261        }
1262
1263        #[tokio::test]
1264        async fn test_concrete_get_transaction_error() {
1265            let _env_guard = setup_test_env();
1266
1267            let provider = setup_provider();
1268            let hash: Hash = dummy_hash();
1269            let result = provider.get_transaction(&hash).await;
1270            assert!(result.is_err());
1271            let err_str = result.unwrap_err().to_string();
1272            // Should contain the "Failed to..." context message
1273            assert!(
1274                err_str.contains("Failed to get transaction"),
1275                "Unexpected error message: {}",
1276                err_str
1277            );
1278        }
1279
1280        #[tokio::test]
1281        async fn test_concrete_get_transactions_error() {
1282            let _env_guard = setup_test_env();
1283
1284            let provider = setup_provider();
1285            let req = GetTransactionsRequest {
1286                start_ledger: None,
1287                pagination: None,
1288            };
1289            let result = provider.get_transactions(req).await;
1290            assert!(result.is_err());
1291            let err_str = result.unwrap_err().to_string();
1292            // Should contain the "Failed to..." context message
1293            assert!(
1294                err_str.contains("Failed to get transactions"),
1295                "Unexpected error message: {}",
1296                err_str
1297            );
1298        }
1299
1300        #[tokio::test]
1301        async fn test_concrete_get_ledger_entries_error() {
1302            let _env_guard = setup_test_env();
1303
1304            let provider = setup_provider();
1305            let key: LedgerKey = dummy_ledger_key();
1306            let result = provider.get_ledger_entries(&[key]).await;
1307            assert!(result.is_err());
1308            let err_str = result.unwrap_err().to_string();
1309            // Should contain the "Failed to..." context message
1310            assert!(
1311                err_str.contains("Failed to get ledger entries"),
1312                "Unexpected error message: {}",
1313                err_str
1314            );
1315        }
1316
1317        #[tokio::test]
1318        async fn test_concrete_get_events_error() {
1319            let _env_guard = setup_test_env();
1320            let provider = setup_provider();
1321            let req = GetEventsRequest {
1322                start: EventStart::Ledger(1),
1323                event_type: None,
1324                contract_ids: vec![],
1325                topics: vec![],
1326                limit: None,
1327            };
1328            let result = provider.get_events(req).await;
1329            assert!(result.is_err());
1330            let err_str = result.unwrap_err().to_string();
1331            // Should contain the "Failed to..." context message
1332            assert!(
1333                err_str.contains("Failed to get events"),
1334                "Unexpected error message: {}",
1335                err_str
1336            );
1337        }
1338    }
1339
1340    #[test]
1341    fn test_generate_unique_rpc_id() {
1342        let id1 = generate_unique_rpc_id();
1343        let id2 = generate_unique_rpc_id();
1344        assert_ne!(id1, id2, "Generated IDs should be unique");
1345        assert!(id1 > 0, "ID should be positive");
1346        assert!(id2 > 0, "ID should be positive");
1347        assert!(id2 > id1, "IDs should be monotonically increasing");
1348    }
1349
1350    #[test]
1351    fn test_normalize_url_for_log() {
1352        // Test basic URL without query/fragment
1353        assert_eq!(
1354            normalize_url_for_log("https://api.example.com/path"),
1355            "https://api.example.com/path"
1356        );
1357
1358        // Test URL with query string removal
1359        assert_eq!(
1360            normalize_url_for_log("https://api.example.com/path?api_key=secret&other=value"),
1361            "https://api.example.com/path"
1362        );
1363
1364        // Test URL with fragment removal
1365        assert_eq!(
1366            normalize_url_for_log("https://api.example.com/path#section"),
1367            "https://api.example.com/path"
1368        );
1369
1370        // Test URL with both query and fragment
1371        assert_eq!(
1372            normalize_url_for_log("https://api.example.com/path?key=value#fragment"),
1373            "https://api.example.com/path"
1374        );
1375
1376        // Test URL with userinfo redaction
1377        assert_eq!(
1378            normalize_url_for_log("https://user:password@api.example.com/path"),
1379            "https://<redacted>@api.example.com/path"
1380        );
1381
1382        // Test URL with userinfo and query/fragment removal
1383        assert_eq!(
1384            normalize_url_for_log("https://user:pass@api.example.com/path?token=abc#frag"),
1385            "https://<redacted>@api.example.com/path"
1386        );
1387
1388        // Test URL without userinfo (should remain unchanged)
1389        assert_eq!(
1390            normalize_url_for_log("https://api.example.com/path?token=abc"),
1391            "https://api.example.com/path"
1392        );
1393
1394        // Test malformed URL (should handle gracefully)
1395        assert_eq!(normalize_url_for_log("not-a-url"), "not-a-url");
1396    }
1397
1398    #[test]
1399    fn test_categorize_stellar_error_with_context_timeout() {
1400        let err = StellarClientError::TransactionSubmissionTimeout;
1401        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1402        assert!(matches!(result, ProviderError::Timeout));
1403    }
1404
1405    #[test]
1406    fn test_categorize_stellar_error_with_context_xdr_error() {
1407        use soroban_rs::xdr::Error as XdrError;
1408        let err = StellarClientError::Xdr(XdrError::Invalid);
1409        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1410        match result {
1411            ProviderError::Other(msg) => {
1412                assert!(msg.contains("Test operation"));
1413            }
1414            _ => panic!("Expected Other error"),
1415        }
1416    }
1417
1418    #[test]
1419    fn test_categorize_stellar_error_with_context_serde_error() {
1420        // Create a serde error by attempting to deserialize invalid JSON
1421        let json_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
1422        let err = StellarClientError::Serde(json_err);
1423        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1424        match result {
1425            ProviderError::Other(msg) => {
1426                assert!(msg.contains("Test operation"));
1427            }
1428            _ => panic!("Expected Other error"),
1429        }
1430    }
1431
1432    #[test]
1433    fn test_categorize_stellar_error_with_context_url_errors() {
1434        // Test InvalidRpcUrl
1435        let invalid_uri_err: http::uri::InvalidUri =
1436            ":::invalid url".parse::<http::Uri>().unwrap_err();
1437        let err = StellarClientError::InvalidRpcUrl(invalid_uri_err);
1438        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1439        match result {
1440            ProviderError::NetworkConfiguration(msg) => {
1441                assert!(msg.contains("Test operation"));
1442                assert!(msg.contains("Invalid RPC URL"));
1443            }
1444            _ => panic!("Expected NetworkConfiguration error"),
1445        }
1446
1447        // Test InvalidUrl
1448        let err = StellarClientError::InvalidUrl("not a url".to_string());
1449        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1450        match result {
1451            ProviderError::NetworkConfiguration(msg) => {
1452                assert!(msg.contains("Test operation"));
1453                assert!(msg.contains("Invalid URL"));
1454            }
1455            _ => panic!("Expected NetworkConfiguration error"),
1456        }
1457    }
1458
1459    #[test]
1460    fn test_categorize_stellar_error_with_context_network_passphrase() {
1461        let err = StellarClientError::InvalidNetworkPassphrase {
1462            expected: "Expected".to_string(),
1463            server: "Server".to_string(),
1464        };
1465        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1466        match result {
1467            ProviderError::NetworkConfiguration(msg) => {
1468                assert!(msg.contains("Test operation"));
1469                assert!(msg.contains("Expected"));
1470                assert!(msg.contains("Server"));
1471            }
1472            _ => panic!("Expected NetworkConfiguration error"),
1473        }
1474    }
1475
1476    #[test]
1477    fn test_categorize_stellar_error_with_context_json_rpc_call_error() {
1478        // Test that RPC Call errors are properly categorized as RpcErrorCode
1479        // We'll test this indirectly through other error types since creating Call errors
1480        // requires jsonrpsee internals that aren't easily accessible in tests
1481        let err = StellarClientError::TransactionSubmissionTimeout;
1482        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1483        // Verify timeout is properly categorized
1484        assert!(matches!(result, ProviderError::Timeout));
1485    }
1486
1487    #[test]
1488    fn test_categorize_stellar_error_with_context_json_rpc_timeout() {
1489        // Test timeout through TransactionSubmissionTimeout which is simpler to construct
1490        let err = StellarClientError::TransactionSubmissionTimeout;
1491        let result = categorize_stellar_error_with_context(err, None);
1492        assert!(matches!(result, ProviderError::Timeout));
1493    }
1494
1495    #[test]
1496    fn test_categorize_stellar_error_with_context_transport_errors() {
1497        // Test network-related errors through InvalidResponse which is simpler to construct
1498        let err = StellarClientError::InvalidResponse;
1499        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1500        match result {
1501            ProviderError::Other(msg) => {
1502                assert!(msg.contains("Test operation"));
1503                assert!(msg.contains("Invalid response"));
1504            }
1505            _ => panic!("Expected Other error for response issues"),
1506        }
1507    }
1508
1509    #[test]
1510    fn test_categorize_stellar_error_with_context_response_errors() {
1511        // Test InvalidResponse
1512        let err = StellarClientError::InvalidResponse;
1513        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1514        match result {
1515            ProviderError::Other(msg) => {
1516                assert!(msg.contains("Test operation"));
1517                assert!(msg.contains("Invalid response"));
1518            }
1519            _ => panic!("Expected Other error"),
1520        }
1521
1522        // Test MissingResult
1523        let err = StellarClientError::MissingResult;
1524        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1525        match result {
1526            ProviderError::Other(msg) => {
1527                assert!(msg.contains("Test operation"));
1528                assert!(msg.contains("Missing result"));
1529            }
1530            _ => panic!("Expected Other error"),
1531        }
1532    }
1533
1534    #[test]
1535    fn test_categorize_stellar_error_with_context_transaction_errors() {
1536        // Test TransactionFailed
1537        let err = StellarClientError::TransactionFailed("tx failed".to_string());
1538        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1539        match result {
1540            ProviderError::Other(msg) => {
1541                assert!(msg.contains("Test operation"));
1542                assert!(msg.contains("tx failed"));
1543            }
1544            _ => panic!("Expected Other error"),
1545        }
1546
1547        // Test NotFound
1548        let err = StellarClientError::NotFound("Account".to_string(), "123".to_string());
1549        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1550        match result {
1551            ProviderError::Other(msg) => {
1552                assert!(msg.contains("Test operation"));
1553                assert!(msg.contains("Account not found"));
1554                assert!(msg.contains("123"));
1555            }
1556            _ => panic!("Expected Other error"),
1557        }
1558    }
1559
1560    #[test]
1561    fn test_categorize_stellar_error_with_context_validation_errors() {
1562        // Test InvalidCursor
1563        let err = StellarClientError::InvalidCursor;
1564        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1565        match result {
1566            ProviderError::Other(msg) => {
1567                assert!(msg.contains("Test operation"));
1568                assert!(msg.contains("Invalid cursor"));
1569            }
1570            _ => panic!("Expected Other error"),
1571        }
1572
1573        // Test LargeFee
1574        let err = StellarClientError::LargeFee(1000000);
1575        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1576        match result {
1577            ProviderError::Other(msg) => {
1578                assert!(msg.contains("Test operation"));
1579                assert!(msg.contains("1000000"));
1580            }
1581            _ => panic!("Expected Other error"),
1582        }
1583    }
1584
1585    #[test]
1586    fn test_categorize_stellar_error_with_context_no_context() {
1587        // Test with a simpler error type that doesn't have version conflicts
1588        let err = StellarClientError::InvalidResponse;
1589        let result = categorize_stellar_error_with_context(err, None);
1590        match result {
1591            ProviderError::Other(msg) => {
1592                assert!(!msg.contains(":")); // No context prefix
1593                assert!(msg.contains("Invalid response"));
1594            }
1595            _ => panic!("Expected Other error"),
1596        }
1597    }
1598
1599    #[test]
1600    fn test_initialize_provider_invalid_url() {
1601        let _env_guard = setup_test_env();
1602        let provider = StellarProvider::new(
1603            vec![RpcConfig::new("http://localhost:8000".to_string())],
1604            30,
1605        )
1606        .unwrap();
1607
1608        // Test with invalid URL that should fail client creation
1609        let result = provider.initialize_provider("invalid-url");
1610        assert!(result.is_err());
1611        match result.unwrap_err() {
1612            ProviderError::NetworkConfiguration(msg) => {
1613                assert!(msg.contains("Failed to create Stellar RPC client"));
1614            }
1615            _ => panic!("Expected NetworkConfiguration error"),
1616        }
1617    }
1618
1619    #[test]
1620    fn test_initialize_raw_provider_timeout_config() {
1621        let _env_guard = setup_test_env();
1622        let provider = StellarProvider::new(
1623            vec![RpcConfig::new("http://localhost:8000".to_string())],
1624            30,
1625        )
1626        .unwrap();
1627
1628        // Test with valid URL - should succeed
1629        let result = provider.initialize_raw_provider("http://localhost:8000");
1630        assert!(result.is_ok());
1631
1632        // Test with invalid URL for reqwest client - this might not fail immediately
1633        // but we can test that the function doesn't panic
1634        let result = provider.initialize_raw_provider("not-a-url");
1635        // reqwest::Client::builder() may not fail immediately for malformed URLs
1636        // but the function should return a Result
1637        assert!(result.is_ok() || result.is_err());
1638    }
1639
1640    #[tokio::test]
1641    async fn test_raw_request_dyn_success() {
1642        let _env_guard = setup_test_env();
1643
1644        // Create a provider with a mock server URL that won't actually connect
1645        let provider =
1646            StellarProvider::new(vec![RpcConfig::new("http://127.0.0.1:9999".to_string())], 1)
1647                .unwrap();
1648
1649        let params = serde_json::json!({"test": "value"});
1650        let result = provider
1651            .raw_request_dyn("test_method", params, Some(JsonRpcId::Number(1)))
1652            .await;
1653
1654        // Should fail due to connection, but should go through the retry logic
1655        assert!(result.is_err());
1656        let err = result.unwrap_err();
1657        // Should be a network-related error, not a panic
1658        assert!(matches!(
1659            err,
1660            ProviderError::Other(_)
1661                | ProviderError::Timeout
1662                | ProviderError::NetworkConfiguration(_)
1663        ));
1664    }
1665
1666    #[tokio::test]
1667    async fn test_raw_request_dyn_with_auto_generated_id() {
1668        let _env_guard = setup_test_env();
1669
1670        let provider =
1671            StellarProvider::new(vec![RpcConfig::new("http://127.0.0.1:9999".to_string())], 1)
1672                .unwrap();
1673
1674        let params = serde_json::json!({"test": "value"});
1675        let result = provider.raw_request_dyn("test_method", params, None).await;
1676
1677        // Should fail due to connection, but the ID generation should work
1678        assert!(result.is_err());
1679    }
1680
1681    #[tokio::test]
1682    async fn test_retry_raw_request_connection_failure() {
1683        let _env_guard = setup_test_env();
1684
1685        let provider =
1686            StellarProvider::new(vec![RpcConfig::new("http://127.0.0.1:9999".to_string())], 1)
1687                .unwrap();
1688
1689        let request = serde_json::json!({
1690            "jsonrpc": "2.0",
1691            "id": 1,
1692            "method": "test",
1693            "params": {}
1694        });
1695
1696        let result = provider.retry_raw_request("test_operation", request).await;
1697
1698        // Should fail due to connection issues
1699        assert!(result.is_err());
1700        let err = result.unwrap_err();
1701        // Should be categorized as network error
1702        assert!(matches!(
1703            err,
1704            ProviderError::Other(_) | ProviderError::Timeout
1705        ));
1706    }
1707
1708    #[tokio::test]
1709    async fn test_raw_request_dyn_json_rpc_error_response() {
1710        let _env_guard = setup_test_env();
1711
1712        // This test would require mocking the HTTP response, which is complex
1713        // For now, we test that the function exists and can be called
1714        let provider =
1715            StellarProvider::new(vec![RpcConfig::new("http://127.0.0.1:9999".to_string())], 1)
1716                .unwrap();
1717
1718        let params = serde_json::json!({"test": "value"});
1719        let result = provider
1720            .raw_request_dyn(
1721                "test_method",
1722                params,
1723                Some(JsonRpcId::String("test-id".to_string())),
1724            )
1725            .await;
1726
1727        // Should fail due to connection, but should handle the request properly
1728        assert!(result.is_err());
1729    }
1730
1731    #[test]
1732    fn test_provider_creation_edge_cases() {
1733        let _env_guard = setup_test_env();
1734
1735        // Test with empty configs
1736        let result = StellarProvider::new(vec![], 30);
1737        assert!(result.is_err());
1738        match result.unwrap_err() {
1739            ProviderError::NetworkConfiguration(msg) => {
1740                assert!(msg.contains("No RPC configurations provided"));
1741            }
1742            _ => panic!("Expected NetworkConfiguration error"),
1743        }
1744
1745        // Test with configs that have zero weights after filtering
1746        let mut config1 = RpcConfig::new("http://localhost:8000".to_string());
1747        config1.weight = 0;
1748        let mut config2 = RpcConfig::new("http://localhost:8001".to_string());
1749        config2.weight = 0;
1750        let configs = vec![config1, config2];
1751        let result = StellarProvider::new(configs, 30);
1752        assert!(result.is_err());
1753        match result.unwrap_err() {
1754            ProviderError::NetworkConfiguration(msg) => {
1755                assert!(msg.contains("No active RPC configurations"));
1756            }
1757            _ => panic!("Expected NetworkConfiguration error"),
1758        }
1759    }
1760
1761    #[tokio::test]
1762    async fn test_get_events_empty_request() {
1763        let _env_guard = setup_test_env();
1764
1765        let mut mock = MockStellarProviderTrait::new();
1766        mock.expect_get_events()
1767            .withf(|req| req.contract_ids.is_empty() && req.topics.is_empty())
1768            .returning(|_| async { Ok(dummy_get_events_response()) }.boxed());
1769
1770        let req = GetEventsRequest {
1771            start: EventStart::Ledger(1),
1772            event_type: Some(EventType::Contract),
1773            contract_ids: vec![],
1774            topics: vec![],
1775            limit: Some(10),
1776        };
1777
1778        let result = mock.get_events(req).await;
1779        assert!(result.is_ok());
1780    }
1781
1782    #[tokio::test]
1783    async fn test_get_ledger_entries_empty_keys() {
1784        let _env_guard = setup_test_env();
1785
1786        let mut mock = MockStellarProviderTrait::new();
1787        mock.expect_get_ledger_entries()
1788            .withf(|keys| keys.is_empty())
1789            .returning(|_| async { Ok(dummy_get_ledger_entries_response()) }.boxed());
1790
1791        let result = mock.get_ledger_entries(&[]).await;
1792        assert!(result.is_ok());
1793    }
1794
1795    #[tokio::test]
1796    async fn test_send_transaction_polling_success() {
1797        let _env_guard = setup_test_env();
1798
1799        let mut mock = MockStellarProviderTrait::new();
1800        mock.expect_send_transaction_polling()
1801            .returning(|_| async { Ok(dummy_soroban_tx()) }.boxed());
1802
1803        let envelope = dummy_transaction_envelope();
1804        let result = mock.send_transaction_polling(&envelope).await;
1805        assert!(result.is_ok());
1806    }
1807
1808    #[tokio::test]
1809    async fn test_get_transactions_with_pagination() {
1810        let _env_guard = setup_test_env();
1811
1812        let mut mock = MockStellarProviderTrait::new();
1813        mock.expect_get_transactions()
1814            .returning(|_| async { Ok(dummy_get_transactions_response()) }.boxed());
1815
1816        let req = GetTransactionsRequest {
1817            start_ledger: Some(1000),
1818            pagination: None, // Pagination struct may not be available in this version
1819        };
1820
1821        let result = mock.get_transactions(req).await;
1822        assert!(result.is_ok());
1823    }
1824}