This commit is contained in:
2026-04-22 09:24:08 +02:00
parent e9bcca21cc
commit ba4a4fdfb7
3 changed files with 529 additions and 74 deletions

View File

@@ -5,12 +5,9 @@
//! This module provides a reusable `HttpClient` built on top of `reqwest` for
//! Solana RPC HTTP endpoints.
//!
//! Version `0.4.0` keeps the API intentionally small:
//! - reusable async client
//! - endpoint URL resolution via config
//! - local req/sec + burst limiter
//! - generic JSON-RPC 2.0 request/response handling
//! - a few initial Solana validation helpers
//! Version `0.4.1` extends the `0.4.0` transport layer with:
//! - raw Solana HTTP helpers
//! - typed Solana HTTP helpers using `solana_rpc_client_api` types
/// JSON-RPC 2.0 request envelope for HTTP.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -291,6 +288,20 @@ impl HttpClient {
}
}
/// Executes one JSON-RPC request and returns only the raw `result` field.
pub async fn execute_json_rpc_result_raw(
&self,
method: std::string::String,
params: std::vec::Vec<serde_json::Value>,
) -> Result<serde_json::Value, crate::KbError> {
let raw_result = self.execute_json_rpc_request_raw(method, params).await;
let raw_response = match raw_result {
Ok(raw_response) => raw_response,
Err(error) => return Err(error),
};
Ok(raw_response.result)
}
/// Executes one JSON-RPC request and decodes `result` into `T`.
pub async fn execute_json_rpc_request_typed<T>(
&self,
@@ -300,12 +311,12 @@ impl HttpClient {
where
T: serde::de::DeserializeOwned,
{
let raw_result = self.execute_json_rpc_request_raw(method, params).await;
let raw_response = match raw_result {
Ok(raw_response) => raw_response,
let raw_result = self.execute_json_rpc_result_raw(method, params).await;
let raw_value = match raw_result {
Ok(raw_value) => raw_value,
Err(error) => return Err(error),
};
let typed_result = serde_json::from_value::<T>(raw_response.result);
let typed_result = serde_json::from_value::<T>(raw_value);
match typed_result {
Ok(value) => Ok(value),
Err(error) => Err(crate::KbError::Json(format!(
@@ -314,46 +325,259 @@ impl HttpClient {
}
}
/// Calls `getHealth`.
pub async fn get_health(&self) -> Result<serde_json::Value, crate::KbError> {
let raw_result = self
.execute_json_rpc_request_raw("getHealth".to_string(), std::vec::Vec::new())
.await;
match raw_result {
Ok(response) => Ok(response.result),
Err(error) => Err(error),
}
/// Raw helper for `getHealth`.
pub async fn get_health_raw(&self) -> Result<serde_json::Value, crate::KbError> {
self.execute_json_rpc_result_raw("getHealth".to_string(), std::vec::Vec::new())
.await
}
/// Calls `getVersion`.
pub async fn get_version(&self) -> Result<serde_json::Value, crate::KbError> {
let raw_result = self
.execute_json_rpc_request_raw("getVersion".to_string(), std::vec::Vec::new())
.await;
match raw_result {
Ok(response) => Ok(response.result),
Err(error) => Err(error),
}
/// Typed helper for `getHealth`.
pub async fn get_health(&self) -> Result<std::string::String, crate::KbError> {
self.execute_json_rpc_request_typed::<std::string::String>(
"getHealth".to_string(),
std::vec::Vec::new(),
)
.await
}
/// Calls `getSlot`.
/// Raw helper for `getVersion`.
pub async fn get_version_raw(&self) -> Result<serde_json::Value, crate::KbError> {
self.execute_json_rpc_result_raw("getVersion".to_string(), std::vec::Vec::new())
.await
}
/// Typed helper for `getVersion`.
pub async fn get_version(
&self,
) -> Result<solana_rpc_client_api::response::RpcVersionInfo, crate::KbError> {
self.execute_json_rpc_request_typed::<solana_rpc_client_api::response::RpcVersionInfo>(
"getVersion".to_string(),
std::vec::Vec::new(),
)
.await
}
/// Raw helper for `getSlot`.
pub async fn get_slot_raw(
&self,
config: std::option::Option<serde_json::Value>,
) -> Result<serde_json::Value, crate::KbError> {
let params = kb_build_optional_config_only_params(config);
self.execute_json_rpc_result_raw("getSlot".to_string(), params)
.await
}
/// Typed helper for `getSlot`.
pub async fn get_slot(
&self,
commitment: std::option::Option<std::string::String>,
config: std::option::Option<solana_rpc_client_api::config::RpcContextConfig>,
) -> Result<u64, crate::KbError> {
let config_value_result = kb_serialize_optional_json_value(config, "getSlot config");
let config_value = match config_value_result {
Ok(config_value) => config_value,
Err(error) => return Err(error),
};
let params = kb_build_optional_config_only_params(config_value);
self.execute_json_rpc_request_typed::<u64>("getSlot".to_string(), params)
.await
}
/// Raw helper for `getBlockHeight`.
pub async fn get_block_height_raw(
&self,
config: std::option::Option<serde_json::Value>,
) -> Result<serde_json::Value, crate::KbError> {
let mut params = std::vec::Vec::new();
if let Some(commitment) = commitment {
params.push(serde_json::json!({
"commitment": commitment
}));
}
let raw_result = self
.execute_json_rpc_request_raw("getSlot".to_string(), params)
.await;
match raw_result {
Ok(response) => Ok(response.result),
Err(error) => Err(error),
}
let params = kb_build_optional_config_only_params(config);
self.execute_json_rpc_result_raw("getBlockHeight".to_string(), params)
.await
}
/// Typed helper for `getBlockHeight`.
pub async fn get_block_height(
&self,
config: std::option::Option<solana_rpc_client_api::config::RpcContextConfig>,
) -> Result<u64, crate::KbError> {
let config_value_result = kb_serialize_optional_json_value(config, "getBlockHeight config");
let config_value = match config_value_result {
Ok(config_value) => config_value,
Err(error) => return Err(error),
};
let params = kb_build_optional_config_only_params(config_value);
self.execute_json_rpc_request_typed::<u64>("getBlockHeight".to_string(), params)
.await
}
/// Raw helper for `getLatestBlockhash`.
pub async fn get_latest_blockhash_raw(
&self,
config: std::option::Option<serde_json::Value>,
) -> Result<serde_json::Value, crate::KbError> {
let params = kb_build_optional_config_only_params(config);
self.execute_json_rpc_result_raw("getLatestBlockhash".to_string(), params)
.await
}
/// Typed helper for `getLatestBlockhash`.
pub async fn get_latest_blockhash(
&self,
config: std::option::Option<solana_rpc_client_api::config::RpcContextConfig>,
) -> Result<
solana_rpc_client_api::response::Response<solana_rpc_client_api::response::RpcBlockhash>,
crate::KbError,
> {
let config_value_result =
kb_serialize_optional_json_value(config, "getLatestBlockhash config");
let config_value = match config_value_result {
Ok(config_value) => config_value,
Err(error) => return Err(error),
};
let params = kb_build_optional_config_only_params(config_value);
self.execute_json_rpc_request_typed::<
solana_rpc_client_api::response::Response<
solana_rpc_client_api::response::RpcBlockhash,
>,
>("getLatestBlockhash".to_string(), params)
.await
}
/// Raw helper for `getBalance`.
pub async fn get_balance_raw(
&self,
address: std::string::String,
config: std::option::Option<serde_json::Value>,
) -> Result<serde_json::Value, crate::KbError> {
let params = kb_build_first_string_optional_config_params(address, config);
self.execute_json_rpc_result_raw("getBalance".to_string(), params)
.await
}
/// Typed helper for `getBalance`.
pub async fn get_balance(
&self,
address: std::string::String,
config: std::option::Option<solana_rpc_client_api::config::RpcContextConfig>,
) -> Result<solana_rpc_client_api::response::Response<u64>, crate::KbError> {
let config_value_result = kb_serialize_optional_json_value(config, "getBalance config");
let config_value = match config_value_result {
Ok(config_value) => config_value,
Err(error) => return Err(error),
};
let params = kb_build_first_string_optional_config_params(address, config_value);
self.execute_json_rpc_request_typed::<solana_rpc_client_api::response::Response<u64>>(
"getBalance".to_string(),
params,
)
.await
}
/// Raw helper for `getAccountInfo`.
pub async fn get_account_info_raw(
&self,
address: std::string::String,
config: std::option::Option<serde_json::Value>,
) -> Result<serde_json::Value, crate::KbError> {
let params = kb_build_first_string_optional_config_params(address, config);
self.execute_json_rpc_result_raw("getAccountInfo".to_string(), params)
.await
}
/// Typed helper for `getAccountInfo`.
pub async fn get_account_info(
&self,
address: std::string::String,
config: std::option::Option<solana_rpc_client_api::config::RpcAccountInfoConfig>,
) -> Result<
solana_rpc_client_api::response::Response<
std::option::Option<solana_rpc_client_api::response::UiAccount>,
>,
crate::KbError,
> {
let config_value_result = kb_serialize_optional_json_value(config, "getAccountInfo config");
let config_value = match config_value_result {
Ok(config_value) => config_value,
Err(error) => return Err(error),
};
let params = kb_build_first_string_optional_config_params(address, config_value);
self.execute_json_rpc_request_typed::<solana_rpc_client_api::response::Response<
std::option::Option<solana_rpc_client_api::response::UiAccount>,
>>("getAccountInfo".to_string(), params)
.await
}
/// Raw helper for `getProgramAccounts`.
pub async fn get_program_accounts_raw(
&self,
program_id: std::string::String,
config: std::option::Option<serde_json::Value>,
) -> Result<serde_json::Value, crate::KbError> {
let params = kb_build_first_string_optional_config_params(program_id, config);
self.execute_json_rpc_result_raw("getProgramAccounts".to_string(), params)
.await
}
/// Raw helper for `getSignaturesForAddress`.
pub async fn get_signatures_for_address_raw(
&self,
address: std::string::String,
config: std::option::Option<serde_json::Value>,
) -> Result<serde_json::Value, crate::KbError> {
let params = kb_build_first_string_optional_config_params(address, config);
self.execute_json_rpc_result_raw("getSignaturesForAddress".to_string(), params)
.await
}
/// Typed helper for `getSignaturesForAddress`.
pub async fn get_signatures_for_address(
&self,
address: std::string::String,
config: std::option::Option<solana_rpc_client_api::config::RpcSignaturesForAddressConfig>,
) -> Result<
std::vec::Vec<solana_rpc_client_api::response::RpcConfirmedTransactionStatusWithSignature>,
crate::KbError,
> {
let config_value_result =
kb_serialize_optional_json_value(config, "getSignaturesForAddress config");
let config_value = match config_value_result {
Ok(config_value) => config_value,
Err(error) => return Err(error),
};
let params = kb_build_first_string_optional_config_params(address, config_value);
self.execute_json_rpc_request_typed::<std::vec::Vec<
solana_rpc_client_api::response::RpcConfirmedTransactionStatusWithSignature,
>>("getSignaturesForAddress".to_string(), params)
.await
}
/// Raw helper for `getTransaction`.
pub async fn get_transaction_raw(
&self,
signature: std::string::String,
config: std::option::Option<serde_json::Value>,
) -> Result<serde_json::Value, crate::KbError> {
let params = kb_build_first_string_optional_config_params(signature, config);
self.execute_json_rpc_result_raw("getTransaction".to_string(), params)
.await
}
/// Typed helper for `getTransaction`.
pub async fn get_transaction(
&self,
signature: std::string::String,
config: std::option::Option<solana_rpc_client_api::config::RpcTransactionConfig>,
) -> Result<
std::option::Option<solana_rpc_client_api::response::EncodedTransactionWithStatusMeta>,
crate::KbError,
> {
let config_value_result = kb_serialize_optional_json_value(config, "getTransaction config");
let config_value = match config_value_result {
Ok(config_value) => config_value,
Err(error) => return Err(error),
};
let params = kb_build_first_string_optional_config_params(signature, config_value);
self.execute_json_rpc_request_typed::<
std::option::Option<solana_rpc_client_api::response::EncodedTransactionWithStatusMeta>,
>("getTransaction".to_string(), params)
.await
}
async fn acquire_rate_limit_slot(&self) -> Result<(), crate::KbError> {
@@ -420,7 +644,6 @@ pub fn parse_kb_json_rpc_http_response_value(
));
}
};
let jsonrpc_value_option = object.get("jsonrpc");
let jsonrpc_value = match jsonrpc_value_option {
Some(jsonrpc_value) => jsonrpc_value,
@@ -430,7 +653,6 @@ pub fn parse_kb_json_rpc_http_response_value(
));
}
};
let jsonrpc_string_option = jsonrpc_value.as_str();
let jsonrpc_string = match jsonrpc_string_option {
Some(jsonrpc_string) => jsonrpc_string,
@@ -440,18 +662,15 @@ pub fn parse_kb_json_rpc_http_response_value(
));
}
};
if jsonrpc_string != "2.0" {
return Err(crate::KbError::Json(format!(
"unsupported http json-rpc version '{}'",
jsonrpc_string
)));
}
let has_result = object.contains_key("result");
let has_error = object.contains_key("error");
let has_id = object.contains_key("id");
if has_id && has_result && !has_error {
let response_result = serde_json::from_value::<KbJsonRpcHttpSuccessResponse>(value.clone());
return match response_result {
@@ -461,7 +680,6 @@ pub fn parse_kb_json_rpc_http_response_value(
))),
};
}
if has_id && has_error && !has_result {
let response_result = serde_json::from_value::<KbJsonRpcHttpErrorResponse>(value.clone());
return match response_result {
@@ -476,6 +694,49 @@ pub fn parse_kb_json_rpc_http_response_value(
))
}
fn kb_build_optional_config_only_params(
config: std::option::Option<serde_json::Value>,
) -> std::vec::Vec<serde_json::Value> {
let mut params = std::vec::Vec::new();
if let Some(config) = config {
params.push(config);
}
params
}
fn kb_build_first_string_optional_config_params(
first: std::string::String,
config: std::option::Option<serde_json::Value>,
) -> std::vec::Vec<serde_json::Value> {
let mut params = vec![serde_json::Value::String(first)];
if let Some(config) = config {
params.push(config);
}
params
}
fn kb_serialize_optional_json_value<T>(
value: std::option::Option<T>,
label: &str,
) -> Result<std::option::Option<serde_json::Value>, crate::KbError>
where
T: serde::Serialize,
{
match value {
Some(value) => {
let value_result = serde_json::to_value(value);
match value_result {
Ok(value) => Ok(Some(value)),
Err(error) => Err(crate::KbError::Json(format!(
"cannot serialize {}: {error}",
label
))),
}
}
None => Ok(None),
}
}
fn kb_http_shorten_text(input: &str, max_chars: usize) -> std::string::String {
let char_count = input.chars().count();
if char_count <= max_chars {
@@ -517,7 +778,7 @@ mod tests {
let (mut stream, _peer_addr) = accept_result.expect("accept must succeed");
let observed_methods_for_connection = observed_methods_for_server.clone();
tokio::spawn(async move {
let mut buffer = vec![0u8; 8192];
let mut buffer = vec![0u8; 65536];
let read_result = stream.read(&mut buffer).await;
let bytes_read = read_result.expect("read must succeed");
let request_text =
@@ -562,6 +823,82 @@ mod tests {
"result": 424242u64,
"id": id
}).to_string()
} else if method == "getBlockHeight" {
serde_json::json!({
"jsonrpc": "2.0",
"result": 919191u64,
"id": id
}).to_string()
} else if method == "getLatestBlockhash" {
serde_json::json!({
"jsonrpc": "2.0",
"result": {
"context": {
"slot": 999u64
},
"value": {
"blockhash": "11111111111111111111111111111111",
"lastValidBlockHeight": 12345u64
}
},
"id": id
}).to_string()
} else if method == "getBalance" {
serde_json::json!({
"jsonrpc": "2.0",
"result": {
"context": {
"slot": 77u64
},
"value": 5000u64
},
"id": id
}).to_string()
} else if method == "getSignaturesForAddress" {
serde_json::json!({
"jsonrpc": "2.0",
"result": [
{
"signature": "5h6xBEauJ3PK6SWC7r7J2W8mE1D7aQj4J6Jg8n1SmWnVqSg9H6gq2K7xwJkL2GZ2RZ6n9wYk9cW1b2V3a4d5e6f7",
"slot": 88u64,
"err": null,
"memo": null,
"blockTime": 1700000000i64,
"confirmationStatus": "finalized"
}
],
"id": id
}).to_string()
} else if method == "getTransaction" {
serde_json::json!({
"jsonrpc": "2.0",
"result": null,
"id": id
}).to_string()
} else if method == "getAccountInfo" {
serde_json::json!({
"jsonrpc": "2.0",
"result": {
"context": {
"slot": 55u64
},
"value": {
"data": ["", "base64"],
"executable": false,
"lamports": 1u64,
"owner": "11111111111111111111111111111111",
"rentEpoch": 0u64,
"space": 0u64
}
},
"id": id
}).to_string()
} else if method == "getProgramAccounts" {
serde_json::json!({
"jsonrpc": "2.0",
"result": [],
"id": id
}).to_string()
} else {
serde_json::json!({
"jsonrpc": "2.0",
@@ -602,7 +939,7 @@ mod tests {
}
}
}
fn make_http_endpoint(url: std::string::String) -> crate::KbHttpEndpointConfig {
crate::KbHttpEndpointConfig {
name: "test_http".to_string(),
@@ -664,48 +1001,165 @@ mod tests {
}
#[tokio::test]
async fn get_health_works() {
async fn typed_helpers_work_for_basic_methods() {
let server = TestHttpServer::spawn().await;
let endpoint = make_http_endpoint(server.url.clone());
let client = crate::HttpClient::new(endpoint).expect("client creation must succeed");
let result = client.get_health().await.expect("get_health must succeed");
assert_eq!(result, serde_json::Value::String("ok".to_string()));
let observed_methods = server.observed_methods_snapshot().await;
assert!(observed_methods.iter().any(|method| method == "getHealth"));
server.shutdown().await;
}
#[tokio::test]
async fn get_version_works() {
let server = TestHttpServer::spawn().await;
let endpoint = make_http_endpoint(server.url.clone());
let client = crate::HttpClient::new(endpoint).expect("client creation must succeed");
let result = client
let health = client.get_health().await.expect("get_health must succeed");
assert_eq!(health, "ok".to_string());
let version = client
.get_version()
.await
.expect("get_version must succeed");
assert_eq!(version.solana_core, "2.2.3".to_string());
let slot = client.get_slot(None).await.expect("get_slot must succeed");
assert_eq!(slot, 424242u64);
let block_height = client
.get_block_height(None)
.await
.expect("get_block_height must succeed");
assert_eq!(block_height, 919191u64);
let latest_blockhash = client
.get_latest_blockhash(None)
.await
.expect("get_latest_blockhash must succeed");
assert_eq!(latest_blockhash.context.slot, 999u64);
assert_eq!(
result["solana-core"],
serde_json::Value::String("2.2.3".to_string())
latest_blockhash.value.blockhash,
"11111111111111111111111111111111".to_string()
);
assert_eq!(result["feature-set"], serde_json::Value::from(123u64));
assert_eq!(latest_blockhash.value.last_valid_block_height, 12345u64);
let balance = client
.get_balance("11111111111111111111111111111111".to_string(), None)
.await
.expect("get_balance must succeed");
assert_eq!(balance.context.slot, 77u64);
assert_eq!(balance.value, 5000u64);
let signatures = client
.get_signatures_for_address("11111111111111111111111111111111".to_string(), None)
.await
.expect("get_signatures_for_address must succeed");
assert_eq!(signatures.len(), 1);
assert_eq!(signatures[0].slot, 88u64);
let transaction = client
.get_transaction(
"5h6xBEauJ3PK6SWC7r7J2W8mE1D7aQj4J6Jg8n1SmWnVqSg9H6gq2K7xwJkL2GZ2RZ6n9wYk9cW1b2V3a4d5e6f7".to_string(),
None,
)
.await
.expect("get_transaction must succeed");
assert!(transaction.is_none());
let observed_methods = server.observed_methods_snapshot().await;
assert!(observed_methods.iter().any(|method| method == "getHealth"));
assert!(observed_methods.iter().any(|method| method == "getVersion"));
assert!(observed_methods.iter().any(|method| method == "getSlot"));
assert!(
observed_methods
.iter()
.any(|method| method == "getBlockHeight")
);
assert!(
observed_methods
.iter()
.any(|method| method == "getLatestBlockhash")
);
assert!(observed_methods.iter().any(|method| method == "getBalance"));
assert!(
observed_methods
.iter()
.any(|method| method == "getSignaturesForAddress")
);
assert!(
observed_methods
.iter()
.any(|method| method == "getTransaction")
);
server.shutdown().await;
}
#[tokio::test]
async fn get_slot_works() {
async fn raw_helpers_send_expected_methods() {
let server = TestHttpServer::spawn().await;
let endpoint = make_http_endpoint(server.url.clone());
let client = crate::HttpClient::new(endpoint).expect("client creation must succeed");
let result = client
.get_slot(Some("finalized".to_string()))
let _ = client
.get_health_raw()
.await
.expect("get_slot must succeed");
assert_eq!(result, serde_json::Value::from(424242u64));
.expect("get_health_raw must succeed");
let _ = client
.get_version_raw()
.await
.expect("get_version_raw must succeed");
let _ = client
.get_slot_raw(None)
.await
.expect("get_slot_raw must succeed");
let _ = client
.get_block_height_raw(None)
.await
.expect("get_block_height_raw must succeed");
let _ = client
.get_latest_blockhash_raw(None)
.await
.expect("get_latest_blockhash_raw must succeed");
let _ = client
.get_balance_raw("11111111111111111111111111111111".to_string(), None)
.await
.expect("get_balance_raw must succeed");
let _ = client
.get_account_info_raw("11111111111111111111111111111111".to_string(), None)
.await
.expect("get_account_info_raw must succeed");
let _ = client
.get_program_accounts_raw("11111111111111111111111111111111".to_string(), None)
.await
.expect("get_program_accounts_raw must succeed");
let _ = client
.get_signatures_for_address_raw("11111111111111111111111111111111".to_string(), None)
.await
.expect("get_signatures_for_address_raw must succeed");
let _ = client
.get_transaction_raw(
"5h6xBEauJ3PK6SWC7r7J2W8mE1D7aQj4J6Jg8n1SmWnVqSg9H6gq2K7xwJkL2GZ2RZ6n9wYk9cW1b2V3a4d5e6f7".to_string(),
None,
)
.await
.expect("get_transaction_raw must succeed");
let observed_methods = server.observed_methods_snapshot().await;
assert!(observed_methods.iter().any(|method| method == "getHealth"));
assert!(observed_methods.iter().any(|method| method == "getVersion"));
assert!(observed_methods.iter().any(|method| method == "getSlot"));
assert!(
observed_methods
.iter()
.any(|method| method == "getBlockHeight")
);
assert!(
observed_methods
.iter()
.any(|method| method == "getLatestBlockhash")
);
assert!(observed_methods.iter().any(|method| method == "getBalance"));
assert!(
observed_methods
.iter()
.any(|method| method == "getAccountInfo")
);
assert!(
observed_methods
.iter()
.any(|method| method == "getProgramAccounts")
);
assert!(
observed_methods
.iter()
.any(|method| method == "getSignaturesForAddress")
);
assert!(
observed_methods
.iter()
.any(|method| method == "getTransaction")
);
server.shutdown().await;
}