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,
13    GetTransactionResponseRaw, GetTransactionsRequest, GetTransactionsResponse,
14    SendTransactionResponse, SimulateTransactionResponse,
15};
16use soroban_rs::xdr::{
17    AccountEntry, ContractId, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp,
18    LedgerKey, Limits, MuxedAccount, Operation, OperationBody, ReadXdr, ScAddress, ScSymbol, ScVal,
19    SequenceNumber, Transaction, TransactionEnvelope, TransactionV1Envelope, Uint256, VecM,
20    WriteXdr,
21};
22#[cfg(test)]
23use soroban_rs::xdr::{AccountId, LedgerKeyAccount, PublicKey};
24use soroban_rs::SorobanTransactionResponse;
25use std::sync::atomic::{AtomicU64, Ordering};
26
27#[cfg(test)]
28use mockall::automock;
29
30use once_cell::sync::Lazy;
31
32use crate::models::{JsonRpcId, RpcConfig};
33use crate::services::client_cache::SyncClientCache;
34use crate::services::provider::is_retriable_error;
35use crate::services::provider::retry::retry_rpc_call;
36use crate::services::provider::rpc_selector::RpcSelector;
37use crate::services::provider::should_mark_provider_failed;
38use crate::services::provider::RetryConfig;
39use crate::services::provider::{ProviderConfig, ProviderError};
40// Reqwest client is used for raw JSON-RPC HTTP requests. Alias to avoid name clash with the
41// soroban `Client` type imported above.
42use crate::utils::validate_safe_url;
43use reqwest::Client as ReqwestClient;
44use std::sync::Arc;
45use std::time::Duration;
46
47/// Generates a unique JSON-RPC request ID.
48///
49/// This function returns a monotonically increasing ID for JSON-RPC requests.
50/// It's thread-safe and guarantees unique IDs across concurrent requests.
51///
52/// # Returns
53///
54/// A unique u64 ID that can be used for JSON-RPC requests
55fn generate_unique_rpc_id() -> u64 {
56    static NEXT_ID: AtomicU64 = AtomicU64::new(1);
57    NEXT_ID.fetch_add(1, Ordering::Relaxed)
58}
59
60/// Cache for soroban_rs Stellar RPC clients, keyed by URL.
61/// Avoids recreating jsonrpsee HTTP clients on every retry attempt.
62static STELLAR_RPC_CLIENT_CACHE: Lazy<SyncClientCache<String, Client>> =
63    Lazy::new(SyncClientCache::new);
64
65/// Categorizes a Stellar client error into an appropriate `ProviderError` variant.
66///
67/// This function analyzes the given error and maps it to a specific `ProviderError` variant:
68/// - Handles StellarClientError variants directly (timeouts, JSON-RPC errors, etc.)
69/// - Extracts reqwest::Error from jsonrpsee Transport errors
70/// - Maps JSON-RPC error codes appropriately
71/// - Distinguishes between retriable network errors and non-retriable validation errors
72/// - Falls back to ProviderError::Other for unknown error types
73/// - Optionally prepends a context message to the error for better debugging
74///
75/// # Arguments
76///
77/// * `err` - The StellarClientError to categorize (takes ownership)
78/// * `context` - Optional context message to prepend (e.g., "Failed to get account")
79///
80/// # Returns
81///
82/// The appropriate `ProviderError` variant based on the error type
83fn categorize_stellar_error_with_context(
84    err: StellarClientError,
85    context: Option<&str>,
86) -> ProviderError {
87    let add_context = |msg: String| -> String {
88        match context {
89            Some(ctx) => format!("{ctx}: {msg}"),
90            None => msg,
91        }
92    };
93    match err {
94        // === Timeout Errors (Retriable) ===
95        StellarClientError::TransactionSubmissionTimeout => ProviderError::Timeout,
96
97        // === Address/Encoding Errors (Non-retriable, Client-side) ===
98        StellarClientError::InvalidAddress(decode_err) => ProviderError::InvalidAddress(
99            add_context(format!("Invalid Stellar address: {decode_err}")),
100        ),
101
102        // === XDR/Serialization Errors (Non-retriable, Client-side) ===
103        StellarClientError::Xdr(xdr_err) => {
104            ProviderError::Other(add_context(format!("XDR processing error: {xdr_err}")))
105        }
106
107        // === JSON Parsing Errors (Non-retriable, may indicate RPC response issue) ===
108        StellarClientError::Serde(serde_err) => {
109            ProviderError::Other(add_context(format!("JSON parsing error: {serde_err}")))
110        }
111
112        // === URL Configuration Errors (Non-retriable, Configuration issue) ===
113        StellarClientError::InvalidRpcUrl(uri_err) => {
114            ProviderError::NetworkConfiguration(add_context(format!("Invalid RPC URL: {uri_err}")))
115        }
116        StellarClientError::InvalidRpcUrlFromUriParts(uri_err) => {
117            ProviderError::NetworkConfiguration(add_context(format!(
118                "Invalid RPC URL parts: {uri_err}"
119            )))
120        }
121        StellarClientError::InvalidUrl(url) => {
122            ProviderError::NetworkConfiguration(add_context(format!("Invalid URL: {url}")))
123        }
124
125        // === Network Passphrase Mismatch (Non-retriable, Configuration issue) ===
126        StellarClientError::InvalidNetworkPassphrase { expected, server } => {
127            ProviderError::NetworkConfiguration(add_context(format!(
128                "Network passphrase mismatch: expected {expected:?}, server returned {server:?}"
129            )))
130        }
131
132        // === JSON-RPC Errors (May be retriable depending on the specific error) ===
133        StellarClientError::JsonRpc(jsonrpsee_err) => {
134            match jsonrpsee_err {
135                // Handle Call errors with error codes
136                jsonrpsee_core::error::Error::Call(err_obj) => {
137                    let code = err_obj.code() as i64;
138                    let message = add_context(err_obj.message().to_string());
139                    ProviderError::RpcErrorCode { code, message }
140                }
141
142                // Handle request timeouts
143                jsonrpsee_core::error::Error::RequestTimeout => ProviderError::Timeout,
144
145                // Handle transport errors (network-level issues)
146                jsonrpsee_core::error::Error::Transport(transport_err) => {
147                    // Check source chain for reqwest errors
148                    let mut source = transport_err.source();
149                    while let Some(s) = source {
150                        if let Some(reqwest_err) = s.downcast_ref::<reqwest::Error>() {
151                            return ProviderError::from(reqwest_err);
152                        }
153                        source = s.source();
154                    }
155
156                    ProviderError::TransportError(add_context(format!(
157                        "Transport error: {transport_err}"
158                    )))
159                }
160                // Catch-all for other jsonrpsee errors
161                other => ProviderError::Other(add_context(format!("JSON-RPC error: {other}"))),
162            }
163        }
164        // === Response Parsing/Validation Errors (May indicate RPC node issue) ===
165        StellarClientError::InvalidResponse => {
166            // This could be a temporary RPC node issue or malformed response
167            ProviderError::Other(add_context(
168                "Invalid response from Stellar RPC server".to_string(),
169            ))
170        }
171        StellarClientError::MissingResult => {
172            ProviderError::Other(add_context("Missing result in RPC response".to_string()))
173        }
174        StellarClientError::MissingError => ProviderError::Other(add_context(
175            "Failed to read error from RPC response".to_string(),
176        )),
177
178        // === Transaction Errors (Non-retriable, Transaction-specific issues) ===
179        StellarClientError::TransactionFailed(msg) => {
180            ProviderError::Other(add_context(format!("Transaction failed: {msg}")))
181        }
182        StellarClientError::TransactionSubmissionFailed(msg) => {
183            ProviderError::Other(add_context(format!("Transaction submission failed: {msg}")))
184        }
185        StellarClientError::TransactionSimulationFailed(msg) => {
186            ProviderError::Other(add_context(format!("Transaction simulation failed: {msg}")))
187        }
188        StellarClientError::UnexpectedTransactionStatus(status) => ProviderError::Other(
189            add_context(format!("Unexpected transaction status: {status}")),
190        ),
191
192        // === Resource Not Found Errors (Non-retriable) ===
193        StellarClientError::NotFound(resource, id) => {
194            ProviderError::Other(add_context(format!("{resource} not found: {id}")))
195        }
196
197        // === Client-side Validation Errors (Non-retriable) ===
198        StellarClientError::InvalidCursor => {
199            ProviderError::Other(add_context("Invalid cursor".to_string()))
200        }
201        StellarClientError::UnexpectedSimulateTransactionResultSize { length } => {
202            ProviderError::Other(add_context(format!(
203                "Unexpected simulate transaction result size: {length}"
204            )))
205        }
206        StellarClientError::UnexpectedOperationCount { count } => {
207            ProviderError::Other(add_context(format!("Unexpected operation count: {count}")))
208        }
209        StellarClientError::UnsupportedOperationType => {
210            ProviderError::Other(add_context("Unsupported operation type".to_string()))
211        }
212        StellarClientError::UnexpectedContractCodeDataType(data) => ProviderError::Other(
213            add_context(format!("Unexpected contract code data type: {data:?}")),
214        ),
215        StellarClientError::UnexpectedContractInstance(val) => ProviderError::Other(add_context(
216            format!("Unexpected contract instance: {val:?}"),
217        )),
218        StellarClientError::LargeFee(fee) => {
219            ProviderError::Other(add_context(format!("Fee too large: {fee}")))
220        }
221        StellarClientError::CannotAuthorizeRawTransaction => {
222            ProviderError::Other(add_context("Cannot authorize raw transaction".to_string()))
223        }
224        StellarClientError::MissingOp => {
225            ProviderError::Other(add_context("Missing operation in transaction".to_string()))
226        }
227        StellarClientError::MissingSignerForAddress { address } => ProviderError::Other(
228            add_context(format!("Missing signer for address: {address}")),
229        ),
230
231        // === Deprecated/Other Errors ===
232        #[allow(deprecated)]
233        StellarClientError::UnexpectedToken(entry) => {
234            ProviderError::Other(add_context(format!("Unexpected token: {entry:?}")))
235        }
236    }
237}
238
239/// Normalize a URL for logging by removing query strings, fragments and redacting userinfo.
240///
241/// Examples:
242/// - https://user:secret@api.example.com/path?api_key=XXX -> https://<redacted>@api.example.com/path
243/// - https://api.example.com/path?api_key=XXX -> https://api.example.com/path
244fn normalize_url_for_log(url: &str) -> String {
245    // Remove query and fragment first
246    let mut s = url.to_string();
247    if let Some(q) = s.find('?') {
248        s.truncate(q);
249    }
250    if let Some(h) = s.find('#') {
251        s.truncate(h);
252    }
253
254    // Redact userinfo if present (scheme://userinfo@host...)
255    if let Some(scheme_pos) = s.find("://") {
256        let start = scheme_pos + 3;
257        if let Some(at_pos) = s[start..].find('@') {
258            let after = &s[start + at_pos + 1..];
259            let prefix = &s[..start];
260            s = format!("{prefix}<redacted>@{after}");
261        }
262    }
263
264    s
265}
266#[derive(Debug, Clone)]
267pub struct GetEventsRequest {
268    pub start: EventStart,
269    pub event_type: Option<EventType>,
270    pub contract_ids: Vec<String>,
271    pub topics: Vec<Vec<String>>,
272    pub limit: Option<usize>,
273}
274
275#[derive(Clone, Debug)]
276pub struct StellarProvider {
277    /// RPC selector for managing and selecting providers
278    selector: RpcSelector,
279    /// Timeout in seconds for RPC calls
280    timeout_seconds: Duration,
281    /// Configuration for retry behavior
282    retry_config: RetryConfig,
283}
284
285#[async_trait]
286#[cfg_attr(test, automock)]
287#[allow(dead_code)]
288pub trait StellarProviderTrait: Send + Sync {
289    fn get_configs(&self) -> Vec<RpcConfig>;
290    async fn get_account(&self, account_id: &str) -> Result<AccountEntry, ProviderError>;
291    async fn simulate_transaction_envelope(
292        &self,
293        tx_envelope: &TransactionEnvelope,
294    ) -> Result<SimulateTransactionResponse, ProviderError>;
295    async fn send_transaction_polling(
296        &self,
297        tx_envelope: &TransactionEnvelope,
298    ) -> Result<SorobanTransactionResponse, ProviderError>;
299    async fn get_network(&self) -> Result<GetNetworkResponse, ProviderError>;
300    async fn get_latest_ledger(&self) -> Result<GetLatestLedgerResponse, ProviderError>;
301    async fn send_transaction(
302        &self,
303        tx_envelope: &TransactionEnvelope,
304    ) -> Result<Hash, ProviderError>;
305    /// Sends a transaction and returns the full response including the status field.
306    ///
307    /// # Why this method exists
308    ///
309    /// The `stellar-rpc-client` crate's `send_transaction` method only returns
310    /// `Result<Hash, Error>` and discards the status field for non-ERROR responses.
311    /// This means TRY_AGAIN_LATER is silently treated as success, which is problematic
312    /// for relayers that need to track transaction states precisely.
313    ///
314    /// This method calls the `sendTransaction` RPC directly to get the full
315    /// `SendTransactionResponse` including the status field:
316    /// - "PENDING": Transaction accepted for processing
317    /// - "DUPLICATE": Transaction already submitted
318    /// - "TRY_AGAIN_LATER": Transaction NOT queued (e.g., another tx from same account
319    ///   in mempool, fee too low and resubmitted too soon, or resource limits exceeded)
320    /// - "ERROR": Transaction validation failed
321    async fn send_transaction_with_status(
322        &self,
323        tx_envelope: &TransactionEnvelope,
324    ) -> Result<SendTransactionResponse, ProviderError>;
325    async fn get_transaction(&self, tx_id: &Hash) -> Result<GetTransactionResponse, ProviderError>;
326    async fn get_transactions(
327        &self,
328        request: GetTransactionsRequest,
329    ) -> Result<GetTransactionsResponse, ProviderError>;
330    async fn get_ledger_entries(
331        &self,
332        keys: &[LedgerKey],
333    ) -> Result<GetLedgerEntriesResponse, ProviderError>;
334    async fn get_events(
335        &self,
336        request: GetEventsRequest,
337    ) -> Result<GetEventsResponse, ProviderError>;
338    async fn raw_request_dyn(
339        &self,
340        method: &str,
341        params: serde_json::Value,
342        id: Option<JsonRpcId>,
343    ) -> Result<serde_json::Value, ProviderError>;
344    /// Calls a contract function (read-only, via simulation).
345    ///
346    /// This method invokes a Soroban contract function without submitting a transaction.
347    /// It uses simulation to execute the function and return the result.
348    ///
349    /// # Arguments
350    /// * `contract_address` - The contract address in StrKey format
351    /// * `function_name` - The function name as an ScSymbol
352    /// * `args` - Function arguments as ScVal vector
353    ///
354    /// # Returns
355    /// The function result as an ScVal, or an error if the call fails
356    async fn call_contract(
357        &self,
358        contract_address: &str,
359        function_name: &ScSymbol,
360        args: Vec<ScVal>,
361    ) -> Result<ScVal, ProviderError>;
362}
363
364impl StellarProvider {
365    // Create new StellarProvider instance
366    pub fn new(config: ProviderConfig) -> Result<Self, ProviderError> {
367        if config.rpc_configs.is_empty() {
368            return Err(ProviderError::NetworkConfiguration(
369                "No RPC configurations provided for StellarProvider".to_string(),
370            ));
371        }
372
373        RpcConfig::validate_list(&config.rpc_configs)
374            .map_err(|e| ProviderError::NetworkConfiguration(e.to_string()))?;
375
376        let mut rpc_configs = config.rpc_configs;
377        rpc_configs.retain(|config| config.get_weight() > 0);
378
379        if rpc_configs.is_empty() {
380            return Err(ProviderError::NetworkConfiguration(
381                "No active RPC configurations provided (all weights are 0 or list was empty after filtering)".to_string(),
382            ));
383        }
384
385        let selector = RpcSelector::new(
386            rpc_configs,
387            config.failure_threshold,
388            config.pause_duration_secs,
389            config.failure_expiration_secs,
390        )
391        .map_err(|e| {
392            ProviderError::NetworkConfiguration(format!("Failed to create RPC selector: {e}"))
393        })?;
394
395        let retry_config = RetryConfig::from_env();
396
397        Ok(Self {
398            selector,
399            timeout_seconds: Duration::from_secs(config.timeout_seconds),
400            retry_config,
401        })
402    }
403
404    /// Gets the current RPC configurations.
405    ///
406    /// # Returns
407    /// * `Vec<RpcConfig>` - The current configurations
408    pub fn get_configs(&self) -> Vec<RpcConfig> {
409        self.selector.get_configs()
410    }
411
412    /// Get or create a cached Stellar RPC client for a given URL.
413    /// Reuses clients across retry attempts and provider instances.
414    fn initialize_provider(&self, url: &str) -> Result<Arc<Client>, ProviderError> {
415        let allowed_hosts = crate::config::ServerConfig::get_rpc_allowed_hosts();
416        let block_private_ips = crate::config::ServerConfig::get_rpc_block_private_ips();
417        validate_safe_url(url, &allowed_hosts, block_private_ips).map_err(|e| {
418            ProviderError::NetworkConfiguration(format!("RPC URL security validation failed: {e}"))
419        })?;
420
421        STELLAR_RPC_CLIENT_CACHE.get_or_try_init(url.to_string(), || {
422            Client::new(url).map_err(|e| {
423                let safe_url = crate::utils::mask_url(url);
424                ProviderError::NetworkConfiguration(format!(
425                    "Failed to create Stellar RPC client: {e} - URL: '{safe_url}'"
426                ))
427            })
428        })
429    }
430
431    /// Get the shared reqwest client for raw HTTP JSON-RPC calls, after
432    /// validating the URL as an SSRF safety net.
433    fn initialize_raw_provider(&self, url: &str) -> Result<ReqwestClient, ProviderError> {
434        let allowed_hosts = crate::config::ServerConfig::get_rpc_allowed_hosts();
435        let block_private_ips = crate::config::ServerConfig::get_rpc_block_private_ips();
436        validate_safe_url(url, &allowed_hosts, block_private_ips).map_err(|e| {
437            ProviderError::NetworkConfiguration(format!("RPC URL security validation failed: {e}"))
438        })?;
439
440        super::get_shared_rpc_http_client()
441    }
442
443    /// Helper method to retry RPC calls with exponential backoff
444    async fn retry_rpc_call<T, F, Fut>(
445        &self,
446        operation_name: &str,
447        operation: F,
448    ) -> Result<T, ProviderError>
449    where
450        F: Fn(Arc<Client>) -> Fut,
451        Fut: std::future::Future<Output = Result<T, ProviderError>>,
452    {
453        let provider_url_raw = match self.selector.get_current_url() {
454            Ok(url) => url,
455            Err(e) => {
456                return Err(ProviderError::NetworkConfiguration(format!(
457                    "No RPC URL available for StellarProvider: {e}"
458                )));
459            }
460        };
461        let provider_url = normalize_url_for_log(&provider_url_raw);
462
463        tracing::debug!(
464            "Starting Stellar RPC operation '{}' with timeout: {}s, provider_url: {}",
465            operation_name,
466            self.timeout_seconds.as_secs(),
467            provider_url
468        );
469
470        retry_rpc_call(
471            &self.selector,
472            operation_name,
473            is_retriable_error,
474            should_mark_provider_failed,
475            |url| self.initialize_provider(url),
476            operation,
477            Some(self.retry_config.clone()),
478        )
479        .await
480    }
481
482    /// Retry helper for raw JSON-RPC requests
483    async fn retry_raw_request(
484        &self,
485        operation_name: &str,
486        request: serde_json::Value,
487    ) -> Result<serde_json::Value, ProviderError> {
488        let provider_url_raw = match self.selector.get_current_url() {
489            Ok(url) => url,
490            Err(e) => {
491                return Err(ProviderError::NetworkConfiguration(format!(
492                    "No RPC URL available for StellarProvider: {e}"
493                )));
494            }
495        };
496        let provider_url = normalize_url_for_log(&provider_url_raw);
497
498        tracing::debug!(
499            "Starting raw RPC operation '{}' with timeout: {}s, provider_url: {}",
500            operation_name,
501            self.timeout_seconds.as_secs(),
502            provider_url
503        );
504
505        let request_clone = request.clone();
506        retry_rpc_call(
507            &self.selector,
508            operation_name,
509            is_retriable_error,
510            should_mark_provider_failed,
511            |url| {
512                // Initialize an HTTP client for this URL and return it together with the URL string
513                self.initialize_raw_provider(url)
514                    .map(|client| (url.to_string(), client))
515            },
516            |(url, client): (String, ReqwestClient)| {
517                let request_for_call = request_clone.clone();
518                async move {
519                    let response = client
520                        .post(&url)
521                        .json(&request_for_call)
522                        // Keep a per-request timeout as a safeguard (client also has a default timeout)
523                        .timeout(self.timeout_seconds)
524                        .send()
525                        .await
526                        .map_err(ProviderError::from)?;
527
528                    // Surface HTTP-level errors (429/5xx/...) with their status code so
529                    // `is_retriable_error` and `should_mark_provider_failed` classify them,
530                    // instead of losing the status as a JSON decode error on non-JSON bodies.
531                    // Read the body on failure so the provider's own error detail is
532                    // preserved for diagnosis rather than dropped by `error_for_status`.
533                    let status = response.status();
534                    if !status.is_success() {
535                        let body = response.text().await.unwrap_or_default();
536                        return Err(http_status_error_to_provider_error(status, body));
537                    }
538
539                    let json_response: serde_json::Value =
540                        response.json().await.map_err(ProviderError::from)?;
541
542                    // Map JSON-RPC error objects inside the retried operation so
543                    // retriable codes (e.g. -32005 rate limited) can retry/fail over.
544                    // Some non-strict providers send `"error": null` on success, so
545                    // skip null to avoid misclassifying a valid response as an error.
546                    if let Some(error) = json_response.get("error").filter(|e| !e.is_null()) {
547                        return Err(json_rpc_error_to_provider_error(error));
548                    }
549
550                    Ok(json_response)
551                }
552            },
553            Some(self.retry_config.clone()),
554        )
555        .await
556    }
557
558    /// Executes a JSON-RPC request over the raw reqwest HTTP path and returns the
559    /// `result` field. HTTP status and JSON-RPC error objects are mapped to
560    /// `ProviderError` inside `retry_raw_request`, where they participate in
561    /// retry/failover.
562    ///
563    /// Unlike the `stellar-rpc-client` (jsonrpsee) client, the raw reqwest path
564    /// negotiates zstd response compression (`Accept-Encoding: zstd`), which
565    /// providers like QuickNode honor — significantly shrinking large responses.
566    async fn raw_json_rpc_request(
567        &self,
568        operation_name: &str,
569        method: &str,
570        params: serde_json::Value,
571        id: Option<JsonRpcId>,
572    ) -> Result<serde_json::Value, ProviderError> {
573        let id_value = match id {
574            Some(id) => serde_json::to_value(id)
575                .map_err(|e| ProviderError::Other(format!("Failed to serialize id: {e}")))?,
576            None => serde_json::json!(generate_unique_rpc_id()),
577        };
578
579        let request = serde_json::json!({
580            "jsonrpc": "2.0",
581            "id": id_value,
582            "method": method,
583            "params": params,
584        });
585
586        let response = self.retry_raw_request(operation_name, request).await?;
587
588        // Extract result
589        response
590            .get("result")
591            .cloned()
592            .ok_or_else(|| ProviderError::Other("No result field in JSON-RPC response".to_string()))
593    }
594}
595
596/// Maps a non-success HTTP status and its response body to a `ProviderError`,
597/// keeping the status-based classification used by `is_retriable_error` and
598/// `should_mark_provider_failed` while preserving the provider's body text for
599/// diagnosis. The body is decompressed transparently when zstd-encoded.
600fn http_status_error_to_provider_error(status: reqwest::StatusCode, body: String) -> ProviderError {
601    match status.as_u16() {
602        429 => ProviderError::RateLimited,
603        502 => ProviderError::BadGateway,
604        code => {
605            let detail = if body.trim().is_empty() {
606                status.to_string()
607            } else {
608                body
609            };
610            ProviderError::RequestError {
611                error: detail,
612                status_code: code,
613            }
614        }
615    }
616}
617
618/// Maps a JSON-RPC `error` object to a `ProviderError`, preserving the error
619/// code so `is_retriable_error` can classify it.
620fn json_rpc_error_to_provider_error(error: &serde_json::Value) -> ProviderError {
621    if let Some(code) = error.get("code").and_then(|c| c.as_i64()) {
622        return ProviderError::RpcErrorCode {
623            code,
624            message: error
625                .get("message")
626                .and_then(|m| m.as_str())
627                .unwrap_or("Unknown error")
628                .to_string(),
629        };
630    }
631    ProviderError::Other(format!("JSON-RPC error: {error}"))
632}
633
634#[async_trait]
635impl StellarProviderTrait for StellarProvider {
636    fn get_configs(&self) -> Vec<RpcConfig> {
637        self.get_configs()
638    }
639
640    async fn get_account(&self, account_id: &str) -> Result<AccountEntry, ProviderError> {
641        let account_id = Arc::new(account_id.to_string());
642
643        self.retry_rpc_call("get_account", move |client| {
644            let account_id = Arc::clone(&account_id);
645            async move {
646                client.get_account(&account_id).await.map_err(|e| {
647                    categorize_stellar_error_with_context(e, Some("Failed to get account"))
648                })
649            }
650        })
651        .await
652    }
653
654    async fn simulate_transaction_envelope(
655        &self,
656        tx_envelope: &TransactionEnvelope,
657    ) -> Result<SimulateTransactionResponse, ProviderError> {
658        let tx_envelope = Arc::new(tx_envelope.clone());
659
660        self.retry_rpc_call("simulate_transaction_envelope", move |client| {
661            let tx_envelope = Arc::clone(&tx_envelope);
662            async move {
663                client
664                    .simulate_transaction_envelope(&tx_envelope, None)
665                    .await
666                    .map_err(|e| {
667                        categorize_stellar_error_with_context(
668                            e,
669                            Some("Failed to simulate transaction"),
670                        )
671                    })
672            }
673        })
674        .await
675    }
676
677    async fn send_transaction_polling(
678        &self,
679        tx_envelope: &TransactionEnvelope,
680    ) -> Result<SorobanTransactionResponse, ProviderError> {
681        let tx_envelope = Arc::new(tx_envelope.clone());
682
683        self.retry_rpc_call("send_transaction_polling", move |client| {
684            let tx_envelope = Arc::clone(&tx_envelope);
685            async move {
686                client
687                    .send_transaction_polling(&tx_envelope)
688                    .await
689                    .map(SorobanTransactionResponse::from)
690                    .map_err(|e| {
691                        categorize_stellar_error_with_context(
692                            e,
693                            Some("Failed to send transaction (polling)"),
694                        )
695                    })
696            }
697        })
698        .await
699    }
700
701    async fn get_network(&self) -> Result<GetNetworkResponse, ProviderError> {
702        self.retry_rpc_call("get_network", |client| async move {
703            client.get_network().await.map_err(|e| {
704                categorize_stellar_error_with_context(e, Some("Failed to get network"))
705            })
706        })
707        .await
708    }
709
710    async fn get_latest_ledger(&self) -> Result<GetLatestLedgerResponse, ProviderError> {
711        self.retry_rpc_call("get_latest_ledger", |client| async move {
712            client.get_latest_ledger().await.map_err(|e| {
713                categorize_stellar_error_with_context(e, Some("Failed to get latest ledger"))
714            })
715        })
716        .await
717    }
718
719    async fn send_transaction(
720        &self,
721        tx_envelope: &TransactionEnvelope,
722    ) -> Result<Hash, ProviderError> {
723        let tx_envelope = Arc::new(tx_envelope.clone());
724
725        self.retry_rpc_call("send_transaction", move |client| {
726            let tx_envelope = Arc::clone(&tx_envelope);
727            async move {
728                client.send_transaction(&tx_envelope).await.map_err(|e| {
729                    categorize_stellar_error_with_context(e, Some("Failed to send transaction"))
730                })
731            }
732        })
733        .await
734    }
735
736    async fn send_transaction_with_status(
737        &self,
738        tx_envelope: &TransactionEnvelope,
739    ) -> Result<SendTransactionResponse, ProviderError> {
740        // Encode the transaction envelope to XDR base64
741        let tx_xdr = tx_envelope
742            .to_xdr_base64(Limits::none())
743            .map_err(|e| ProviderError::Other(format!("Failed to encode transaction XDR: {e}")))?;
744
745        // Call sendTransaction RPC method directly to get the full response
746        let params = serde_json::json!({
747            "transaction": tx_xdr
748        });
749
750        let result = self
751            .raw_request_dyn("sendTransaction", params, None)
752            .await?;
753
754        // Deserialize the response
755        serde_json::from_value(result).map_err(|e| {
756            ProviderError::Other(format!(
757                "Failed to deserialize SendTransactionResponse: {e}"
758            ))
759        })
760    }
761
762    /// Fetches a transaction via the raw reqwest path rather than the
763    /// `stellar-rpc-client` (jsonrpsee) client: `getTransaction` responses carry
764    /// envelope/result/meta XDR that can reach hundreds of KB for Soroban
765    /// transactions, and jsonrpsee cannot decompress responses; the raw path
766    /// negotiates zstd compression.
767    async fn get_transaction(&self, tx_id: &Hash) -> Result<GetTransactionResponse, ProviderError> {
768        let params = serde_json::json!({ "hash": tx_id });
769
770        let result = self
771            .raw_json_rpc_request("get_transaction", "getTransaction", params, None)
772            .await?;
773
774        let raw: GetTransactionResponseRaw = serde_json::from_value(result).map_err(|e| {
775            ProviderError::Other(format!("Failed to deserialize GetTransactionResponse: {e}"))
776        })?;
777
778        raw.try_into().map_err(|e: soroban_rs::xdr::Error| {
779            ProviderError::Other(format!("Failed to decode getTransaction XDR: {e}"))
780        })
781    }
782
783    async fn get_transactions(
784        &self,
785        request: GetTransactionsRequest,
786    ) -> Result<GetTransactionsResponse, ProviderError> {
787        let request = Arc::new(request);
788
789        self.retry_rpc_call("get_transactions", move |client| {
790            let request = Arc::clone(&request);
791            async move {
792                client
793                    .get_transactions((*request).clone())
794                    .await
795                    .map_err(|e| {
796                        categorize_stellar_error_with_context(e, Some("Failed to get transactions"))
797                    })
798            }
799        })
800        .await
801    }
802
803    async fn get_ledger_entries(
804        &self,
805        keys: &[LedgerKey],
806    ) -> Result<GetLedgerEntriesResponse, ProviderError> {
807        let keys = Arc::new(keys.to_vec());
808
809        self.retry_rpc_call("get_ledger_entries", move |client| {
810            let keys = Arc::clone(&keys);
811            async move {
812                client.get_ledger_entries(&keys).await.map_err(|e| {
813                    categorize_stellar_error_with_context(e, Some("Failed to get ledger entries"))
814                })
815            }
816        })
817        .await
818    }
819
820    async fn get_events(
821        &self,
822        request: GetEventsRequest,
823    ) -> Result<GetEventsResponse, ProviderError> {
824        let request = Arc::new(request);
825
826        self.retry_rpc_call("get_events", move |client| {
827            let request = Arc::clone(&request);
828            async move {
829                client
830                    .get_events(
831                        request.start.clone(),
832                        request.event_type,
833                        &request.contract_ids,
834                        &request.topics,
835                        request.limit,
836                    )
837                    .await
838                    .map_err(|e| {
839                        categorize_stellar_error_with_context(e, Some("Failed to get events"))
840                    })
841            }
842        })
843        .await
844    }
845
846    async fn raw_request_dyn(
847        &self,
848        method: &str,
849        params: serde_json::Value,
850        id: Option<JsonRpcId>,
851    ) -> Result<serde_json::Value, ProviderError> {
852        self.raw_json_rpc_request("raw_request_dyn", method, params, id)
853            .await
854    }
855
856    async fn call_contract(
857        &self,
858        contract_address: &str,
859        function_name: &ScSymbol,
860        args: Vec<ScVal>,
861    ) -> Result<ScVal, ProviderError> {
862        // Parse contract address
863        let contract = stellar_strkey::Contract::from_string(contract_address)
864            .map_err(|e| ProviderError::Other(format!("Invalid contract address: {e}")))?;
865        let contract_addr = ScAddress::Contract(ContractId(Hash(contract.0)));
866
867        // Convert args to VecM
868        let args_vec = VecM::try_from(args)
869            .map_err(|e| ProviderError::Other(format!("Failed to convert arguments: {e:?}")))?;
870
871        // Build InvokeHostFunction operation
872        let host_function = HostFunction::InvokeContract(InvokeContractArgs {
873            contract_address: contract_addr,
874            function_name: function_name.clone(),
875            args: args_vec,
876        });
877
878        let operation = Operation {
879            source_account: None,
880            body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
881                host_function,
882                auth: VecM::try_from(vec![]).unwrap(),
883            }),
884        };
885
886        // Build a minimal transaction envelope for simulation
887        //
888        // Why simulation instead of direct reads?
889        // In Soroban, contract functions (even read-only ones like decimals()) must be invoked
890        // through the transaction system. Simulation is the standard way to call read-only
891        // functions because it:
892        // 1. Executes the contract function without submitting to the ledger (no fees, no state changes)
893        // 2. Returns the computed result immediately
894        // 3. Works for functions that compute values (not just storage reads)
895        //
896        // Direct storage reads (get_ledger_entries) only work if the value is stored in contract
897        // data storage. For functions that compute values, simulation is required.
898        //
899        // Use a dummy account - simulation doesn't require a real account or signature
900        let dummy_account = MuxedAccount::Ed25519(Uint256([0u8; 32]));
901        let operations: VecM<Operation, 100> = vec![operation].try_into().map_err(|e| {
902            ProviderError::Other(format!("Failed to create operations vector: {e:?}"))
903        })?;
904
905        let tx = Transaction {
906            source_account: dummy_account,
907            fee: 100,
908            seq_num: SequenceNumber(0),
909            cond: soroban_rs::xdr::Preconditions::None,
910            memo: soroban_rs::xdr::Memo::None,
911            operations,
912            ext: soroban_rs::xdr::TransactionExt::V0,
913        };
914
915        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
916            tx,
917            signatures: VecM::try_from(vec![]).unwrap(),
918        });
919
920        // Simulate the transaction to get the result (read-only execution, no ledger submission)
921        let sim_response = self.simulate_transaction_envelope(&envelope).await?;
922
923        // Check for simulation errors
924        if let Some(error) = sim_response.error {
925            return Err(ProviderError::Other(format!(
926                "Contract invocation simulation failed: {error}",
927            )));
928        }
929
930        // Extract result from simulation response
931        if sim_response.results.is_empty() {
932            return Err(ProviderError::Other(
933                "Simulation returned no results".to_string(),
934            ));
935        }
936
937        // Parse the XDR result as ScVal
938        let result_xdr = &sim_response.results[0].xdr;
939        ScVal::from_xdr_base64(result_xdr, Limits::none()).map_err(|e| {
940            ProviderError::Other(format!("Failed to parse simulation result XDR: {e}"))
941        })
942    }
943}
944
945#[cfg(test)]
946mod stellar_rpc_tests {
947    use super::*;
948    use crate::services::provider::stellar::{
949        GetEventsRequest, StellarProvider, StellarProviderTrait,
950    };
951    use futures::FutureExt;
952    use lazy_static::lazy_static;
953    use mockall::predicate as p;
954    use soroban_rs::stellar_rpc_client::{
955        EventStart, GetEventsResponse, GetLatestLedgerResponse, GetLedgerEntriesResponse,
956        GetNetworkResponse, GetTransactionEvents, GetTransactionResponse, GetTransactionsRequest,
957        GetTransactionsResponse, SimulateTransactionResponse,
958    };
959    use soroban_rs::xdr::{
960        AccountEntryExt, Hash, LedgerKey, OperationResult, String32, Thresholds,
961        TransactionEnvelope, TransactionResult, TransactionResultExt, TransactionResultResult,
962        VecM,
963    };
964    use soroban_rs::{create_mock_set_options_tx_envelope, SorobanTransactionResponse};
965    use std::str::FromStr;
966    use std::sync::Mutex;
967
968    lazy_static! {
969        static ref STELLAR_TEST_ENV_MUTEX: Mutex<()> = Mutex::new(());
970    }
971
972    struct StellarTestEnvGuard {
973        _mutex_guard: std::sync::MutexGuard<'static, ()>,
974    }
975
976    impl StellarTestEnvGuard {
977        fn new(mutex_guard: std::sync::MutexGuard<'static, ()>) -> Self {
978            std::env::set_var(
979                "API_KEY",
980                "test_api_key_for_evm_provider_new_this_is_long_enough_32_chars",
981            );
982            std::env::set_var("REDIS_URL", "redis://test-dummy-url-for-evm-provider");
983            // Set minimal retry config to avoid excessive retries and TCP exhaustion in concurrent tests
984            std::env::set_var("PROVIDER_MAX_RETRIES", "1");
985            std::env::set_var("PROVIDER_MAX_FAILOVERS", "0");
986            std::env::set_var("PROVIDER_RETRY_BASE_DELAY_MS", "0");
987            std::env::set_var("PROVIDER_RETRY_MAX_DELAY_MS", "0");
988
989            Self {
990                _mutex_guard: mutex_guard,
991            }
992        }
993    }
994
995    impl Drop for StellarTestEnvGuard {
996        fn drop(&mut self) {
997            std::env::remove_var("API_KEY");
998            std::env::remove_var("REDIS_URL");
999            std::env::remove_var("PROVIDER_MAX_RETRIES");
1000            std::env::remove_var("PROVIDER_MAX_FAILOVERS");
1001            std::env::remove_var("PROVIDER_RETRY_BASE_DELAY_MS");
1002            std::env::remove_var("PROVIDER_RETRY_MAX_DELAY_MS");
1003        }
1004    }
1005
1006    // Helper function to set up the test environment
1007    fn setup_test_env() -> StellarTestEnvGuard {
1008        let guard = STELLAR_TEST_ENV_MUTEX
1009            .lock()
1010            .unwrap_or_else(|e| e.into_inner());
1011        StellarTestEnvGuard::new(guard)
1012    }
1013
1014    fn dummy_hash() -> Hash {
1015        Hash([0u8; 32])
1016    }
1017
1018    fn dummy_get_network_response() -> GetNetworkResponse {
1019        GetNetworkResponse {
1020            friendbot_url: Some("https://friendbot.testnet.stellar.org/".into()),
1021            passphrase: "Test SDF Network ; September 2015".into(),
1022            protocol_version: 20,
1023        }
1024    }
1025
1026    fn dummy_get_latest_ledger_response() -> GetLatestLedgerResponse {
1027        GetLatestLedgerResponse {
1028            id: "c73c5eac58a441d4eb733c35253ae85f783e018f7be5ef974258fed067aabb36".into(),
1029            protocol_version: 20,
1030            sequence: 2_539_605,
1031        }
1032    }
1033
1034    fn dummy_simulate() -> SimulateTransactionResponse {
1035        SimulateTransactionResponse {
1036            min_resource_fee: 100,
1037            transaction_data: "test".to_string(),
1038            ..Default::default()
1039        }
1040    }
1041
1042    fn create_success_tx_result() -> TransactionResult {
1043        // Create empty operation results
1044        let empty_vec: Vec<OperationResult> = Vec::new();
1045        let op_results = empty_vec.try_into().unwrap_or_default();
1046
1047        TransactionResult {
1048            fee_charged: 100,
1049            result: TransactionResultResult::TxSuccess(op_results),
1050            ext: TransactionResultExt::V0,
1051        }
1052    }
1053
1054    fn dummy_get_transaction_response() -> GetTransactionResponse {
1055        GetTransactionResponse {
1056            status: "SUCCESS".to_string(),
1057            envelope: None,
1058            result: Some(create_success_tx_result()),
1059            result_meta: None,
1060            events: GetTransactionEvents {
1061                contract_events: vec![],
1062                diagnostic_events: vec![],
1063                transaction_events: vec![],
1064            },
1065            ledger: None,
1066        }
1067    }
1068
1069    fn dummy_soroban_tx() -> SorobanTransactionResponse {
1070        SorobanTransactionResponse {
1071            response: dummy_get_transaction_response(),
1072        }
1073    }
1074
1075    fn dummy_get_transactions_response() -> GetTransactionsResponse {
1076        GetTransactionsResponse {
1077            transactions: vec![],
1078            latest_ledger: 0,
1079            latest_ledger_close_time: 0,
1080            oldest_ledger: 0,
1081            oldest_ledger_close_time: 0,
1082            cursor: 0,
1083        }
1084    }
1085
1086    fn dummy_get_ledger_entries_response() -> GetLedgerEntriesResponse {
1087        GetLedgerEntriesResponse {
1088            entries: None,
1089            latest_ledger: 0,
1090        }
1091    }
1092
1093    fn dummy_get_events_response() -> GetEventsResponse {
1094        GetEventsResponse {
1095            events: vec![],
1096            latest_ledger: 0,
1097            latest_ledger_close_time: "0".to_string(),
1098            oldest_ledger: 0,
1099            oldest_ledger_close_time: "0".to_string(),
1100            cursor: "0".to_string(),
1101        }
1102    }
1103
1104    fn dummy_transaction_envelope() -> TransactionEnvelope {
1105        create_mock_set_options_tx_envelope()
1106    }
1107
1108    fn dummy_ledger_key() -> LedgerKey {
1109        LedgerKey::Account(LedgerKeyAccount {
1110            account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
1111        })
1112    }
1113
1114    pub fn mock_account_entry(account_id: &str) -> AccountEntry {
1115        AccountEntry {
1116            account_id: AccountId(PublicKey::from_str(account_id).unwrap()),
1117            balance: 0,
1118            ext: AccountEntryExt::V0,
1119            flags: 0,
1120            home_domain: String32::default(),
1121            inflation_dest: None,
1122            seq_num: 0.into(),
1123            num_sub_entries: 0,
1124            signers: VecM::default(),
1125            thresholds: Thresholds([0, 0, 0, 0]),
1126        }
1127    }
1128
1129    fn dummy_account_entry() -> AccountEntry {
1130        mock_account_entry("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF")
1131    }
1132
1133    // ---------------------------------------------------------------------
1134    // Tests
1135    // ---------------------------------------------------------------------
1136
1137    fn create_test_provider_config(configs: Vec<RpcConfig>, timeout: u64) -> ProviderConfig {
1138        ProviderConfig::new(configs, timeout, 3, 60, 60)
1139    }
1140
1141    #[test]
1142    fn test_new_provider() {
1143        let _env_guard = setup_test_env();
1144
1145        let provider = StellarProvider::new(create_test_provider_config(
1146            vec![RpcConfig::new("http://localhost:8000".to_string())],
1147            0,
1148        ));
1149        assert!(provider.is_ok());
1150
1151        let provider_err = StellarProvider::new(create_test_provider_config(vec![], 0));
1152        assert!(provider_err.is_err());
1153        match provider_err.unwrap_err() {
1154            ProviderError::NetworkConfiguration(msg) => {
1155                assert!(msg.contains("No RPC configurations provided"));
1156            }
1157            _ => panic!("Unexpected error type"),
1158        }
1159    }
1160
1161    #[test]
1162    fn test_new_provider_selects_highest_weight() {
1163        let _env_guard = setup_test_env();
1164
1165        let configs = vec![
1166            RpcConfig::with_weight("http://rpc1.example.com".to_string(), 10).unwrap(),
1167            RpcConfig::with_weight("http://rpc2.example.com".to_string(), 100).unwrap(), // Highest weight
1168            RpcConfig::with_weight("http://rpc3.example.com".to_string(), 50).unwrap(),
1169        ];
1170        let provider = StellarProvider::new(create_test_provider_config(configs, 0));
1171        assert!(provider.is_ok());
1172        // We can't directly inspect the client's URL easily without more complex mocking or changes.
1173        // For now, we trust the sorting logic and that Client::new would fail for a truly bad URL if selection was wrong.
1174        // A more robust test would involve a mock client or a way to inspect the chosen URL.
1175    }
1176
1177    #[test]
1178    fn test_new_provider_ignores_weight_zero() {
1179        let _env_guard = setup_test_env();
1180
1181        let configs = vec![
1182            RpcConfig::with_weight("http://rpc1.example.com".to_string(), 0).unwrap(), // Weight 0
1183            RpcConfig::with_weight("http://rpc2.example.com".to_string(), 100).unwrap(), // Should be selected
1184        ];
1185        let provider = StellarProvider::new(create_test_provider_config(configs, 0));
1186        assert!(provider.is_ok());
1187
1188        let configs_only_zero =
1189            vec![RpcConfig::with_weight("http://rpc1.example.com".to_string(), 0).unwrap()];
1190        let provider_err = StellarProvider::new(create_test_provider_config(configs_only_zero, 0));
1191        assert!(provider_err.is_err());
1192        match provider_err.unwrap_err() {
1193            ProviderError::NetworkConfiguration(msg) => {
1194                assert!(msg.contains("No active RPC configurations provided"));
1195            }
1196            _ => panic!("Unexpected error type"),
1197        }
1198    }
1199
1200    #[test]
1201    fn test_new_provider_invalid_url_scheme() {
1202        let configs = vec![RpcConfig::new("ftp://invalid.example.com".to_string())];
1203        let provider_err = StellarProvider::new(create_test_provider_config(configs, 0));
1204        assert!(provider_err.is_err());
1205        match provider_err.unwrap_err() {
1206            ProviderError::NetworkConfiguration(msg) => {
1207                assert!(msg.contains("Invalid URL scheme"));
1208            }
1209            _ => panic!("Unexpected error type"),
1210        }
1211    }
1212
1213    #[test]
1214    fn test_new_provider_all_zero_weight_configs() {
1215        let _env_guard = setup_test_env();
1216
1217        let configs = vec![
1218            RpcConfig::with_weight("http://rpc1.example.com".to_string(), 0).unwrap(),
1219            RpcConfig::with_weight("http://rpc2.example.com".to_string(), 0).unwrap(),
1220        ];
1221        let provider_err = StellarProvider::new(create_test_provider_config(configs, 0));
1222        assert!(provider_err.is_err());
1223        match provider_err.unwrap_err() {
1224            ProviderError::NetworkConfiguration(msg) => {
1225                assert!(msg.contains("No active RPC configurations provided"));
1226            }
1227            _ => panic!("Unexpected error type"),
1228        }
1229    }
1230
1231    #[tokio::test]
1232    async fn test_mock_basic_methods() {
1233        let mut mock = MockStellarProviderTrait::new();
1234
1235        mock.expect_get_network()
1236            .times(1)
1237            .returning(|| async { Ok(dummy_get_network_response()) }.boxed());
1238
1239        mock.expect_get_latest_ledger()
1240            .times(1)
1241            .returning(|| async { Ok(dummy_get_latest_ledger_response()) }.boxed());
1242
1243        assert!(mock.get_network().await.is_ok());
1244        assert!(mock.get_latest_ledger().await.is_ok());
1245    }
1246
1247    #[tokio::test]
1248    async fn test_mock_transaction_flow() {
1249        let mut mock = MockStellarProviderTrait::new();
1250
1251        let envelope: TransactionEnvelope = dummy_transaction_envelope();
1252        let hash = dummy_hash();
1253
1254        mock.expect_simulate_transaction_envelope()
1255            .withf(|_| true)
1256            .times(1)
1257            .returning(|_| async { Ok(dummy_simulate()) }.boxed());
1258
1259        mock.expect_send_transaction()
1260            .withf(|_| true)
1261            .times(1)
1262            .returning(|_| async { Ok(dummy_hash()) }.boxed());
1263
1264        mock.expect_send_transaction_polling()
1265            .withf(|_| true)
1266            .times(1)
1267            .returning(|_| async { Ok(dummy_soroban_tx()) }.boxed());
1268
1269        mock.expect_get_transaction()
1270            .withf(|_| true)
1271            .times(1)
1272            .returning(|_| async { Ok(dummy_get_transaction_response()) }.boxed());
1273
1274        mock.simulate_transaction_envelope(&envelope).await.unwrap();
1275        mock.send_transaction(&envelope).await.unwrap();
1276        mock.send_transaction_polling(&envelope).await.unwrap();
1277        mock.get_transaction(&hash).await.unwrap();
1278    }
1279
1280    #[tokio::test]
1281    async fn test_mock_events_and_entries() {
1282        let mut mock = MockStellarProviderTrait::new();
1283
1284        mock.expect_get_events()
1285            .times(1)
1286            .returning(|_| async { Ok(dummy_get_events_response()) }.boxed());
1287
1288        mock.expect_get_ledger_entries()
1289            .times(1)
1290            .returning(|_| async { Ok(dummy_get_ledger_entries_response()) }.boxed());
1291
1292        let events_request = GetEventsRequest {
1293            start: EventStart::Ledger(1),
1294            event_type: None,
1295            contract_ids: vec![],
1296            topics: vec![],
1297            limit: Some(10),
1298        };
1299
1300        let dummy_key: LedgerKey = dummy_ledger_key();
1301        mock.get_events(events_request).await.unwrap();
1302        mock.get_ledger_entries(&[dummy_key]).await.unwrap();
1303    }
1304
1305    #[tokio::test]
1306    async fn test_mock_all_methods_ok() {
1307        let mut mock = MockStellarProviderTrait::new();
1308
1309        mock.expect_get_account()
1310            .with(p::eq("GTESTACCOUNTID"))
1311            .times(1)
1312            .returning(|_| async { Ok(dummy_account_entry()) }.boxed());
1313
1314        mock.expect_simulate_transaction_envelope()
1315            .times(1)
1316            .returning(|_| async { Ok(dummy_simulate()) }.boxed());
1317
1318        mock.expect_send_transaction_polling()
1319            .times(1)
1320            .returning(|_| async { Ok(dummy_soroban_tx()) }.boxed());
1321
1322        mock.expect_get_network()
1323            .times(1)
1324            .returning(|| async { Ok(dummy_get_network_response()) }.boxed());
1325
1326        mock.expect_get_latest_ledger()
1327            .times(1)
1328            .returning(|| async { Ok(dummy_get_latest_ledger_response()) }.boxed());
1329
1330        mock.expect_send_transaction()
1331            .times(1)
1332            .returning(|_| async { Ok(dummy_hash()) }.boxed());
1333
1334        mock.expect_get_transaction()
1335            .times(1)
1336            .returning(|_| async { Ok(dummy_get_transaction_response()) }.boxed());
1337
1338        mock.expect_get_transactions()
1339            .times(1)
1340            .returning(|_| async { Ok(dummy_get_transactions_response()) }.boxed());
1341
1342        mock.expect_get_ledger_entries()
1343            .times(1)
1344            .returning(|_| async { Ok(dummy_get_ledger_entries_response()) }.boxed());
1345
1346        mock.expect_get_events()
1347            .times(1)
1348            .returning(|_| async { Ok(dummy_get_events_response()) }.boxed());
1349
1350        let _ = mock.get_account("GTESTACCOUNTID").await.unwrap();
1351        let env: TransactionEnvelope = dummy_transaction_envelope();
1352        mock.simulate_transaction_envelope(&env).await.unwrap();
1353        mock.send_transaction_polling(&env).await.unwrap();
1354        mock.get_network().await.unwrap();
1355        mock.get_latest_ledger().await.unwrap();
1356        mock.send_transaction(&env).await.unwrap();
1357
1358        let h = dummy_hash();
1359        mock.get_transaction(&h).await.unwrap();
1360
1361        let req: GetTransactionsRequest = GetTransactionsRequest {
1362            start_ledger: None,
1363            pagination: None,
1364        };
1365        mock.get_transactions(req).await.unwrap();
1366
1367        let key: LedgerKey = dummy_ledger_key();
1368        mock.get_ledger_entries(&[key]).await.unwrap();
1369
1370        let ev_req = GetEventsRequest {
1371            start: EventStart::Ledger(0),
1372            event_type: None,
1373            contract_ids: vec![],
1374            topics: vec![],
1375            limit: None,
1376        };
1377        mock.get_events(ev_req).await.unwrap();
1378    }
1379
1380    #[tokio::test]
1381    async fn test_error_propagation() {
1382        let mut mock = MockStellarProviderTrait::new();
1383
1384        mock.expect_get_account()
1385            .returning(|_| async { Err(ProviderError::Other("boom".to_string())) }.boxed());
1386
1387        let res = mock.get_account("BAD").await;
1388        assert!(res.is_err());
1389        assert!(res.unwrap_err().to_string().contains("boom"));
1390    }
1391
1392    #[tokio::test]
1393    async fn test_get_events_edge_cases() {
1394        let mut mock = MockStellarProviderTrait::new();
1395
1396        mock.expect_get_events()
1397            .withf(|req| {
1398                req.contract_ids.is_empty() && req.topics.is_empty() && req.limit.is_none()
1399            })
1400            .times(1)
1401            .returning(|_| async { Ok(dummy_get_events_response()) }.boxed());
1402
1403        let ev_req = GetEventsRequest {
1404            start: EventStart::Ledger(0),
1405            event_type: None,
1406            contract_ids: vec![],
1407            topics: vec![],
1408            limit: None,
1409        };
1410
1411        mock.get_events(ev_req).await.unwrap();
1412    }
1413
1414    #[test]
1415    fn test_provider_send_sync_bounds() {
1416        fn assert_send_sync<T: Send + Sync>() {}
1417        assert_send_sync::<StellarProvider>();
1418    }
1419
1420    #[cfg(test)]
1421    mod concrete_tests {
1422        use super::*;
1423
1424        const NON_EXISTENT_URL: &str = "http://127.0.0.1:9998";
1425
1426        fn create_test_provider_config(configs: Vec<RpcConfig>, timeout: u64) -> ProviderConfig {
1427            ProviderConfig::new(configs, timeout, 3, 60, 60)
1428        }
1429
1430        fn setup_provider() -> StellarProvider {
1431            StellarProvider::new(create_test_provider_config(
1432                vec![RpcConfig::new(NON_EXISTENT_URL.to_string())],
1433                0,
1434            ))
1435            .expect("Provider creation should succeed even with bad URL")
1436        }
1437
1438        #[tokio::test]
1439        async fn test_concrete_get_account_error() {
1440            let _env_guard = setup_test_env();
1441            let provider = setup_provider();
1442            let result = provider.get_account("SOME_ACCOUNT_ID").await;
1443            assert!(result.is_err());
1444            let err_str = result.unwrap_err().to_string();
1445            // Should contain the "Failed to..." context message
1446            assert!(
1447                err_str.contains("Failed to get account"),
1448                "Unexpected error message: {err_str}"
1449            );
1450        }
1451
1452        #[tokio::test]
1453        async fn test_concrete_simulate_transaction_envelope_error() {
1454            let _env_guard = setup_test_env();
1455
1456            let provider = setup_provider();
1457            let envelope: TransactionEnvelope = dummy_transaction_envelope();
1458            let result = provider.simulate_transaction_envelope(&envelope).await;
1459            assert!(result.is_err());
1460            let err_str = result.unwrap_err().to_string();
1461            // Should contain the "Failed to..." context message
1462            assert!(
1463                err_str.contains("Failed to simulate transaction"),
1464                "Unexpected error message: {err_str}"
1465            );
1466        }
1467
1468        #[tokio::test]
1469        async fn test_concrete_send_transaction_polling_error() {
1470            let _env_guard = setup_test_env();
1471
1472            let provider = setup_provider();
1473            let envelope: TransactionEnvelope = dummy_transaction_envelope();
1474            let result = provider.send_transaction_polling(&envelope).await;
1475            assert!(result.is_err());
1476            let err_str = result.unwrap_err().to_string();
1477            // Should contain the "Failed to..." context message
1478            assert!(
1479                err_str.contains("Failed to send transaction (polling)"),
1480                "Unexpected error message: {err_str}"
1481            );
1482        }
1483
1484        #[tokio::test]
1485        async fn test_concrete_get_network_error() {
1486            let _env_guard = setup_test_env();
1487
1488            let provider = setup_provider();
1489            let result = provider.get_network().await;
1490            assert!(result.is_err());
1491            let err_str = result.unwrap_err().to_string();
1492            // Should contain the "Failed to..." context message
1493            assert!(
1494                err_str.contains("Failed to get network"),
1495                "Unexpected error message: {err_str}"
1496            );
1497        }
1498
1499        #[tokio::test]
1500        async fn test_concrete_get_latest_ledger_error() {
1501            let _env_guard = setup_test_env();
1502
1503            let provider = setup_provider();
1504            let result = provider.get_latest_ledger().await;
1505            assert!(result.is_err());
1506            let err_str = result.unwrap_err().to_string();
1507            // Should contain the "Failed to..." context message
1508            assert!(
1509                err_str.contains("Failed to get latest ledger"),
1510                "Unexpected error message: {err_str}"
1511            );
1512        }
1513
1514        #[tokio::test]
1515        async fn test_concrete_send_transaction_error() {
1516            let _env_guard = setup_test_env();
1517
1518            let provider = setup_provider();
1519            let envelope: TransactionEnvelope = dummy_transaction_envelope();
1520            let result = provider.send_transaction(&envelope).await;
1521            assert!(result.is_err());
1522            let err_str = result.unwrap_err().to_string();
1523            // Should contain the "Failed to..." context message
1524            assert!(
1525                err_str.contains("Failed to send transaction"),
1526                "Unexpected error message: {err_str}"
1527            );
1528        }
1529
1530        #[tokio::test]
1531        async fn test_concrete_get_transaction_error() {
1532            let _env_guard = setup_test_env();
1533
1534            let provider = setup_provider();
1535            let hash: Hash = dummy_hash();
1536            let result = provider.get_transaction(&hash).await;
1537            assert!(result.is_err());
1538            // get_transaction uses the raw reqwest path, so connection failures
1539            // surface as transport-level provider errors.
1540            let err = result.unwrap_err();
1541            assert!(
1542                matches!(
1543                    err,
1544                    ProviderError::Other(_)
1545                        | ProviderError::Timeout
1546                        | ProviderError::NetworkConfiguration(_)
1547                ),
1548                "Unexpected error: {err}"
1549            );
1550        }
1551
1552        #[tokio::test]
1553        async fn test_concrete_get_transactions_error() {
1554            let _env_guard = setup_test_env();
1555
1556            let provider = setup_provider();
1557            let req = GetTransactionsRequest {
1558                start_ledger: None,
1559                pagination: None,
1560            };
1561            let result = provider.get_transactions(req).await;
1562            assert!(result.is_err());
1563            let err_str = result.unwrap_err().to_string();
1564            // Should contain the "Failed to..." context message
1565            assert!(
1566                err_str.contains("Failed to get transactions"),
1567                "Unexpected error message: {err_str}"
1568            );
1569        }
1570
1571        #[tokio::test]
1572        async fn test_concrete_get_ledger_entries_error() {
1573            let _env_guard = setup_test_env();
1574
1575            let provider = setup_provider();
1576            let key: LedgerKey = dummy_ledger_key();
1577            let result = provider.get_ledger_entries(&[key]).await;
1578            assert!(result.is_err());
1579            let err_str = result.unwrap_err().to_string();
1580            // Should contain the "Failed to..." context message
1581            assert!(
1582                err_str.contains("Failed to get ledger entries"),
1583                "Unexpected error message: {err_str}"
1584            );
1585        }
1586
1587        #[tokio::test]
1588        async fn test_concrete_get_events_error() {
1589            let _env_guard = setup_test_env();
1590            let provider = setup_provider();
1591            let req = GetEventsRequest {
1592                start: EventStart::Ledger(1),
1593                event_type: None,
1594                contract_ids: vec![],
1595                topics: vec![],
1596                limit: None,
1597            };
1598            let result = provider.get_events(req).await;
1599            assert!(result.is_err());
1600            let err_str = result.unwrap_err().to_string();
1601            // Should contain the "Failed to..." context message
1602            assert!(
1603                err_str.contains("Failed to get events"),
1604                "Unexpected error message: {err_str}"
1605            );
1606        }
1607    }
1608
1609    #[test]
1610    fn test_generate_unique_rpc_id() {
1611        let id1 = generate_unique_rpc_id();
1612        let id2 = generate_unique_rpc_id();
1613        assert_ne!(id1, id2, "Generated IDs should be unique");
1614        assert!(id1 > 0, "ID should be positive");
1615        assert!(id2 > 0, "ID should be positive");
1616        assert!(id2 > id1, "IDs should be monotonically increasing");
1617    }
1618
1619    #[test]
1620    fn test_normalize_url_for_log() {
1621        // Test basic URL without query/fragment
1622        assert_eq!(
1623            normalize_url_for_log("https://api.example.com/path"),
1624            "https://api.example.com/path"
1625        );
1626
1627        // Test URL with query string removal
1628        assert_eq!(
1629            normalize_url_for_log("https://api.example.com/path?api_key=secret&other=value"),
1630            "https://api.example.com/path"
1631        );
1632
1633        // Test URL with fragment removal
1634        assert_eq!(
1635            normalize_url_for_log("https://api.example.com/path#section"),
1636            "https://api.example.com/path"
1637        );
1638
1639        // Test URL with both query and fragment
1640        assert_eq!(
1641            normalize_url_for_log("https://api.example.com/path?key=value#fragment"),
1642            "https://api.example.com/path"
1643        );
1644
1645        // Test URL with userinfo redaction
1646        assert_eq!(
1647            normalize_url_for_log("https://user:password@api.example.com/path"),
1648            "https://<redacted>@api.example.com/path"
1649        );
1650
1651        // Test URL with userinfo and query/fragment removal
1652        assert_eq!(
1653            normalize_url_for_log("https://user:pass@api.example.com/path?token=abc#frag"),
1654            "https://<redacted>@api.example.com/path"
1655        );
1656
1657        // Test URL without userinfo (should remain unchanged)
1658        assert_eq!(
1659            normalize_url_for_log("https://api.example.com/path?token=abc"),
1660            "https://api.example.com/path"
1661        );
1662
1663        // Test malformed URL (should handle gracefully)
1664        assert_eq!(normalize_url_for_log("not-a-url"), "not-a-url");
1665    }
1666
1667    #[test]
1668    fn test_categorize_stellar_error_with_context_timeout() {
1669        let err = StellarClientError::TransactionSubmissionTimeout;
1670        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1671        assert!(matches!(result, ProviderError::Timeout));
1672    }
1673
1674    #[test]
1675    fn test_categorize_stellar_error_with_context_xdr_error() {
1676        use soroban_rs::xdr::Error as XdrError;
1677        let err = StellarClientError::Xdr(XdrError::Invalid);
1678        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1679        match result {
1680            ProviderError::Other(msg) => {
1681                assert!(msg.contains("Test operation"));
1682            }
1683            _ => panic!("Expected Other error"),
1684        }
1685    }
1686
1687    #[test]
1688    fn test_categorize_stellar_error_with_context_serde_error() {
1689        // Create a serde error by attempting to deserialize invalid JSON
1690        let json_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
1691        let err = StellarClientError::Serde(json_err);
1692        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1693        match result {
1694            ProviderError::Other(msg) => {
1695                assert!(msg.contains("Test operation"));
1696            }
1697            _ => panic!("Expected Other error"),
1698        }
1699    }
1700
1701    #[test]
1702    fn test_categorize_stellar_error_with_context_url_errors() {
1703        // Test InvalidRpcUrl
1704        let invalid_uri_err: http::uri::InvalidUri =
1705            ":::invalid url".parse::<http::Uri>().unwrap_err();
1706        let err = StellarClientError::InvalidRpcUrl(invalid_uri_err);
1707        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1708        match result {
1709            ProviderError::NetworkConfiguration(msg) => {
1710                assert!(msg.contains("Test operation"));
1711                assert!(msg.contains("Invalid RPC URL"));
1712            }
1713            _ => panic!("Expected NetworkConfiguration error"),
1714        }
1715
1716        // Test InvalidUrl
1717        let err = StellarClientError::InvalidUrl("not a url".to_string());
1718        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1719        match result {
1720            ProviderError::NetworkConfiguration(msg) => {
1721                assert!(msg.contains("Test operation"));
1722                assert!(msg.contains("Invalid URL"));
1723            }
1724            _ => panic!("Expected NetworkConfiguration error"),
1725        }
1726    }
1727
1728    #[test]
1729    fn test_categorize_stellar_error_with_context_network_passphrase() {
1730        let err = StellarClientError::InvalidNetworkPassphrase {
1731            expected: "Expected".to_string(),
1732            server: "Server".to_string(),
1733        };
1734        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1735        match result {
1736            ProviderError::NetworkConfiguration(msg) => {
1737                assert!(msg.contains("Test operation"));
1738                assert!(msg.contains("Expected"));
1739                assert!(msg.contains("Server"));
1740            }
1741            _ => panic!("Expected NetworkConfiguration error"),
1742        }
1743    }
1744
1745    #[test]
1746    fn test_categorize_stellar_error_with_context_json_rpc_call_error() {
1747        // Test that RPC Call errors are properly categorized as RpcErrorCode
1748        // We'll test this indirectly through other error types since creating Call errors
1749        // requires jsonrpsee internals that aren't easily accessible in tests
1750        let err = StellarClientError::TransactionSubmissionTimeout;
1751        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1752        // Verify timeout is properly categorized
1753        assert!(matches!(result, ProviderError::Timeout));
1754    }
1755
1756    #[test]
1757    fn test_categorize_stellar_error_with_context_json_rpc_timeout() {
1758        // Test timeout through TransactionSubmissionTimeout which is simpler to construct
1759        let err = StellarClientError::TransactionSubmissionTimeout;
1760        let result = categorize_stellar_error_with_context(err, None);
1761        assert!(matches!(result, ProviderError::Timeout));
1762    }
1763
1764    #[test]
1765    fn test_categorize_stellar_error_with_context_transport_errors() {
1766        // Test network-related errors through InvalidResponse which is simpler to construct
1767        let err = StellarClientError::InvalidResponse;
1768        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1769        match result {
1770            ProviderError::Other(msg) => {
1771                assert!(msg.contains("Test operation"));
1772                assert!(msg.contains("Invalid response"));
1773            }
1774            _ => panic!("Expected Other error for response issues"),
1775        }
1776    }
1777
1778    #[test]
1779    fn test_categorize_stellar_error_with_context_response_errors() {
1780        // Test InvalidResponse
1781        let err = StellarClientError::InvalidResponse;
1782        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1783        match result {
1784            ProviderError::Other(msg) => {
1785                assert!(msg.contains("Test operation"));
1786                assert!(msg.contains("Invalid response"));
1787            }
1788            _ => panic!("Expected Other error"),
1789        }
1790
1791        // Test MissingResult
1792        let err = StellarClientError::MissingResult;
1793        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1794        match result {
1795            ProviderError::Other(msg) => {
1796                assert!(msg.contains("Test operation"));
1797                assert!(msg.contains("Missing result"));
1798            }
1799            _ => panic!("Expected Other error"),
1800        }
1801    }
1802
1803    #[test]
1804    fn test_categorize_stellar_error_with_context_transaction_errors() {
1805        // Test TransactionFailed
1806        let err = StellarClientError::TransactionFailed("tx failed".to_string());
1807        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1808        match result {
1809            ProviderError::Other(msg) => {
1810                assert!(msg.contains("Test operation"));
1811                assert!(msg.contains("tx failed"));
1812            }
1813            _ => panic!("Expected Other error"),
1814        }
1815
1816        // Test NotFound
1817        let err = StellarClientError::NotFound("Account".to_string(), "123".to_string());
1818        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1819        match result {
1820            ProviderError::Other(msg) => {
1821                assert!(msg.contains("Test operation"));
1822                assert!(msg.contains("Account not found"));
1823                assert!(msg.contains("123"));
1824            }
1825            _ => panic!("Expected Other error"),
1826        }
1827    }
1828
1829    #[test]
1830    fn test_categorize_stellar_error_with_context_validation_errors() {
1831        // Test InvalidCursor
1832        let err = StellarClientError::InvalidCursor;
1833        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1834        match result {
1835            ProviderError::Other(msg) => {
1836                assert!(msg.contains("Test operation"));
1837                assert!(msg.contains("Invalid cursor"));
1838            }
1839            _ => panic!("Expected Other error"),
1840        }
1841
1842        // Test LargeFee
1843        let err = StellarClientError::LargeFee(1000000);
1844        let result = categorize_stellar_error_with_context(err, Some("Test operation"));
1845        match result {
1846            ProviderError::Other(msg) => {
1847                assert!(msg.contains("Test operation"));
1848                assert!(msg.contains("1000000"));
1849            }
1850            _ => panic!("Expected Other error"),
1851        }
1852    }
1853
1854    #[test]
1855    fn test_categorize_stellar_error_with_context_no_context() {
1856        // Test with a simpler error type that doesn't have version conflicts
1857        let err = StellarClientError::InvalidResponse;
1858        let result = categorize_stellar_error_with_context(err, None);
1859        match result {
1860            ProviderError::Other(msg) => {
1861                assert!(!msg.contains(":")); // No context prefix
1862                assert!(msg.contains("Invalid response"));
1863            }
1864            _ => panic!("Expected Other error"),
1865        }
1866    }
1867
1868    #[test]
1869    fn test_initialize_provider_invalid_url() {
1870        let _env_guard = setup_test_env();
1871        let provider = StellarProvider::new(create_test_provider_config(
1872            vec![RpcConfig::new("http://localhost:8000".to_string())],
1873            30,
1874        ))
1875        .unwrap();
1876
1877        // Test with invalid URL that should fail client creation
1878        let result = provider.initialize_provider("invalid-url");
1879        assert!(result.is_err());
1880        match result.unwrap_err() {
1881            ProviderError::NetworkConfiguration(msg) => {
1882                // Error message can be either from URL validation or client creation
1883                assert!(
1884                    msg.contains("Failed to create Stellar RPC client")
1885                        || msg.contains("RPC URL security validation failed")
1886                );
1887            }
1888            _ => panic!("Expected NetworkConfiguration error"),
1889        }
1890    }
1891
1892    #[test]
1893    fn test_initialize_raw_provider_timeout_config() {
1894        let _env_guard = setup_test_env();
1895        let provider = StellarProvider::new(create_test_provider_config(
1896            vec![RpcConfig::new("http://localhost:8000".to_string())],
1897            30,
1898        ))
1899        .unwrap();
1900
1901        // Test with valid URL - should succeed
1902        let result = provider.initialize_raw_provider("http://localhost:8000");
1903        assert!(result.is_ok());
1904
1905        // Test with invalid URL for reqwest client - this might not fail immediately
1906        // but we can test that the function doesn't panic
1907        let result = provider.initialize_raw_provider("not-a-url");
1908        // reqwest::Client::builder() may not fail immediately for malformed URLs
1909        // but the function should return a Result
1910        assert!(result.is_ok() || result.is_err());
1911    }
1912
1913    #[tokio::test]
1914    async fn test_raw_request_dyn_success() {
1915        let _env_guard = setup_test_env();
1916
1917        // Create a provider with a mock server URL that won't actually connect
1918        let provider = StellarProvider::new(create_test_provider_config(
1919            vec![RpcConfig::new("http://127.0.0.1:9999".to_string())],
1920            1,
1921        ))
1922        .unwrap();
1923
1924        let params = serde_json::json!({"test": "value"});
1925        let result = provider
1926            .raw_request_dyn("test_method", params, Some(JsonRpcId::Number(1)))
1927            .await;
1928
1929        // Should fail due to connection, but should go through the retry logic
1930        assert!(result.is_err());
1931        let err = result.unwrap_err();
1932        // Should be a network-related error, not a panic
1933        assert!(matches!(
1934            err,
1935            ProviderError::Other(_)
1936                | ProviderError::Timeout
1937                | ProviderError::NetworkConfiguration(_)
1938        ));
1939    }
1940
1941    #[tokio::test]
1942    async fn test_raw_request_dyn_with_auto_generated_id() {
1943        let _env_guard = setup_test_env();
1944
1945        let provider = StellarProvider::new(create_test_provider_config(
1946            vec![RpcConfig::new("http://127.0.0.1:9999".to_string())],
1947            1,
1948        ))
1949        .unwrap();
1950
1951        let params = serde_json::json!({"test": "value"});
1952        let result = provider.raw_request_dyn("test_method", params, None).await;
1953
1954        // Should fail due to connection, but the ID generation should work
1955        assert!(result.is_err());
1956    }
1957
1958    #[tokio::test]
1959    async fn test_retry_raw_request_connection_failure() {
1960        let _env_guard = setup_test_env();
1961
1962        let provider = StellarProvider::new(create_test_provider_config(
1963            vec![RpcConfig::new("http://127.0.0.1:9999".to_string())],
1964            1,
1965        ))
1966        .unwrap();
1967
1968        let request = serde_json::json!({
1969            "jsonrpc": "2.0",
1970            "id": 1,
1971            "method": "test",
1972            "params": {}
1973        });
1974
1975        let result = provider.retry_raw_request("test_operation", request).await;
1976
1977        // Should fail due to connection issues
1978        assert!(result.is_err());
1979        let err = result.unwrap_err();
1980        // Should be categorized as network error
1981        assert!(matches!(
1982            err,
1983            ProviderError::Other(_) | ProviderError::Timeout
1984        ));
1985    }
1986
1987    #[tokio::test]
1988    async fn test_raw_request_dyn_json_rpc_error_response() {
1989        let _env_guard = setup_test_env();
1990
1991        // This test would require mocking the HTTP response, which is complex
1992        // For now, we test that the function exists and can be called
1993        let provider = StellarProvider::new(create_test_provider_config(
1994            vec![RpcConfig::new("http://127.0.0.1:9999".to_string())],
1995            1,
1996        ))
1997        .unwrap();
1998
1999        let params = serde_json::json!({"test": "value"});
2000        let result = provider
2001            .raw_request_dyn(
2002                "test_method",
2003                params,
2004                Some(JsonRpcId::String("test-id".to_string())),
2005            )
2006            .await;
2007
2008        // Should fail due to connection, but should handle the request properly
2009        assert!(result.is_err());
2010    }
2011
2012    #[tokio::test]
2013    async fn test_get_transaction_zstd_compressed_response() {
2014        let _env_guard = setup_test_env();
2015
2016        let mut mock_server = mockito::Server::new_async().await;
2017
2018        let response_body = serde_json::json!({
2019            "jsonrpc": "2.0",
2020            "id": 1,
2021            "result": {
2022                "status": "NOT_FOUND",
2023            }
2024        });
2025        let compressed = zstd::encode_all(response_body.to_string().as_bytes(), 3).unwrap();
2026
2027        let mock = mock_server
2028            .mock("POST", "/")
2029            // The raw reqwest path must advertise zstd support...
2030            .match_header(
2031                "accept-encoding",
2032                mockito::Matcher::Regex("zstd".to_string()),
2033            )
2034            // ...and serialize the tx hash as a hex string.
2035            .match_body(mockito::Matcher::PartialJson(serde_json::json!({
2036                "method": "getTransaction",
2037                "params": { "hash": "0".repeat(64) },
2038            })))
2039            .with_header("content-encoding", "zstd")
2040            .with_body(compressed)
2041            .create_async()
2042            .await;
2043
2044        let provider = StellarProvider::new(create_test_provider_config(
2045            vec![RpcConfig::new(mock_server.url())],
2046            5,
2047        ))
2048        .unwrap();
2049
2050        let response = provider.get_transaction(&dummy_hash()).await.unwrap();
2051        assert_eq!(response.status, "NOT_FOUND");
2052        mock.assert_async().await;
2053    }
2054
2055    #[tokio::test]
2056    async fn test_raw_path_http_429_is_classified_and_retried() {
2057        let _env_guard = setup_test_env();
2058        // Two attempts on the same provider so the retry classification is observable.
2059        std::env::set_var("PROVIDER_MAX_RETRIES", "2");
2060
2061        let mut mock_server = mockito::Server::new_async().await;
2062        let mock = mock_server
2063            .mock("POST", "/")
2064            .with_status(429)
2065            .with_body("rate limited, non-JSON body")
2066            .expect(2)
2067            .create_async()
2068            .await;
2069
2070        let provider = StellarProvider::new(create_test_provider_config(
2071            vec![RpcConfig::new(mock_server.url())],
2072            5,
2073        ))
2074        .unwrap();
2075
2076        let err = provider.get_transaction(&dummy_hash()).await.unwrap_err();
2077        assert!(
2078            matches!(err, ProviderError::RateLimited),
2079            "Unexpected error: {err}"
2080        );
2081        mock.assert_async().await;
2082    }
2083
2084    #[tokio::test]
2085    async fn test_raw_path_retriable_json_rpc_error_is_retried() {
2086        let _env_guard = setup_test_env();
2087        std::env::set_var("PROVIDER_MAX_RETRIES", "2");
2088
2089        let mut mock_server = mockito::Server::new_async().await;
2090        let mock = mock_server
2091            .mock("POST", "/")
2092            .with_body(
2093                serde_json::json!({
2094                    "jsonrpc": "2.0",
2095                    "id": 1,
2096                    "error": { "code": -32005, "message": "limit exceeded" }
2097                })
2098                .to_string(),
2099            )
2100            .expect(2)
2101            .create_async()
2102            .await;
2103
2104        let provider = StellarProvider::new(create_test_provider_config(
2105            vec![RpcConfig::new(mock_server.url())],
2106            5,
2107        ))
2108        .unwrap();
2109
2110        let err = provider.get_transaction(&dummy_hash()).await.unwrap_err();
2111        assert!(
2112            matches!(err, ProviderError::RpcErrorCode { code: -32005, .. }),
2113            "Unexpected error: {err}"
2114        );
2115        mock.assert_async().await;
2116    }
2117
2118    #[tokio::test]
2119    async fn test_raw_path_non_retriable_json_rpc_error_fails_fast() {
2120        let _env_guard = setup_test_env();
2121        std::env::set_var("PROVIDER_MAX_RETRIES", "2");
2122
2123        let mut mock_server = mockito::Server::new_async().await;
2124        let mock = mock_server
2125            .mock("POST", "/")
2126            .with_body(
2127                serde_json::json!({
2128                    "jsonrpc": "2.0",
2129                    "id": 1,
2130                    "error": { "code": -32601, "message": "method not found" }
2131                })
2132                .to_string(),
2133            )
2134            .expect(1)
2135            .create_async()
2136            .await;
2137
2138        let provider = StellarProvider::new(create_test_provider_config(
2139            vec![RpcConfig::new(mock_server.url())],
2140            5,
2141        ))
2142        .unwrap();
2143
2144        let err = provider.get_transaction(&dummy_hash()).await.unwrap_err();
2145        assert!(
2146            matches!(err, ProviderError::RpcErrorCode { code: -32601, .. }),
2147            "Unexpected error: {err}"
2148        );
2149        mock.assert_async().await;
2150    }
2151
2152    #[tokio::test]
2153    async fn test_raw_path_null_error_field_is_treated_as_success() {
2154        let _env_guard = setup_test_env();
2155
2156        let mut mock_server = mockito::Server::new_async().await;
2157        // Non-strict providers may include `"error": null` on a successful response.
2158        let mock = mock_server
2159            .mock("POST", "/")
2160            .with_body(
2161                serde_json::json!({
2162                    "jsonrpc": "2.0",
2163                    "id": 1,
2164                    "error": null,
2165                    "result": { "status": "NOT_FOUND" }
2166                })
2167                .to_string(),
2168            )
2169            .expect(1)
2170            .create_async()
2171            .await;
2172
2173        let provider = StellarProvider::new(create_test_provider_config(
2174            vec![RpcConfig::new(mock_server.url())],
2175            5,
2176        ))
2177        .unwrap();
2178
2179        let response = provider.get_transaction(&dummy_hash()).await.unwrap();
2180        assert_eq!(response.status, "NOT_FOUND");
2181        mock.assert_async().await;
2182    }
2183
2184    #[tokio::test]
2185    async fn test_raw_path_http_error_surfaces_provider_body() {
2186        let _env_guard = setup_test_env();
2187        // 400 is non-retriable, so a single attempt is enough to observe the body.
2188        std::env::set_var("PROVIDER_MAX_RETRIES", "1");
2189
2190        let mut mock_server = mockito::Server::new_async().await;
2191        let mock = mock_server
2192            .mock("POST", "/")
2193            .with_status(400)
2194            .with_body("provider says: bad params")
2195            .expect(1)
2196            .create_async()
2197            .await;
2198
2199        let provider = StellarProvider::new(create_test_provider_config(
2200            vec![RpcConfig::new(mock_server.url())],
2201            5,
2202        ))
2203        .unwrap();
2204
2205        let err = provider.get_transaction(&dummy_hash()).await.unwrap_err();
2206        match err {
2207            ProviderError::RequestError { error, status_code } => {
2208                assert_eq!(status_code, 400);
2209                assert!(
2210                    error.contains("provider says: bad params"),
2211                    "Body detail not surfaced: {error}"
2212                );
2213            }
2214            other => panic!("Unexpected error: {other}"),
2215        }
2216        mock.assert_async().await;
2217    }
2218
2219    #[test]
2220    fn test_provider_creation_edge_cases() {
2221        let _env_guard = setup_test_env();
2222
2223        // Test with empty configs
2224        let result = StellarProvider::new(create_test_provider_config(vec![], 30));
2225        assert!(result.is_err());
2226        match result.unwrap_err() {
2227            ProviderError::NetworkConfiguration(msg) => {
2228                assert!(msg.contains("No RPC configurations provided"));
2229            }
2230            _ => panic!("Expected NetworkConfiguration error"),
2231        }
2232
2233        // Test with configs that have zero weights after filtering
2234        let mut config1 = RpcConfig::new("http://localhost:8000".to_string());
2235        config1.weight = 0;
2236        let mut config2 = RpcConfig::new("http://localhost:8001".to_string());
2237        config2.weight = 0;
2238        let configs = vec![config1, config2];
2239        let result = StellarProvider::new(create_test_provider_config(configs, 30));
2240        assert!(result.is_err());
2241        match result.unwrap_err() {
2242            ProviderError::NetworkConfiguration(msg) => {
2243                assert!(msg.contains("No active RPC configurations"));
2244            }
2245            _ => panic!("Expected NetworkConfiguration error"),
2246        }
2247    }
2248
2249    #[tokio::test]
2250    async fn test_get_events_empty_request() {
2251        let _env_guard = setup_test_env();
2252
2253        let mut mock = MockStellarProviderTrait::new();
2254        mock.expect_get_events()
2255            .withf(|req| req.contract_ids.is_empty() && req.topics.is_empty())
2256            .returning(|_| async { Ok(dummy_get_events_response()) }.boxed());
2257
2258        let req = GetEventsRequest {
2259            start: EventStart::Ledger(1),
2260            event_type: Some(EventType::Contract),
2261            contract_ids: vec![],
2262            topics: vec![],
2263            limit: Some(10),
2264        };
2265
2266        let result = mock.get_events(req).await;
2267        assert!(result.is_ok());
2268    }
2269
2270    #[tokio::test]
2271    async fn test_get_ledger_entries_empty_keys() {
2272        let _env_guard = setup_test_env();
2273
2274        let mut mock = MockStellarProviderTrait::new();
2275        mock.expect_get_ledger_entries()
2276            .withf(|keys| keys.is_empty())
2277            .returning(|_| async { Ok(dummy_get_ledger_entries_response()) }.boxed());
2278
2279        let result = mock.get_ledger_entries(&[]).await;
2280        assert!(result.is_ok());
2281    }
2282
2283    #[tokio::test]
2284    async fn test_send_transaction_polling_success() {
2285        let _env_guard = setup_test_env();
2286
2287        let mut mock = MockStellarProviderTrait::new();
2288        mock.expect_send_transaction_polling()
2289            .returning(|_| async { Ok(dummy_soroban_tx()) }.boxed());
2290
2291        let envelope = dummy_transaction_envelope();
2292        let result = mock.send_transaction_polling(&envelope).await;
2293        assert!(result.is_ok());
2294    }
2295
2296    #[tokio::test]
2297    async fn test_get_transactions_with_pagination() {
2298        let _env_guard = setup_test_env();
2299
2300        let mut mock = MockStellarProviderTrait::new();
2301        mock.expect_get_transactions()
2302            .returning(|_| async { Ok(dummy_get_transactions_response()) }.boxed());
2303
2304        let req = GetTransactionsRequest {
2305            start_ledger: Some(1000),
2306            pagination: None, // Pagination struct may not be available in this version
2307        };
2308
2309        let result = mock.get_transactions(req).await;
2310        assert!(result.is_ok());
2311    }
2312}