openzeppelin_relayer/services/provider/stellar/
mod.rs

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