1use crate::error::RpcError;
6use sdkt_core::NetworkConfig;
7use serde::{Deserialize, Serialize};
8use std::time::Duration;
9
10#[derive(Clone)]
14pub struct SorobanRpcClient {
15 endpoint: String,
17 http_client: reqwest::Client,
18}
19
20impl SorobanRpcClient {
21 pub fn new(endpoint: &str) -> Self {
23 Self::with_options(endpoint, Some(15), Some(100))
24 }
25
26 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 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 pub fn endpoint(&self) -> &str {
66 &self.endpoint
67 }
68
69 pub fn http_client(&self) -> &reqwest::Client {
71 &self.http_client
72 }
73
74 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 pub async fn get_health(&self) -> Result<HealthCheck, RpcError> {
130 self.request("getHealth", ()).await
131 }
132
133 pub async fn get_ledger(&self) -> Result<LedgerInfo, RpcError> {
135 self.request("getLatestLedger", ()).await
136 }
137
138 pub async fn get_network(&self) -> Result<NetworkInfo, RpcError> {
140 self.request("getNetwork", ()).await
141 }
142
143 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
160pub 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#[derive(Debug, Deserialize, PartialEq)]
241pub struct HealthCheck {
242 pub status: String,
243}
244
245#[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#[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#[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 #[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 }
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 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 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}