openzeppelin_relayer/services/provider/stellar/
mod.rs

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