use crate::error::RpcError;
use sdkt_core::NetworkConfig;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Clone)]
pub struct SorobanRpcClient {
endpoint: String,
http_client: reqwest::Client,
}
impl SorobanRpcClient {
pub fn new(endpoint: &str) -> Self {
Self::with_options(endpoint, Some(15), Some(100))
}
pub fn with_options(
endpoint: &str,
timeout_secs: Option<u64>,
pool_max_idle: Option<usize>,
) -> Self {
let mut builder = reqwest::Client::builder();
if let Some(secs) = timeout_secs {
builder = builder.timeout(Duration::from_secs(secs));
}
if let Some(max_idle) = pool_max_idle {
builder = builder.pool_max_idle_per_host(max_idle);
}
let http_client = builder
.pool_idle_timeout(Duration::from_secs(60))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self {
endpoint: endpoint.to_string(),
http_client,
}
}
pub fn from_config(config: &NetworkConfig) -> Self {
Self::with_options(
&config.rpc_url,
config.timeout_secs,
config.pool_max_idle_per_host,
)
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub async fn request<T: serde::de::DeserializeOwned>(
&self,
method: &str,
params: impl Serialize,
) -> Result<T, RpcError> {
let payload = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
});
let mut attempt = 0;
let mut last_err = None;
while attempt < 2 {
match self
.http_client
.post(&self.endpoint)
.json(&payload)
.send()
.await
{
Ok(res) => {
let rpc_res: JsonRpcResponse<T> = match res.json().await {
Ok(json) => json,
Err(e) => return Err(RpcError::Reqwest(e)),
};
if let Some(error) = rpc_res.error {
return Err(RpcError::Rpc(error.message));
}
return rpc_res.result.ok_or_else(|| {
RpcError::Rpc("Missing result in JSON-RPC response".to_string())
});
}
Err(e) => {
if e.is_timeout() || e.is_connect() {
last_err = Some(e);
attempt += 1;
tokio::time::sleep(Duration::from_millis(500)).await;
continue;
}
return Err(RpcError::Reqwest(e));
}
}
}
Err(RpcError::Reqwest(last_err.unwrap()))
}
pub async fn get_health(&self) -> Result<HealthCheck, RpcError> {
self.request("getHealth", ()).await
}
pub async fn get_ledger(&self) -> Result<LedgerInfo, RpcError> {
self.request("getLatestLedger", ()).await
}
pub async fn get_contract_storage(
&self,
_contract_id: &str,
keys: &[String],
) -> Result<StorageResponse, RpcError> {
self.request("getLedgerEntries", serde_json::json!([keys]))
.await
}
}
#[derive(Debug, Deserialize)]
struct JsonRpcResponse<T> {
result: Option<T>,
error: Option<JsonRpcError>,
}
#[derive(Debug, Deserialize)]
struct JsonRpcError {
message: String,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct HealthCheck {
pub status: String,
}
#[derive(Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LedgerInfo {
#[serde(default)]
pub id: String,
#[serde(default)]
pub protocol_version: u32,
pub sequence: u32,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct StorageResponse {
pub entries: Vec<LedgerEntryResult>,
#[serde(rename = "latestLedger")]
pub latest_ledger: u32,
}
#[derive(Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LedgerEntryResult {
pub key: String,
pub xdr: String,
pub last_modified_ledger_seq: u32,
pub live_until_ledger_seq: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
use sdkt_core::NetworkConfig;
#[test]
fn new_sets_endpoint() {
let c = SorobanRpcClient::new("http://localhost:8000");
assert_eq!(c.endpoint(), "http://localhost:8000");
}
#[test]
fn from_config_sets_endpoint() {
let cfg = NetworkConfig {
rpc_url: "https://custom.example.com".to_string(),
passphrase: "test".to_string(),
timeout_secs: None,
pool_max_idle_per_host: None,
};
let c = SorobanRpcClient::from_config(&cfg);
assert_eq!(c.endpoint(), "https://custom.example.com");
}
}