v0.2.1-pre.005

This commit is contained in:
2026-08-17 20:27:44 +02:00
parent bc71fba289
commit ac1b1033c4
18 changed files with 1284 additions and 49 deletions

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/client.rs
// version: 2
// version: 3
/// Passive runtime availability reported for one logical HTTP endpoint or role.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -163,6 +163,26 @@ impl HttpEndpointSnapshot {
}
}
pub(crate) struct HttpEndpointHttpResponse {
status: u16,
retry_after: std::option::Option<std::time::Duration>,
body: std::vec::Vec<u8>,
}
impl HttpEndpointHttpResponse {
pub(crate) const fn status(&self) -> u16 {
return self.status;
}
pub(crate) const fn retry_after(&self) -> std::option::Option<std::time::Duration> {
return self.retry_after;
}
pub(crate) fn body(&self) -> &[u8] {
return self.body.as_slice();
}
}
/// Shareable logical HTTP endpoint client owned by KSP Transport.
///
/// The underlying `reqwest::Client` owns socket pooling. KSP keeps the configured URL private from diagnostics and exposes only safe routing metadata.
@@ -173,7 +193,7 @@ pub struct HttpEndpointClient {
struct HttpEndpointClientInner {
settings: crate::HttpEndpointSettings,
_client: reqwest::Client,
client: reqwest::Client,
role_runtimes: std::vec::Vec<std::sync::Arc<crate::resilience::HttpRoleRuntime>>,
}
@@ -212,7 +232,7 @@ impl HttpEndpointClient {
role_count = settings.roles().len(),
"created logical HTTP endpoint client"
);
return std::result::Result::Ok(Self { inner: std::sync::Arc::new(HttpEndpointClientInner { settings, _client: client, role_runtimes }) });
return std::result::Result::Ok(Self { inner: std::sync::Arc::new(HttpEndpointClientInner { settings, client, role_runtimes }) });
}
/// Returns the endpoint identity used for safe diagnostics and routing.
@@ -245,6 +265,36 @@ impl HttpEndpointClient {
return self.inner.settings.request_timeout();
}
pub(crate) async fn post_json_rpc(&self, payload: &str, timeout: std::time::Duration) -> ksp_core_lib::Result<HttpEndpointHttpResponse> {
if timeout.is_zero() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, "HTTP JSON-RPC request budget expired before dispatch")
.with_context("endpoint_name", self.name()),
);
}
let send_result = self
.inner
.client
.post(self.inner.settings.url().as_str())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(payload.to_owned())
.timeout(timeout)
.send()
.await;
let response = match send_result {
std::result::Result::Ok(response) => response,
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(self.name(), error)),
};
let status = response.status().as_u16();
let retry_after = parse_retry_after(response.headers());
let body_result = response.bytes().await;
let body = match body_result {
std::result::Result::Ok(body) => body.to_vec(),
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(self.name(), error)),
};
return std::result::Result::Ok(HttpEndpointHttpResponse { status, retry_after, body });
}
/// Returns whether one enabled role can serve the requested capability structurally.
#[must_use]
pub fn supports(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> bool {
@@ -410,6 +460,33 @@ fn fallback_role_snapshot(role: &crate::HttpEndpointRoleSettings, request_kinds:
};
}
fn map_reqwest_error(endpoint_name: &str, error: reqwest::Error) -> ksp_core_lib::Error {
let code = if error.is_timeout() {
crate::ERROR_CODE_TIMEOUT
} else if error.is_connect() {
crate::ERROR_CODE_HTTP_CONNECTION_FAILED
} else {
crate::ERROR_CODE_HTTP_REQUEST_FAILED
};
return ksp_core_lib::Error::new(code, "HTTP JSON-RPC request failed").with_context("endpoint_name", endpoint_name).with_source(error);
}
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> std::option::Option<std::time::Duration> {
let value = match headers.get(reqwest::header::RETRY_AFTER) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::option::Option::None,
};
let text = match value.to_str() {
std::result::Result::Ok(text) => text.trim(),
std::result::Result::Err(_) => return std::option::Option::None,
};
let seconds = match text.parse::<u64>() {
std::result::Result::Ok(seconds) => seconds,
std::result::Result::Err(_) => return std::option::Option::None,
};
return std::option::Option::Some(std::time::Duration::from_secs(seconds));
}
fn build_reqwest_client(settings: &crate::HttpEndpointSettings) -> std::result::Result<reqwest::Client, reqwest::Error> {
let mut builder = reqwest::Client::builder()
.connect_timeout(settings.connect_timeout())

View File

@@ -0,0 +1,232 @@
// file: crates/ksp-onchain-transport-lib/src/executor.rs
// version: 1
const HTTP_REQUEST_TIMEOUT: u16 = 408;
const HTTP_TOO_MANY_REQUESTS: u16 = 429;
const HTTP_INTERNAL_SERVER_ERROR: u16 = 500;
const HTTP_BAD_GATEWAY: u16 = 502;
const HTTP_SERVICE_UNAVAILABLE: u16 = 503;
const HTTP_GATEWAY_TIMEOUT: u16 = 504;
impl crate::HttpTransportPool {
/// Executes one audited standard Solana HTTP JSON-RPC method through KSP routing, admission and bounded retry policy.
///
/// This generic transport surface intentionally returns the raw JSON result. Typed method coverage remains explicit and is provided separately by
/// method-specific KSP adapters.
pub async fn execute_standard_rpc(
&self,
role: &crate::HttpRoleName,
method: &crate::HttpRpcMethodDescriptor,
params: std::vec::Vec<serde_json::Value>,
) -> ksp_core_lib::Result<serde_json::Value> {
let support = method.ensure_runtime_supported();
if let std::result::Result::Err(error) = support {
return std::result::Result::Err(error);
}
let request_id = self.next_request_id();
let request_result = crate::JsonRpcRequest::new(request_id, method.method(), params);
let request = match request_result {
std::result::Result::Ok(request) => request,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let payload_result = request.to_json_string();
let payload = match payload_result {
std::result::Result::Ok(payload) => payload,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let request_kind = crate::HttpRequestKind::new(method.request_kind());
let timeout_result = self.common_request_timeout(role, &request_kind);
let timeout = match timeout_result {
std::result::Result::Ok(timeout) => timeout,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let started = std::time::Instant::now();
let deadline = match started.checked_add(timeout) {
std::option::Option::Some(deadline) => deadline,
std::option::Option::None => return execution_timeout(method, "HTTP JSON-RPC execution deadline could not be represented"),
};
let mut completed_retries = 0_u32;
loop {
let remaining = remaining_budget(deadline);
if remaining.is_zero() {
return execution_timeout(method, "HTTP JSON-RPC execution deadline expired before transport attempt");
}
let permit_result = self.acquire_for_request_kind_with_timeout(role, &request_kind, remaining).await;
let permit = match permit_result {
std::result::Result::Ok(permit) => permit,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let send_timeout = std::cmp::min(remaining_budget(deadline), permit.client().request_timeout());
let response_result = permit.client().post_json_rpc(payload.as_str(), send_timeout).await;
let response = match response_result {
std::result::Result::Ok(response) => response,
std::result::Result::Err(error) => {
permit.record_failure();
let cause = retry_cause_for_error(&error);
let dispatch_state = dispatch_state_for_error(&error);
let decision =
crate::evaluate_transport_retry(method, self.retry_settings(), cause, dispatch_state, completed_retries, std::option::Option::None);
drop(permit);
if let std::option::Option::Some(delay) = decision.delay() {
let waited = wait_retry_delay(delay, deadline).await;
if waited {
completed_retries = completed_retries.saturating_add(1);
continue;
}
return execution_timeout(method, "HTTP JSON-RPC retry delay exceeded the common request deadline");
}
return std::result::Result::Err(error);
},
};
let status = response.status();
if status == HTTP_TOO_MANY_REQUESTS {
let provider_retry_after = response.retry_after();
permit.record_rate_limited(provider_retry_after);
let decision = crate::evaluate_transport_retry(
method,
self.retry_settings(),
crate::HttpRetryCause::RateLimited,
crate::HttpDispatchState::DispatchedAmbiguous,
completed_retries,
provider_retry_after,
);
drop(permit);
if let std::option::Option::Some(delay) = decision.delay() {
let waited = wait_retry_delay(delay, deadline).await;
if waited {
completed_retries = completed_retries.saturating_add(1);
continue;
}
return execution_timeout(method, "HTTP JSON-RPC rate-limit retry exceeded the common request deadline");
}
return rate_limited_error(method, provider_retry_after);
}
if is_temporary_http_status(status) {
permit.record_failure();
let decision = crate::evaluate_transport_retry(
method,
self.retry_settings(),
crate::HttpRetryCause::TemporaryHttp,
crate::HttpDispatchState::DispatchedAmbiguous,
completed_retries,
std::option::Option::None,
);
drop(permit);
if let std::option::Option::Some(delay) = decision.delay() {
let waited = wait_retry_delay(delay, deadline).await;
if waited {
completed_retries = completed_retries.saturating_add(1);
continue;
}
return execution_timeout(method, "HTTP JSON-RPC temporary-status retry exceeded the common request deadline");
}
return http_status_error(method, status);
}
if !(200..300).contains(&status) {
permit.record_success();
return http_status_error(method, status);
}
let response_text = match std::str::from_utf8(response.body()) {
std::result::Result::Ok(text) => text,
std::result::Result::Err(error) => {
permit.record_failure();
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "HTTP JSON-RPC response body is not valid UTF-8")
.with_context("rpc_method", method.method())
.with_source(error),
);
},
};
let parsed_result = crate::parse_json_rpc_response_text(response_text, request_id);
let parsed = match parsed_result {
std::result::Result::Ok(parsed) => parsed,
std::result::Result::Err(error) => {
permit.record_failure();
return std::result::Result::Err(error);
},
};
permit.record_success();
ksp_logging_lib::debug!(
target: env!("CARGO_PKG_NAME"),
endpoint_name = permit.selection().endpoint_name(),
role = permit.selection().role().as_str(),
rpc_method = method.method(),
request_id,
completed_retries,
http_status = status,
"completed Solana HTTP JSON-RPC request"
);
return parsed.into_result();
}
}
}
fn retry_cause_for_error(error: &ksp_core_lib::Error) -> crate::HttpRetryCause {
if error.code() == crate::ERROR_CODE_HTTP_CONNECTION_FAILED {
return crate::HttpRetryCause::Connection;
}
if error.code() == crate::ERROR_CODE_TIMEOUT {
return crate::HttpRetryCause::Timeout;
}
return crate::HttpRetryCause::Request;
}
fn dispatch_state_for_error(error: &ksp_core_lib::Error) -> crate::HttpDispatchState {
if error.code() == crate::ERROR_CODE_HTTP_CONNECTION_FAILED {
return crate::HttpDispatchState::NotDispatched;
}
return crate::HttpDispatchState::DispatchedAmbiguous;
}
const fn is_temporary_http_status(status: u16) -> bool {
return status == HTTP_REQUEST_TIMEOUT
|| status == HTTP_INTERNAL_SERVER_ERROR
|| status == HTTP_BAD_GATEWAY
|| status == HTTP_SERVICE_UNAVAILABLE
|| status == HTTP_GATEWAY_TIMEOUT;
}
fn remaining_budget(deadline: std::time::Instant) -> std::time::Duration {
let now = std::time::Instant::now();
if now >= deadline {
return std::time::Duration::ZERO;
}
return deadline.duration_since(now);
}
async fn wait_retry_delay(delay: std::time::Duration, deadline: std::time::Instant) -> bool {
let remaining = remaining_budget(deadline);
if remaining.is_zero() || delay >= remaining {
return false;
}
tokio::time::sleep(delay).await;
return std::time::Instant::now() < deadline;
}
fn execution_timeout(method: &crate::HttpRpcMethodDescriptor, message: &str) -> ksp_core_lib::Result<serde_json::Value> {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, message).with_context("rpc_method", method.method()));
}
fn rate_limited_error(
method: &crate::HttpRpcMethodDescriptor,
provider_retry_after: std::option::Option<std::time::Duration>,
) -> ksp_core_lib::Result<serde_json::Value> {
let mut error = ksp_core_lib::Error::new(crate::ERROR_CODE_RATE_LIMITED, "Solana HTTP endpoint rate-limited the JSON-RPC request")
.with_context("rpc_method", method.method());
if let std::option::Option::Some(delay) = provider_retry_after {
error = error.with_context("retry_after_seconds", delay.as_secs().to_string());
}
return std::result::Result::Err(error);
}
fn http_status_error(method: &crate::HttpRpcMethodDescriptor, status: u16) -> ksp_core_lib::Result<serde_json::Value> {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "Solana HTTP endpoint returned an unsuccessful status")
.with_context("rpc_method", method.method())
.with_context("http_status", status.to_string()),
);
}
#[cfg(test)]
#[path = "../unit_tests/executor.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 3
// version: 4
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -8,14 +8,16 @@
//!
//! This crate owns runtime HTTP transport settings, Solana HTTP JSON-RPC envelopes and the audited standard method registry. It deliberately remains
//! independent from `ksp-config-lib`, Store and Program layers. Config may later construct these public settings through a one-way adapter. Logical endpoint
//! clients, priority-aware pools, bounded admission limits and retry/no-resend policy are available, while typed Solana method adapters remain staged by
//! subsequent `0.2.1` prereleases.
//! clients, priority-aware pools, bounded admission limits and retry/no-resend policy are available. The first typed Solana HTTP canaries execute real
//! JSON-RPC requests while the remaining audited methods stay staged by subsequent `0.2.x` releases.
mod client;
mod error;
mod executor;
mod json_rpc;
mod pool;
mod resilience;
mod rpc_canary;
mod rpc_method;
mod settings;
@@ -81,6 +83,20 @@ pub use self::resilience::HttpRetryCause;
pub use self::resilience::HttpRetryDecision;
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
pub use self::resilience::evaluate_transport_retry;
/// Optional typed configuration for the `getBalance` canary.
pub use self::rpc_canary::GetBalanceConfig;
/// Typed lamport balance returned by the `getBalance` canary.
pub use self::rpc_canary::GetBalanceResult;
/// Commitment level accepted by the initial typed Solana HTTP canary adapters.
pub use self::rpc_canary::SolanaCommitment;
/// Typed genesis hash returned by the `getGenesisHash` canary.
pub use self::rpc_canary::SolanaGenesisHash;
/// Typed healthy result returned by the `getHealth` canary.
pub use self::rpc_canary::SolanaNodeHealth;
/// Typed software-version response returned by the `getVersion` canary.
pub use self::rpc_canary::SolanaNodeVersion;
/// Typed Solana RPC context used by the initial account canary.
pub use self::rpc_canary::SolanaRpcContext;
/// Functional category used by the audited Solana HTTP JSON-RPC registry.
pub use self::rpc_method::HttpRpcCategory;
/// Release that owns typed KSP coverage for one audited HTTP RPC method.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/pool.rs
// version: 3
// version: 4
/// Safe snapshot of the logical HTTP endpoint pool.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -169,6 +169,7 @@ struct HttpTransportPoolInner {
clients: std::vec::Vec<crate::HttpEndpointClient>,
retry: crate::HttpRetrySettings,
notify: std::sync::Arc<tokio::sync::Notify>,
request_ids: std::sync::atomic::AtomicU64,
cursors: std::sync::Mutex<std::collections::BTreeMap<(std::string::String, std::string::String, u32), usize>>,
}
@@ -194,6 +195,7 @@ impl HttpTransportPool {
clients,
retry: settings.retry().clone(),
notify,
request_ids: std::sync::atomic::AtomicU64::new(1),
cursors: std::sync::Mutex::new(std::collections::BTreeMap::new()),
}),
};
@@ -213,6 +215,14 @@ impl HttpTransportPool {
return &self.inner.retry;
}
pub(crate) fn next_request_id(&self) -> u64 {
let id = self.inner.request_ids.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if id == 0 {
return self.inner.request_ids.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
return id;
}
/// Selects an endpoint for one standard audited RPC method without reserving runtime capacity.
///
/// Request execution should use `acquire_for_method` so rate, cooldown and concurrency limits are enforced.
@@ -424,7 +434,11 @@ impl HttpTransportPool {
return std::time::Instant::now() < deadline;
}
fn common_request_timeout(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> ksp_core_lib::Result<std::time::Duration> {
pub(crate) fn common_request_timeout(
&self,
role: &crate::HttpRoleName,
request_kind: &crate::HttpRequestKind,
) -> ksp_core_lib::Result<std::time::Duration> {
let mut timeout: std::option::Option<std::time::Duration> = std::option::Option::None;
for client in &self.inner.clients {
if client.matching_role(role, request_kind).is_none() {

View File

@@ -0,0 +1,301 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_canary.rs
// version: 1
/// Commitment level accepted by the initial typed Solana HTTP canary adapters.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaCommitment {
/// Query the most recent processed bank.
Processed,
/// Query a bank confirmed by cluster vote.
Confirmed,
/// Query a finalized bank.
Finalized,
}
impl SolanaCommitment {
/// Returns the Solana JSON-RPC commitment string.
#[must_use]
pub const fn as_str(self) -> &'static str {
return match self {
Self::Processed => "processed",
Self::Confirmed => "confirmed",
Self::Finalized => "finalized",
};
}
}
/// Optional typed configuration for `getBalance`.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct GetBalanceConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
min_context_slot: std::option::Option<u64>,
}
impl GetBalanceConfig {
/// Creates an explicit `getBalance` configuration.
#[must_use]
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>, min_context_slot: std::option::Option<u64>) -> Self {
return Self { commitment, min_context_slot };
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Returns the optional minimum context slot.
#[must_use]
pub const fn min_context_slot(&self) -> std::option::Option<u64> {
return self.min_context_slot;
}
fn is_empty(&self) -> bool {
return self.commitment.is_none() && self.min_context_slot.is_none();
}
fn to_json_value(&self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
if let std::option::Option::Some(min_context_slot) = self.min_context_slot {
object.insert("minContextSlot".to_owned(), serde_json::Value::Number(min_context_slot.into()));
}
return serde_json::Value::Object(object);
}
}
/// Typed healthy result returned by the `getHealth` canary.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaNodeHealth {
/// The RPC node returned the stable `"ok"` health result.
Healthy,
}
/// Typed genesis hash returned by the `getGenesisHash` canary.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct SolanaGenesisHash {
value: std::string::String,
}
impl SolanaGenesisHash {
/// Returns the base58-encoded genesis hash text.
#[must_use]
pub fn as_str(&self) -> &str {
return self.value.as_str();
}
}
/// Typed software-version response returned by the `getVersion` canary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaNodeVersion {
solana_core: std::string::String,
feature_set: std::option::Option<u32>,
}
impl SolanaNodeVersion {
/// Returns the node software version string from the `solana-core` field.
#[must_use]
pub fn solana_core(&self) -> &str {
return self.solana_core.as_str();
}
/// Returns the optional runtime feature-set identifier.
#[must_use]
pub const fn feature_set(&self) -> std::option::Option<u32> {
return self.feature_set;
}
}
/// Typed Solana RPC context used by the initial account canary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaRpcContext {
slot: u64,
api_version: std::option::Option<std::string::String>,
}
impl SolanaRpcContext {
/// Returns the context slot reported by the RPC node.
#[must_use]
pub const fn slot(&self) -> u64 {
return self.slot;
}
/// Returns the optional RPC API version reported by the node.
#[must_use]
pub fn api_version(&self) -> std::option::Option<&str> {
return match self.api_version.as_ref() {
std::option::Option::Some(value) => std::option::Option::Some(value.as_str()),
std::option::Option::None => std::option::Option::None,
};
}
}
/// Typed lamport balance returned by the `getBalance` canary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GetBalanceResult {
context: crate::SolanaRpcContext,
value: u64,
}
impl GetBalanceResult {
/// Returns the Solana response context.
#[must_use]
pub const fn context(&self) -> &crate::SolanaRpcContext {
return &self.context;
}
/// Returns the account balance in lamports.
#[must_use]
pub const fn value(&self) -> u64 {
return self.value;
}
}
impl crate::HttpTransportPool {
/// Executes the typed `getHealth` foundation canary.
pub async fn get_health(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaNodeHealth> {
let method_result = canary_descriptor("getHealth");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if value.as_str() == std::option::Option::Some("ok") {
return std::result::Result::Ok(crate::SolanaNodeHealth::Healthy);
}
return invalid_canary_response("getHealth", "result must be exactly the string ok");
}
/// Executes the typed `getGenesisHash` foundation canary.
pub async fn get_genesis_hash(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaGenesisHash> {
let method_result = canary_descriptor("getGenesisHash");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let hash = match value.as_str() {
std::option::Option::Some(hash) => hash,
std::option::Option::None => return invalid_canary_response("getGenesisHash", "result must be a string"),
};
if hash.is_empty() || hash.trim() != hash {
return invalid_canary_response("getGenesisHash", "result must be a non-empty trimmed string");
}
return std::result::Result::Ok(crate::SolanaGenesisHash { value: hash.to_owned() });
}
/// Executes the typed `getVersion` foundation canary.
pub async fn get_version(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaNodeVersion> {
let method_result = canary_descriptor("getVersion");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let decode_result = serde_json::from_value::<WireNodeVersion>(value);
let decoded = match decode_result {
std::result::Result::Ok(decoded) => decoded,
std::result::Result::Err(error) => return invalid_canary_decode("getVersion", error),
};
if decoded.solana_core.is_empty() || decoded.solana_core.trim() != decoded.solana_core {
return invalid_canary_response("getVersion", "solana-core must be a non-empty trimmed string");
}
return std::result::Result::Ok(crate::SolanaNodeVersion { solana_core: decoded.solana_core, feature_set: decoded.feature_set });
}
/// Executes the typed `getBalance` foundation canary.
pub async fn get_balance(
&self,
role: &crate::HttpRoleName,
account: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::GetBalanceConfig>,
) -> ksp_core_lib::Result<crate::GetBalanceResult> {
let method_result = canary_descriptor("getBalance");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut params = std::vec![serde_json::Value::String(account.to_string())];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let decode_result = serde_json::from_value::<WireBalanceResult>(value);
let decoded = match decode_result {
std::result::Result::Ok(decoded) => decoded,
std::result::Result::Err(error) => return invalid_canary_decode("getBalance", error),
};
return std::result::Result::Ok(crate::GetBalanceResult {
context: crate::SolanaRpcContext { slot: decoded.context.slot, api_version: decoded.context.api_version },
value: decoded.value,
});
}
}
#[derive(serde::Deserialize)]
struct WireNodeVersion {
#[serde(rename = "solana-core")]
solana_core: std::string::String,
#[serde(rename = "feature-set", default)]
feature_set: std::option::Option<u32>,
}
#[derive(serde::Deserialize)]
struct WireRpcContext {
slot: u64,
#[serde(rename = "apiVersion", default)]
api_version: std::option::Option<std::string::String>,
}
#[derive(serde::Deserialize)]
struct WireBalanceResult {
context: WireRpcContext,
value: u64,
}
fn canary_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
let descriptor = crate::find_http_rpc_method(method);
return match descriptor {
std::option::Option::Some(descriptor) => std::result::Result::Ok(descriptor),
std::option::Option::None => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed canary descriptor is missing from the audited registry")
.with_context("rpc_method", method),
),
};
}
fn invalid_canary_decode<T>(method: &str, error: serde_json::Error) -> ksp_core_lib::Result<T> {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP canary response has an invalid shape")
.with_context("rpc_method", method)
.with_source(error),
);
}
fn invalid_canary_response<T>(method: &str, message: &str) -> ksp_core_lib::Result<T> {
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method));
}
#[cfg(test)]
#[path = "../unit_tests/rpc_canary.rs"]
mod tests;