Skip to main content

sdkt_rpc/
client.rs

1//! Soroban RPC HTTP client.
2//!
3//! Low-level JSON‑RPC over HTTP via [`reqwest`].
4
5use crate::error::RpcError;
6use sdkt_core::NetworkConfig;
7use serde::{Deserialize, Serialize};
8use std::time::Duration;
9
10/// Soroban RPC HTTP client.
11///
12/// Holds the RPC endpoint URL and a reusable HTTP client.
13#[derive(Clone)]
14pub struct SorobanRpcClient {
15    /// Base URL of the Soroban RPC endpoint (e.g. `https://soroban-testnet.stellar.org`).
16    endpoint: String,
17    http_client: reqwest::Client,
18}
19
20impl SorobanRpcClient {
21    /// Create a client from an explicit endpoint URL.
22    pub fn new(endpoint: &str) -> Self {
23        Self::with_options(endpoint, Some(15), Some(100))
24    }
25
26    /// Create a client with explicit pool and timeout settings.
27    pub fn with_options(
28        endpoint: &str,
29        timeout_secs: Option<u64>,
30        pool_max_idle: Option<usize>,
31    ) -> Self {
32        let mut builder = reqwest::Client::builder();
33
34        if let Some(secs) = timeout_secs {
35            builder = builder.timeout(Duration::from_secs(secs));
36        }
37
38        if let Some(max_idle) = pool_max_idle {
39            builder = builder.pool_max_idle_per_host(max_idle);
40        }
41
42        let http_client = builder
43            .pool_idle_timeout(Duration::from_secs(60))
44            .build()
45            .unwrap_or_else(|_| reqwest::Client::new());
46
47        let endpoint = endpoint.trim_end_matches('/').to_string();
48
49        Self {
50            endpoint,
51            http_client,
52        }
53    }
54
55    /// Create a client from [`NetworkConfig`].
56    pub fn from_config(config: &NetworkConfig) -> Self {
57        Self::with_options(
58            &config.rpc_url,
59            config.timeout_secs,
60            config.pool_max_idle_per_host,
61        )
62    }
63
64    /// Return the configured endpoint URL.
65    pub fn endpoint(&self) -> &str {
66        &self.endpoint
67    }
68
69    /// Return a reference to the internal HTTP client.
70    pub fn http_client(&self) -> &reqwest::Client {
71        &self.http_client
72    }
73
74    /// Helper for making JSON-RPC calls with basic timeout retry logic.
75    pub async fn request<T: serde::de::DeserializeOwned>(
76        &self,
77        method: &str,
78        params: impl Serialize,
79    ) -> Result<T, RpcError> {
80        let payload = serde_json::json!({
81            "jsonrpc": "2.0",
82            "id": 1,
83            "method": method,
84            "params": params,
85        });
86
87        let mut attempt = 0;
88        let mut last_err = None;
89
90        while attempt < 2 {
91            match self
92                .http_client
93                .post(&self.endpoint)
94                .json(&payload)
95                .header("Accept-Encoding", "identity")
96                .send()
97                .await
98            {
99                Ok(res) => {
100                    let rpc_res: JsonRpcResponse<T> = match res.json().await {
101                        Ok(json) => json,
102                        Err(e) => return Err(RpcError::Reqwest(e)),
103                    };
104
105                    if let Some(error) = rpc_res.error {
106                        return Err(RpcError::Rpc(error.message));
107                    }
108
109                    return rpc_res.result.ok_or_else(|| {
110                        RpcError::Rpc("Missing result in JSON-RPC response".to_string())
111                    });
112                }
113                Err(e) => {
114                    if e.is_timeout() || e.is_connect() {
115                        last_err = Some(e);
116                        attempt += 1;
117                        tokio::time::sleep(Duration::from_millis(500)).await;
118                        continue;
119                    }
120                    return Err(RpcError::Reqwest(e));
121                }
122            }
123        }
124
125        Err(RpcError::Reqwest(last_err.unwrap()))
126    }
127
128    /// Check the health of the Soroban RPC node.
129    pub async fn get_health(&self) -> Result<HealthCheck, RpcError> {
130        self.request("getHealth", ()).await
131    }
132
133    /// Get the latest ledger info from the Soroban RPC node.
134    pub async fn get_ledger(&self) -> Result<LedgerInfo, RpcError> {
135        self.request("getLatestLedger", ()).await
136    }
137
138    /// Get network identity and protocol information from the RPC endpoint.
139    pub async fn get_network(&self) -> Result<NetworkInfo, RpcError> {
140        self.request("getNetwork", ()).await
141    }
142
143    /// Get contract storage entries.
144    pub async fn get_contract_storage(
145        &self,
146        _contract_id: &str,
147        keys: &[String],
148    ) -> Result<StorageResponse, RpcError> {
149        self.request("getLedgerEntries", serde_json::json!({ "keys": keys }))
150            .await
151    }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
155pub struct FundResult {
156    pub address: String,
157    pub status: String,
158}
159
160/// Funds an account via the Stellar Testnet Friendbot.
161///
162/// Friendbot is a Testnet-only faucet. It is NOT available on Mainnet.
163/// The caller must supply a network profile that explicitly carries a
164/// `friendbot_url`. This function performs a single HTTP GET to the
165/// Friendbot endpoint with the account address as a query parameter.
166pub async fn fund_account(friendbot_url: &str, address: &str) -> Result<FundResult, RpcError> {
167    if friendbot_url.trim().is_empty() {
168        return Err(RpcError::Rpc("Friendbot URL is empty".into()));
169    }
170    if address.trim().is_empty() {
171        return Err(RpcError::Rpc("Address is empty".into()));
172    }
173
174    let url = format!("{}?addr={}", friendbot_url.trim(), address);
175
176    let response = reqwest::Client::new()
177        .get(&url)
178        .timeout(std::time::Duration::from_secs(30))
179        .send()
180        .await
181        .map_err(|e| {
182            if e.is_timeout() {
183                RpcError::Rpc(format!("Friendbot request timed out: {e}"))
184            } else {
185                RpcError::Reqwest(e)
186            }
187        })?;
188
189    let status = response.status();
190    let body = response.text().await.map_err(RpcError::Reqwest)?;
191
192    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
193        return Err(RpcError::Rpc(
194            "Friendbot rate limit exceeded. Wait and try again.".into(),
195        ));
196    }
197
198    if !status.is_success() {
199        return Err(RpcError::Rpc(format!(
200            "Friendbot returned HTTP {}: {}",
201            status.as_u16(),
202            body.chars().take(200).collect::<String>()
203        )));
204    }
205
206    if body.contains("insufficient funds for rent exemption")
207        || body.contains("account is already funded")
208    {
209        return Err(RpcError::Rpc(format!(
210            "Friendbot refused: {}",
211            body.chars().take(200).collect::<String>()
212        )));
213    }
214
215    if body.contains("ERROR") || body.contains("error") {
216        return Err(RpcError::Rpc(format!(
217            "Friendbot error: {}",
218            body.chars().take(200).collect::<String>()
219        )));
220    }
221
222    Ok(FundResult {
223        address: address.to_string(),
224        status: "funded".into(),
225    })
226}
227
228#[derive(Debug, Deserialize)]
229struct JsonRpcResponse<T> {
230    result: Option<T>,
231    error: Option<JsonRpcError>,
232}
233
234#[derive(Debug, Deserialize)]
235struct JsonRpcError {
236    message: String,
237}
238
239/// Health check response from the node.
240#[derive(Debug, Deserialize, PartialEq)]
241pub struct HealthCheck {
242    pub status: String,
243}
244
245/// Ledger info snapshot.
246#[derive(Debug, Deserialize, PartialEq)]
247#[serde(rename_all = "camelCase")]
248pub struct LedgerInfo {
249    #[serde(default)]
250    pub id: String,
251    #[serde(default)]
252    pub protocol_version: u32,
253    pub sequence: u32,
254}
255
256/// Network identity and protocol details reported by `getNetwork`.
257///
258/// `latest_ledger` is optional because the SDF endpoints (testnet, futurenet,
259/// mainnet) answer `getNetwork` with only `passphrase`, `protocolVersion` and
260/// `friendbotUrl`; a required field there made every `getNetwork` decode fail
261/// on those endpoints and the error was swallowed by callers that treated the
262/// probe as best-effort.
263#[derive(Debug, Clone, Deserialize, PartialEq)]
264#[serde(rename_all = "camelCase")]
265pub struct NetworkInfo {
266    #[serde(default)]
267    pub friendbot_url: Option<String>,
268    pub passphrase: String,
269    pub protocol_version: u32,
270    #[serde(default)]
271    pub latest_ledger: Option<u32>,
272}
273
274/// Storage response payload.
275#[derive(Debug, Deserialize, PartialEq)]
276pub struct StorageResponse {
277    pub entries: Vec<LedgerEntryResult>,
278    #[serde(rename = "latestLedger")]
279    pub latest_ledger: u32,
280}
281
282#[derive(Debug, Deserialize, PartialEq)]
283#[serde(rename_all = "camelCase")]
284pub struct LedgerEntryResult {
285    pub key: String,
286    pub xdr: String,
287    pub last_modified_ledger_seq: u32,
288    pub live_until_ledger_seq: Option<u32>,
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use sdkt_core::NetworkConfig;
295
296    #[test]
297    fn new_sets_endpoint() {
298        let c = SorobanRpcClient::new("http://localhost:8000");
299        assert_eq!(c.endpoint(), "http://localhost:8000");
300    }
301
302    #[test]
303    fn from_config_sets_endpoint() {
304        let cfg = NetworkConfig {
305            rpc_url: "https://custom.example.com".to_string(),
306            passphrase: "test".to_string(),
307            timeout_secs: None,
308            pool_max_idle_per_host: None,
309        };
310        let c = SorobanRpcClient::from_config(&cfg);
311        assert_eq!(c.endpoint(), "https://custom.example.com");
312    }
313
314    #[test]
315    fn network_info_deserializes_rpc_camel_case_fields() {
316        let info: NetworkInfo = serde_json::from_value(serde_json::json!({
317            "friendbotUrl": "https://friendbot.example",
318            "passphrase": "Test Network",
319            "protocolVersion": 22,
320            "latestLedger": 1234
321        }))
322        .unwrap();
323
324        assert_eq!(
325            info.friendbot_url.as_deref(),
326            Some("https://friendbot.example")
327        );
328        assert_eq!(info.passphrase, "Test Network");
329        assert_eq!(info.protocol_version, 22);
330        assert_eq!(info.latest_ledger, Some(1234));
331    }
332
333    /// The live SDF endpoints answer `getNetwork` without `latestLedger`; a
334    /// required field there made the decode fail on every real endpoint.
335    #[test]
336    fn network_info_deserializes_without_latest_ledger() {
337        let info: NetworkInfo = serde_json::from_value(serde_json::json!({
338            "friendbotUrl": "https://friendbot.stellar.org/",
339            "passphrase": "Test SDF Network ; September 2015",
340            "protocolVersion": 29
341        }))
342        .unwrap();
343        assert_eq!(info.protocol_version, 29);
344        assert_eq!(info.latest_ledger, None);
345    }
346
347    #[test]
348    fn get_ledger_entries_request_uses_keys_object() {
349        let keys = vec!["AAAA".to_string()];
350        let body = serde_json::json!({ "keys": keys });
351        assert_eq!(body, serde_json::json!({ "keys": ["AAAA".to_string()] }));
352        assert_ne!(body, serde_json::json!(["AAAA".to_string()]));
353    }
354
355    #[tokio::test]
356    async fn request_decodes_gzip_response_body() {
357        // Removed: this test requires reqwest gzip feature which was disabled
358        // to fix real Testnet E2E compatibility. This is a known trade-off.
359    }
360
361    #[tokio::test]
362    async fn request_does_not_send_gzip_encoding_header() {
363        use tokio::io::{AsyncReadExt, AsyncWriteExt};
364        use tokio::net::TcpListener;
365
366        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
367        let addr = listener.local_addr().unwrap();
368
369        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
370
371        let server = tokio::spawn(async move {
372            let (mut sock, _) = listener.accept().await.unwrap();
373            let mut buf = [0u8; 8192];
374            let n = sock.read(&mut buf).await.unwrap();
375            let request = String::from_utf8_lossy(&buf[..n]).to_string();
376
377            let response = r#"{"jsonrpc":"2.0","id":1,"result":{"status":"ok"}}"#;
378            let header = format!(
379                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
380                response.len(),
381                response
382            );
383            sock.write_all(header.as_bytes()).await.unwrap();
384
385            let _ = tx.send(request);
386        });
387
388        let client = SorobanRpcClient::new(&format!("http://{}", addr));
389        let _res: HealthCheck = client.request("getHealth", ()).await.unwrap();
390
391        let request = rx.await.unwrap();
392        assert!(
393            !request.contains("Accept-Encoding: gzip"),
394            "Request should NOT contain Accept-Encoding: gzip, got:\n{}",
395            request
396        );
397        assert!(
398            !request.contains("Accept-Encoding: deflate"),
399            "Request should NOT contain Accept-Encoding: deflate, got:\n{}",
400            request
401        );
402
403        server.await.unwrap();
404    }
405
406    #[tokio::test]
407    async fn fund_account_success() {
408        use tokio::io::{AsyncReadExt, AsyncWriteExt};
409        use tokio::net::TcpListener;
410
411        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
412        let addr = listener.local_addr().unwrap();
413
414        let server = tokio::spawn(async move {
415            let (mut sock, _) = listener.accept().await.unwrap();
416            let mut buf = [0u8; 8192];
417            let n = sock.read(&mut buf).await.unwrap();
418            let request = String::from_utf8_lossy(&buf[..n]).to_string();
419
420            assert!(
421                request.contains("addr=GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF")
422            );
423
424            let response = r#"{"hash":"abc123","ledger":12345}"#;
425            let header = format!(
426                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
427                response.len(),
428                response
429            );
430            sock.write_all(header.as_bytes()).await.unwrap();
431        });
432
433        let result = fund_account(
434            &format!("http://{}", addr),
435            "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
436        )
437        .await
438        .unwrap();
439
440        assert_eq!(
441            result.address,
442            "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"
443        );
444        assert_eq!(result.status, "funded");
445
446        server.await.unwrap();
447    }
448
449    #[tokio::test]
450    async fn fund_account_http_500() {
451        use tokio::io::{AsyncReadExt, AsyncWriteExt};
452        use tokio::net::TcpListener;
453
454        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
455        let addr = listener.local_addr().unwrap();
456
457        let server = tokio::spawn(async move {
458            let (mut sock, _) = listener.accept().await.unwrap();
459            let mut buf = [0u8; 8192];
460            let _n = sock.read(&mut buf).await.unwrap();
461
462            let response = r#"{"error":"internal"}"#;
463            let header = format!(
464                "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
465                response.len(),
466                response
467            );
468            sock.write_all(header.as_bytes()).await.unwrap();
469        });
470
471        let err = fund_account(
472            &format!("http://{}", addr),
473            "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
474        )
475        .await
476        .unwrap_err();
477
478        assert!(err.to_string().contains("500"));
479
480        server.await.unwrap();
481    }
482
483    #[tokio::test]
484    async fn fund_account_rate_limit() {
485        use tokio::io::{AsyncReadExt, AsyncWriteExt};
486        use tokio::net::TcpListener;
487
488        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
489        let addr = listener.local_addr().unwrap();
490
491        let server = tokio::spawn(async move {
492            let (mut sock, _) = listener.accept().await.unwrap();
493            let mut buf = [0u8; 8192];
494            let _n = sock.read(&mut buf).await.unwrap();
495
496            let response = r#"{"error":"rate limited"}"#;
497            let header = format!(
498                "HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
499                response.len(),
500                response
501            );
502            sock.write_all(header.as_bytes()).await.unwrap();
503        });
504
505        let err = fund_account(
506            &format!("http://{}", addr),
507            "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
508        )
509        .await
510        .unwrap_err();
511
512        assert!(err.to_string().contains("rate limit"));
513
514        server.await.unwrap();
515    }
516
517    #[tokio::test]
518    async fn fund_account_empty_url_rejected() {
519        let err = fund_account(
520            "",
521            "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
522        )
523        .await
524        .unwrap_err();
525        assert!(err.to_string().contains("empty"));
526    }
527
528    #[tokio::test]
529    async fn fund_account_empty_address_rejected() {
530        let err = fund_account("http://localhost", "").await.unwrap_err();
531        assert!(err.to_string().contains("empty"));
532    }
533
534    #[tokio::test]
535    async fn fund_account_malformed_response() {
536        use tokio::io::{AsyncReadExt, AsyncWriteExt};
537        use tokio::net::TcpListener;
538
539        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
540        let addr = listener.local_addr().unwrap();
541
542        let server = tokio::spawn(async move {
543            let (mut sock, _) = listener.accept().await.unwrap();
544            let mut buf = [0u8; 8192];
545            let _n = sock.read(&mut buf).await.unwrap();
546
547            // Return 200 OK but with body containing "ERROR"
548            let response = r#"{"status":"ERROR","message":"internal failure"}"#;
549            let header = format!(
550                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
551                response.len(),
552                response
553            );
554            sock.write_all(header.as_bytes()).await.unwrap();
555        });
556
557        let err = fund_account(
558            &format!("http://{}", addr),
559            "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
560        )
561        .await
562        .unwrap_err();
563
564        // Should be rejected because body contains "error"
565        assert!(err.to_string().contains("error") || err.to_string().contains("Error"));
566
567        server.await.unwrap();
568    }
569
570    #[tokio::test]
571    async fn fund_account_http_4xx() {
572        use tokio::io::{AsyncReadExt, AsyncWriteExt};
573        use tokio::net::TcpListener;
574
575        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
576        let addr = listener.local_addr().unwrap();
577
578        let server = tokio::spawn(async move {
579            let (mut sock, _) = listener.accept().await.unwrap();
580            let mut buf = [0u8; 8192];
581            let _n = sock.read(&mut buf).await.unwrap();
582
583            let response = r#"{"error":"bad request"}"#;
584            let header = format!(
585                "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
586                response.len(),
587                response
588            );
589            sock.write_all(header.as_bytes()).await.unwrap();
590        });
591
592        let err = fund_account(
593            &format!("http://{}", addr),
594            "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
595        )
596        .await
597        .unwrap_err();
598
599        assert!(err.to_string().contains("400"));
600
601        server.await.unwrap();
602    }
603}