From ac1b1033c45005ca032b5ac53f5c8b7797524fc8 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Mon, 17 Aug 2026 20:27:44 +0200 Subject: [PATCH] v0.2.1-pre.005 --- Cargo.toml | 4 +- ROADMAP.md | 4 +- .../tests/workspace_dependencies.rs} | 57 ++-- .../fixtures/http/get_balance.success.json | 1 + .../http/get_genesis_hash.success.json | 1 + .../fixtures/http/get_health.success.json | 1 + .../fixtures/http/get_version.success.json | 1 + .../ksp-onchain-transport-lib/src/client.rs | 83 ++++- .../ksp-onchain-transport-lib/src/executor.rs | 232 ++++++++++++++ crates/ksp-onchain-transport-lib/src/lib.rs | 22 +- crates/ksp-onchain-transport-lib/src/pool.rs | 18 +- .../src/rpc_canary.rs | 301 ++++++++++++++++++ .../tests/public_api.rs | 13 +- .../unit_tests/executor.rs | 136 ++++++++ .../unit_tests/rpc_canary.rs | 137 ++++++++ deltas/0.2.1/pre.005.md | 282 ++++++++++++++++ docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md | 37 ++- docs/rules/RULES_DEPENDENCIES.md | 3 +- 18 files changed, 1284 insertions(+), 49 deletions(-) rename crates/{ksp-onchain-transport-lib/tests/dependency_boundary.rs => ksp-core-lib/tests/workspace_dependencies.rs} (72%) create mode 100644 crates/ksp-onchain-transport-lib/fixtures/http/get_balance.success.json create mode 100644 crates/ksp-onchain-transport-lib/fixtures/http/get_genesis_hash.success.json create mode 100644 crates/ksp-onchain-transport-lib/fixtures/http/get_health.success.json create mode 100644 crates/ksp-onchain-transport-lib/fixtures/http/get_version.success.json create mode 100644 crates/ksp-onchain-transport-lib/src/executor.rs create mode 100644 crates/ksp-onchain-transport-lib/src/rpc_canary.rs create mode 100644 crates/ksp-onchain-transport-lib/unit_tests/executor.rs create mode 100644 crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs create mode 100644 deltas/0.2.1/pre.005.md diff --git a/Cargo.toml b/Cargo.toml index 5f37bed..37c0786 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 102 +# version: 103 [workspace] resolver = "3" members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib"] [workspace.package] -version = "0.2.1-pre.4.fix.2" +version = "0.2.1-pre.5" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/ROADMAP.md b/ROADMAP.md index b5f2a19..c08cec3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,5 @@ - + # Roadmap KSP @@ -41,7 +41,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U ### Cadrage - [X] `0.2.0` — Audit bot3, ordre fonctionnel de `0.2.x`, architecture durable, discipline de sizing et pipeline RAW/CORE/DECODE/SPECIALIZED stabilisés. -- [/] `0.2.1` — HTTP foundation en cours : matrice 52+14, crate/settings/JSON-RPC/registry, client/pool/routing et résilience runtime (RPS/burst, concurrence, cooldown, deadline et retry borné/no-resend) acquis ; 4 canaris, Config standard et clôture restent planifiés. +- [/] `0.2.1` — HTTP foundation en cours : matrice 52+14, crate/settings/JSON-RPC/registry, client/pool/routing, résilience runtime et exécution HTTP acquises ; les 4 canaris typés (`getBalance`, `getGenesisHash`, `getHealth`, `getVersion`) sont matérialisés, Config standard et clôture restent planifiés. ### Releases fonctionnelles décidées/pressenties diff --git a/crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs b/crates/ksp-core-lib/tests/workspace_dependencies.rs similarity index 72% rename from crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs rename to crates/ksp-core-lib/tests/workspace_dependencies.rs index 51784e6..308bc24 100644 --- a/crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs +++ b/crates/ksp-core-lib/tests/workspace_dependencies.rs @@ -1,33 +1,27 @@ -// file: crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs -// version: 5 +// file: crates/ksp-core-lib/tests/workspace_dependencies.rs +// version: 1 -//! Integration canary for the direct dependency firewall of `ksp-onchain-transport-lib`. +//! Workspace-level dependency policy canaries owned by the foundational KSP test surface. -#[test] -fn transport_manifest_preserves_ksp_dependency_firewall() { - let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); - let manifest = std::fs::read_to_string(manifest_path).expect("transport manifest must be readable during integration tests"); - for forbidden in ["ksp-config-lib", "ksp-store-api", "ksp-store-lib", "ksp-program-api", "ksp-program-lib", "tracing =", "tracing."] { - assert!(!manifest.contains(forbidden), "forbidden direct transport dependency detected: {forbidden}"); - } - assert!(manifest.contains("ksp-core-lib")); - assert!(manifest.contains("ksp-logging-lib")); - assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }")); - assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"sync\", \"time\"] }")); - assert!(manifest.contains("[dev-dependencies]")); - assert!(manifest.contains("tokio = { workspace = true, features = [\"rt\"] }")); +fn workspace_root() -> std::path::PathBuf { + let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let parent = manifest_directory.parent(); + assert!(parent.is_some(), "core crate must have a crates directory parent"); + let parent = match parent { + std::option::Option::Some(value) => value, + std::option::Option::None => return manifest_directory.to_path_buf(), + }; + let root = parent.parent(); + assert!(root.is_some(), "core crate must have a workspace root"); + return match root { + std::option::Option::Some(value) => value.to_path_buf(), + std::option::Option::None => parent.to_path_buf(), + }; } #[test] fn workspace_dependency_table_does_not_activate_consumer_features() { - let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_directory.parent().and_then(std::path::Path::parent); - assert!(workspace_root.is_some(), "transport crate must have a workspace root"); - let workspace_root = match workspace_root { - std::option::Option::Some(value) => value, - std::option::Option::None => return, - }; - let manifest_path = workspace_root.join("Cargo.toml"); + let manifest_path = workspace_root().join("Cargo.toml"); let manifest = std::fs::read_to_string(manifest_path.as_path()); assert!(manifest.is_ok(), "workspace manifest must be readable during integration tests"); let manifest = match manifest { @@ -57,3 +51,18 @@ fn workspace_dependency_table_does_not_activate_consumer_features() { } } } + +#[test] +fn transport_manifest_preserves_ksp_dependency_firewall() { + let manifest_path = workspace_root().join("crates/ksp-onchain-transport-lib/Cargo.toml"); + let manifest = std::fs::read_to_string(manifest_path).expect("transport manifest must be readable during workspace integration tests"); + for forbidden in ["ksp-config-lib", "ksp-store-api", "ksp-store-lib", "ksp-program-api", "ksp-program-lib", "tracing =", "tracing."] { + assert!(!manifest.contains(forbidden), "forbidden direct transport dependency detected: {forbidden}"); + } + assert!(manifest.contains("ksp-core-lib")); + assert!(manifest.contains("ksp-logging-lib")); + assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }")); + assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"sync\", \"time\"] }")); + assert!(manifest.contains("[dev-dependencies]")); + assert!(manifest.contains("tokio = { workspace = true, features = [\"rt\"] }")); +} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_balance.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_balance.success.json new file mode 100644 index 0000000..4c286f7 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_balance.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"3.1.8","slot":123456789},"value":424242},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_genesis_hash.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_genesis_hash.success.json new file mode 100644 index 0000000..1eced5e --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_genesis_hash.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":"GH7ome3EiwEr7tu9JuTh2dpYWBJK3z69Xm1ZE3MEE6JC","id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_health.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_health.success.json new file mode 100644 index 0000000..e5253dc --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_health.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":"ok","id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_version.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_version.success.json new file mode 100644 index 0000000..963bfc1 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_version.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"solana-core":"3.1.8","feature-set":2891131721},"id":1} diff --git a/crates/ksp-onchain-transport-lib/src/client.rs b/crates/ksp-onchain-transport-lib/src/client.rs index 4bc1a41..b75999f 100644 --- a/crates/ksp-onchain-transport-lib/src/client.rs +++ b/crates/ksp-onchain-transport-lib/src/client.rs @@ -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, + body: std::vec::Vec, +} + +impl HttpEndpointHttpResponse { + pub(crate) const fn status(&self) -> u16 { + return self.status; + } + + pub(crate) const fn retry_after(&self) -> std::option::Option { + 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>, } @@ -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 { + 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 { + 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::() { + 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 { let mut builder = reqwest::Client::builder() .connect_timeout(settings.connect_timeout()) diff --git a/crates/ksp-onchain-transport-lib/src/executor.rs b/crates/ksp-onchain-transport-lib/src/executor.rs new file mode 100644 index 0000000..11c67b8 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/src/executor.rs @@ -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, + ) -> ksp_core_lib::Result { + 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 { + 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, +) -> ksp_core_lib::Result { + 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 { + 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; diff --git a/crates/ksp-onchain-transport-lib/src/lib.rs b/crates/ksp-onchain-transport-lib/src/lib.rs index 9d56189..9844187 100644 --- a/crates/ksp-onchain-transport-lib/src/lib.rs +++ b/crates/ksp-onchain-transport-lib/src/lib.rs @@ -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. diff --git a/crates/ksp-onchain-transport-lib/src/pool.rs b/crates/ksp-onchain-transport-lib/src/pool.rs index 02b4a2b..318ab85 100644 --- a/crates/ksp-onchain-transport-lib/src/pool.rs +++ b/crates/ksp-onchain-transport-lib/src/pool.rs @@ -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, retry: crate::HttpRetrySettings, notify: std::sync::Arc, + request_ids: std::sync::atomic::AtomicU64, cursors: std::sync::Mutex>, } @@ -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 { + pub(crate) fn common_request_timeout( + &self, + role: &crate::HttpRoleName, + request_kind: &crate::HttpRequestKind, + ) -> ksp_core_lib::Result { let mut timeout: std::option::Option = std::option::Option::None; for client in &self.inner.clients { if client.matching_role(role, request_kind).is_none() { diff --git a/crates/ksp-onchain-transport-lib/src/rpc_canary.rs b/crates/ksp-onchain-transport-lib/src/rpc_canary.rs new file mode 100644 index 0000000..2dd8c13 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/src/rpc_canary.rs @@ -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, + min_context_slot: std::option::Option, +} + +impl GetBalanceConfig { + /// Creates an explicit `getBalance` configuration. + #[must_use] + pub const fn new(commitment: std::option::Option, min_context_slot: std::option::Option) -> Self { + return Self { commitment, min_context_slot }; + } + + /// Returns the optional commitment level. + #[must_use] + pub const fn commitment(&self) -> std::option::Option { + return self.commitment; + } + + /// Returns the optional minimum context slot. + #[must_use] + pub const fn min_context_slot(&self) -> std::option::Option { + 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, +} + +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 { + 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, +} + +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 { + 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 { + 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 { + 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::(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 { + 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::(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, +} + +#[derive(serde::Deserialize)] +struct WireRpcContext { + slot: u64, + #[serde(rename = "apiVersion", default)] + api_version: std::option::Option, +} + +#[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(method: &str, error: serde_json::Error) -> ksp_core_lib::Result { + 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(method: &str, message: &str) -> ksp_core_lib::Result { + 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; diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index cc93e00..72f16c7 100644 --- a/crates/ksp-onchain-transport-lib/tests/public_api.rs +++ b/crates/ksp-onchain-transport-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/tests/public_api.rs -// version: 4 +// version: 5 //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. @@ -160,3 +160,14 @@ async fn public_async_admission_exposes_bounded_permit_without_endpoint_url() { assert!(!rendered.contains("ASYNC-SECRET-CANARY")); assert!(!rendered.contains("provider.invalid")); } + +#[test] +fn public_typed_canary_contracts_are_available_from_crate_root() { + let config = ksp_onchain_transport_lib::GetBalanceConfig::new( + std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed), + std::option::Option::Some(42), + ); + assert_eq!(config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed)); + assert_eq!(config.min_context_slot(), std::option::Option::Some(42)); + assert_eq!(ksp_onchain_transport_lib::SolanaNodeHealth::Healthy, ksp_onchain_transport_lib::SolanaNodeHealth::Healthy); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/executor.rs b/crates/ksp-onchain-transport-lib/unit_tests/executor.rs new file mode 100644 index 0000000..547045f --- /dev/null +++ b/crates/ksp-onchain-transport-lib/unit_tests/executor.rs @@ -0,0 +1,136 @@ +// file: crates/ksp-onchain-transport-lib/unit_tests/executor.rs +// version: 1 + +fn pool_for_url(url: &str, request_timeout: std::time::Duration, max_retries: u32) -> crate::HttpTransportPool { + let role = crate::HttpEndpointRoleSettings::new( + crate::HttpRoleName::new("default"), + true, + std::vec![crate::HttpRequestKind::wildcard()], + 10, + crate::HttpRoleLimits::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(std::time::Duration::from_millis(1)), + ), + ); + let endpoint = crate::HttpEndpointSettings::new( + "fixture", + true, + crate::HttpProviderName::new("fixture"), + crate::HttpClusterName::new("local"), + crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"), + std::time::Duration::from_millis(100), + request_timeout, + std::option::Option::Some(1), + std::vec![role], + ); + let settings = crate::HttpTransportSettings::new( + std::vec![endpoint], + crate::HttpRetrySettings::new(max_retries, std::time::Duration::from_millis(1), std::time::Duration::from_millis(2)), + ); + return crate::HttpTransportPool::new(settings).expect("fixture pool must build"); +} + +fn health_method() -> &'static crate::HttpRpcMethodDescriptor { + return crate::find_http_rpc_method("getHealth").expect("getHealth descriptor must exist"); +} + +fn serve_rate_limit_then_success() -> (std::string::String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind"); + let address = listener.local_addr().expect("fixture listener address must resolve"); + let handle = std::thread::spawn(move || { + let mut count = 0_usize; + while count < 2 { + let (mut stream, _) = listener.accept().expect("fixture server must accept request"); + let _ = read_request(&mut stream); + let response = if count == 0 { + "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_owned() + } else { + let body = include_str!("../fixtures/http/get_health.success.json"); + format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body) + }; + std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write"); + count = count.saturating_add(1); + } + return count; + }); + return (format!("http://{address}"), handle); +} + +fn serve_timeout() -> (std::string::String, std::thread::JoinHandle<()>) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind"); + let address = listener.local_addr().expect("fixture listener address must resolve"); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("fixture server must accept request"); + let _ = read_request(&mut stream); + std::thread::sleep(std::time::Duration::from_millis(100)); + return; + }); + return (format!("http://{address}"), handle); +} + +fn read_request(stream: &mut std::net::TcpStream) -> std::string::String { + let mut bytes = std::vec::Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let count = std::io::Read::read(stream, &mut buffer).expect("fixture request must read"); + if count == 0 { + break; + } + bytes.extend_from_slice(&buffer[..count]); + if request_complete(bytes.as_slice()) { + break; + } + } + return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8"); +} + +fn request_complete(bytes: &[u8]) -> bool { + let text = match std::str::from_utf8(bytes) { + std::result::Result::Ok(text) => text, + std::result::Result::Err(_) => return false, + }; + let header_end = match text.find("\r\n\r\n") { + std::option::Option::Some(value) => value, + std::option::Option::None => return false, + }; + let mut content_length = 0_usize; + for line in text[..header_end].lines() { + let (name, value) = match line.split_once(':') { + std::option::Option::Some(parts) => parts, + std::option::Option::None => continue, + }; + if name.eq_ignore_ascii_case("content-length") { + content_length = value.trim().parse::().expect("content length must parse"); + } + } + return bytes.len() >= header_end.saturating_add(4).saturating_add(content_length); +} + +#[tokio::test(flavor = "current_thread")] +async fn executor_applies_retry_after_and_retries_http_429_for_retry_safe_method() { + let (url, handle) = serve_rate_limit_then_success(); + let pool = pool_for_url(url.as_str(), std::time::Duration::from_millis(500), 1); + let result = pool + .execute_standard_rpc(&crate::HttpRoleName::new("default"), health_method(), std::vec::Vec::new()) + .await + .expect("retry-safe request must recover from one 429"); + assert_eq!(result, serde_json::json!("ok")); + assert_eq!(handle.join().expect("fixture server must join"), 2); + let snapshot = pool.snapshot(); + assert_eq!(snapshot.endpoints()[0].roles()[0].rate_limit_count(), 1); + assert_eq!(snapshot.endpoints()[0].roles()[0].success_count(), 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn executor_maps_reqwest_timeout_to_ksp_timeout_error() { + let (url, handle) = serve_timeout(); + let pool = pool_for_url(url.as_str(), std::time::Duration::from_millis(20), 0); + let error = pool + .execute_standard_rpc(&crate::HttpRoleName::new("default"), health_method(), std::vec::Vec::new()) + .await + .expect_err("timed out request must fail"); + assert_eq!(error.code(), crate::ERROR_CODE_TIMEOUT); + handle.join().expect("fixture server must join"); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs b/crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs new file mode 100644 index 0000000..3be90bc --- /dev/null +++ b/crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs @@ -0,0 +1,137 @@ +// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs +// version: 1 + +fn pool_for_url(url: &str) -> crate::HttpTransportPool { + let role = crate::HttpEndpointRoleSettings::new( + crate::HttpRoleName::new("default"), + true, + std::vec![crate::HttpRequestKind::wildcard()], + 10, + crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None), + ); + let endpoint = crate::HttpEndpointSettings::new( + "fixture", + true, + crate::HttpProviderName::new("fixture"), + crate::HttpClusterName::new("local"), + crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"), + std::time::Duration::from_secs(1), + std::time::Duration::from_secs(1), + std::option::Option::Some(1), + std::vec![role], + ); + let settings = crate::HttpTransportSettings::new( + std::vec![endpoint], + crate::HttpRetrySettings::new(0, std::time::Duration::from_millis(1), std::time::Duration::from_millis(1)), + ); + return crate::HttpTransportPool::new(settings).expect("fixture pool must build"); +} + +fn serve_once(body: &'static str) -> (std::string::String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind"); + let address = listener.local_addr().expect("fixture listener address must resolve"); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("fixture server must accept one request"); + let request = read_request(&mut stream); + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body); + std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write"); + return request; + }); + return (format!("http://{address}"), handle); +} + +fn read_request(stream: &mut std::net::TcpStream) -> std::string::String { + let mut bytes = std::vec::Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let count = std::io::Read::read(stream, &mut buffer).expect("fixture request must read"); + if count == 0 { + break; + } + bytes.extend_from_slice(&buffer[..count]); + if request_complete(bytes.as_slice()) { + break; + } + } + return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8"); +} + +fn request_complete(bytes: &[u8]) -> bool { + let text = match std::str::from_utf8(bytes) { + std::result::Result::Ok(text) => text, + std::result::Result::Err(_) => return false, + }; + let header_end = match text.find("\r\n\r\n") { + std::option::Option::Some(value) => value, + std::option::Option::None => return false, + }; + let mut content_length = 0_usize; + for line in text[..header_end].lines() { + let (name, value) = match line.split_once(':') { + std::option::Option::Some(parts) => parts, + std::option::Option::None => continue, + }; + if name.eq_ignore_ascii_case("content-length") { + content_length = value.trim().parse::().expect("content length must parse"); + } + } + return bytes.len() >= header_end.saturating_add(4).saturating_add(content_length); +} + +fn request_body(request: &str) -> serde_json::Value { + let body = request.split("\r\n\r\n").nth(1).expect("fixture request body must exist"); + return serde_json::from_str(body).expect("fixture request body must be JSON"); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_health_executes_real_http_fixture() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_health.success.json")); + let pool = pool_for_url(url.as_str()); + let result = pool.get_health(&crate::HttpRoleName::new("default")).await.expect("getHealth fixture must succeed"); + assert_eq!(result, crate::SolanaNodeHealth::Healthy); + let request = handle.join().expect("fixture server must join"); + let body = request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("getHealth")); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_genesis_hash_executes_real_http_fixture() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_genesis_hash.success.json")); + let pool = pool_for_url(url.as_str()); + let result = pool.get_genesis_hash(&crate::HttpRoleName::new("default")).await.expect("getGenesisHash fixture must succeed"); + assert_eq!(result.as_str(), "GH7ome3EiwEr7tu9JuTh2dpYWBJK3z69Xm1ZE3MEE6JC"); + let request = handle.join().expect("fixture server must join"); + assert_eq!(request_body(request.as_str())["method"], serde_json::json!("getGenesisHash")); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_version_executes_real_http_fixture() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_version.success.json")); + let pool = pool_for_url(url.as_str()); + let result = pool.get_version(&crate::HttpRoleName::new("default")).await.expect("getVersion fixture must succeed"); + assert_eq!(result.solana_core(), "3.1.8"); + assert_eq!(result.feature_set(), std::option::Option::Some(2_891_131_721)); + let request = handle.join().expect("fixture server must join"); + assert_eq!(request_body(request.as_str())["method"], serde_json::json!("getVersion")); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_balance_executes_real_http_fixture_and_encodes_config() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_balance.success.json")); + let pool = pool_for_url(url.as_str()); + let account = "11111111111111111111111111111111".parse::().expect("system address must parse"); + let config = crate::GetBalanceConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(123)); + let result = pool + .get_balance(&crate::HttpRoleName::new("default"), &account, std::option::Option::Some(&config)) + .await + .expect("getBalance fixture must succeed"); + assert_eq!(result.value(), 424_242); + assert_eq!(result.context().slot(), 123_456_789); + assert_eq!(result.context().api_version(), std::option::Option::Some("3.1.8")); + let request = handle.join().expect("fixture server must join"); + let body = request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("getBalance")); + assert_eq!(body["params"][0], serde_json::json!("11111111111111111111111111111111")); + assert_eq!(body["params"][1]["commitment"], serde_json::json!("finalized")); + assert_eq!(body["params"][1]["minContextSlot"], serde_json::json!(123)); +} diff --git a/deltas/0.2.1/pre.005.md b/deltas/0.2.1/pre.005.md new file mode 100644 index 0000000..10e77ba --- /dev/null +++ b/deltas/0.2.1/pre.005.md @@ -0,0 +1,282 @@ + + + +# Delta `v0.2.1-pre.005` + +## Base + +Base attendue : + +```text +v0.2.1-pre.004-fix.002 +``` + +Cette base a été validée localement par le user avec `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-onchain-transport-lib` et `cargo test --workspace`. + +Version Cargo cible : + +```text +0.2.1-pre.5 +``` + +## Objectif + +Matérialiser la première exécution réseau HTTP complète de `ksp-onchain-transport-lib` et les quatre méthodes typées canari assignées à `0.2.1` : + +```text +getHealth +getVersion +getGenesisHash +getBalance +``` + +La tranche doit également corriger l'ownership des canaries de dépendances générales : les contrôles portant sur le workspace entier ne restent pas dans la crate métier Transport et sont centralisés dans la surface de tests de `ksp-core-lib` tant qu'aucun outil d'audit dédié n'existe. + +## Vérification de la surface Solana + +La documentation Solana HTTP officielle a été revérifiée le 17 août 2026 pour les quatre canaris : + +- `getHealth` : aucun paramètre, résultat stable `"ok"` lorsque le node est sain ; +- `getGenesisHash` : aucun paramètre, résultat string base58 ; +- `getVersion` : aucun paramètre, objet contenant `solana-core` et `feature-set` optionnel/null ; +- `getBalance` : pubkey base58 obligatoire, configuration optionnelle `commitment` / `minContextSlot`, réponse `{ context, value }` où `value` est le solde en lamports. + +## Exécuteur HTTP JSON-RPC + +Nouvelle surface publique : + +```text +HttpTransportPool::execute_standard_rpc(...) +``` + +L'exécuteur : + +1. vérifie le descriptor RPC audité et son support runtime ; +2. alloue un identifiant JSON-RPC numérique KSP ; +3. construit et sérialise `JsonRpcRequest` sans exposer les params dans `Debug` ; +4. dérive le request-kind depuis le registre ; +5. fixe une deadline commune à partir du plus petit `request_timeout` des endpoints compatibles ; +6. acquiert un `HttpRequestPermit`, donc respecte rôles/capabilities/priorité/fairness/RPS/burst/concurrence/cooldown ; +7. exécute un POST `application/json` via le `reqwest::Client` privé de l'endpoint ; +8. mappe connexion, timeout, HTTP status, JSON invalide, protocole JSON-RPC et RPC application error vers les codes KSP existants ; +9. met à jour la santé passive du rôle ; +10. applique le retry/backoff centralisé sans dépasser la deadline commune. + +Les retries libèrent le permit précédent puis réacquièrent explicitement capacité RPS/concurrence : ils ne contournent donc pas les limites runtime du pool. + +## HTTP status, `Retry-After` et retry + +Le raccordement réseau de la résilience est désormais effectif : + +- `429` -> `record_rate_limited`, cooldown de rôle et `HttpRetryCause::RateLimited` ; +- `Retry-After` sous forme HTTP delta-seconds -> `Duration`, puis borne défensive déjà possédée par la résilience ; +- `408`, `500`, `502`, `503`, `504` -> `HttpRetryCause::TemporaryHttp` ; +- erreur de connexion reqwest -> `HttpRetryCause::Connection` + `NotDispatched` ; +- timeout reqwest -> `HttpRetryCause::Timeout` + `DispatchedAmbiguous` ; +- autres erreurs request -> non retryables automatiquement ; +- RPC application error et réponse invalide restent hors retry transport. + +Le parsing d'une forme HTTP-date de `Retry-After` n'est pas introduit dans cette tranche : si le header n'est pas un delta-seconds valide, le cooldown local configuré/fallback reste utilisé. + +La règle `NeverAfterDispatch` reste centralisée dans `evaluate_transport_retry()` ; l'exécuteur générique ne la contourne pas et prépare donc correctement les futurs appels write/submission de `0.2.3`. + +## Quatre canaris typés + +### `getHealth` + +Nouvelle méthode : + +```text +HttpTransportPool::get_health(...) +``` + +Résultat public : + +```text +SolanaNodeHealth::Healthy +``` + +Toute autre forme qu'un résultat string exact `"ok"` est classée `invalid_response`. + +### `getGenesisHash` + +Nouvelle méthode : + +```text +HttpTransportPool::get_genesis_hash(...) +``` + +Résultat public : + +```text +SolanaGenesisHash +``` + +Le wrapper exige un string non vide et trimé et n'utilise pas abusivement `Pubkey` pour représenter sémantiquement un hash de genesis. + +### `getVersion` + +Nouvelle méthode : + +```text +HttpTransportPool::get_version(...) +``` + +Résultat public : + +```text +SolanaNodeVersion + solana_core: String + feature_set: Option +``` + +### `getBalance` + +Nouvelle méthode : + +```text +HttpTransportPool::get_balance(...) +``` + +L'adresse est reçue sous forme `ksp_core_lib::Pubkey`, conformément à l'ownership Core du type Solana bas niveau. + +Nouveaux contrats : + +```text +SolanaCommitment +GetBalanceConfig +SolanaRpcContext +GetBalanceResult +``` + +`GetBalanceConfig` encode uniquement les champs présents ; un config vide n'ajoute pas un second paramètre inutile. + +## Fixtures déterministes + +Nouvelles fixtures : + +```text +crates/ksp-onchain-transport-lib/fixtures/http/get_health.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_genesis_hash.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_version.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_balance.success.json +``` + +Les tests utilisent un serveur TCP local déterministe pour exercer réellement le POST HTTP sans dépendre de Devnet ou d'Internet. + +Ils vérifient notamment : + +- les quatre réponses typées ; +- le nom de méthode envoyé ; +- les params `getBalance`, dont `commitment` et `minContextSlot` ; +- un cycle `429 + Retry-After: 0 -> retry -> succès` ; +- le mapping d'un timeout reqwest vers `ERROR_CODE_TIMEOUT`. + +Le smoke réseau réel reste opt-in et appartient toujours à `pre.007`. + +## Centralisation des canaries de dépendances + +Le fichier suivant est supprimé : + +```text +crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs +``` + +Les deux contrôles qu'il contenait sont transférés vers : + +```text +crates/ksp-core-lib/tests/workspace_dependencies.rs +``` + +Ils continuent de vérifier : + +- absence d'activation `features = [...]` sous `[workspace.dependencies]` ; +- maintien de `default-features` au root ; +- firewall direct Transport -> pas de Config/Store/Program/`tracing` ; +- feature-set direct attendu de `reqwest` et `tokio` dans le manifest Transport. + +Nouvelle règle `DEP-CARGO-007` : les canaries qui inspectent une politique générale du workspace ou plusieurs manifests appartiennent à une surface de gouvernance/fondation, actuellement `ksp-core-lib`, et non à une crate métier spécialisée. + +## ROADMAP et plan + +`ROADMAP.md` reçoit uniquement la mise à jour synthétique normale de la release : exécution HTTP et quatre canaris deviennent acquis ; Config standard et clôture restent à venir. + +Le plan `008` marque `pre.005` réalisé et ajoute son état détaillé. L'historique technique reste dans `deltas/`. + +## Tests + +Après déplacement des deux canaries workspace hors de Transport et ajout des nouveaux tests : + +```text +ksp-onchain-transport-lib : 78 tests Rust déclarés +ksp-core-lib : +2 tests d'intégration workspace_dependencies +``` + +La suite Transport reste propriétaire uniquement des tests fonctionnels/API de son domaine ; les canaries workspace générales sont exécutées via Core lors du `cargo test --workspace`. + +## Dépendances + +Aucune nouvelle dépendance externe et aucune nouvelle feature externe ne sont introduites. + +La politique de `pre.004-fix.001/.002` reste inchangée : versions/default-features au root, features d'usage dans chaque crate consommatrice. + +## Fichiers ajoutés + +```text +crates/ksp-core-lib/tests/workspace_dependencies.rs +crates/ksp-onchain-transport-lib/fixtures/http/get_balance.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_genesis_hash.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_health.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_version.success.json +crates/ksp-onchain-transport-lib/src/executor.rs +crates/ksp-onchain-transport-lib/src/rpc_canary.rs +crates/ksp-onchain-transport-lib/unit_tests/executor.rs +crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs +deltas/0.2.1/pre.005.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +ROADMAP.md +crates/ksp-onchain-transport-lib/src/client.rs +crates/ksp-onchain-transport-lib/src/lib.rs +crates/ksp-onchain-transport-lib/src/pool.rs +crates/ksp-onchain-transport-lib/tests/public_api.rs +docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md +docs/rules/RULES_DEPENDENCIES.md +``` + +## Fichiers supprimés + +```text +crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs +``` + +## Validation + +Non exécutée dans le sandbox de génération : `cargo` et `rustc` n'y sont pas installés. + +Après application : + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-onchain-transport-lib +cargo test -p ksp-core-lib +cargo test --workspace +``` + +Aucune dépendance/feature n'ayant changé, un `cargo tree` complet n'est pas requis pour cette tranche. Il peut néanmoins être rejoué au checkpoint final `pre.007` comme prévu. + +## Suite + +Tranche suivante prévue : + +```text +0.2.1-pre.006 +``` + +Périmètre : document/schema/exemple Config standard `std.transport`, enregistrement dans Config, adapter Config -> Transport et tests de sensibilité/provenance/env sans créer de dépendance inverse Transport -> Config. diff --git a/docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md b/docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md index 87fa7e1..f4013d7 100644 --- a/docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md +++ b/docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md @@ -1,5 +1,5 @@ - + # `0.2.1-pre.001` — plan `ksp-onchain-transport-lib` HTTP Solana foundation @@ -867,15 +867,15 @@ La release peut devenir stable seulement si : ## 22. Prévision souple des prereleases de la `0.2.1` réduite -| Tranche | Objectif | -|-----------|----------------------------------------------------------------------------------------------------------------------| -| `pre.001` | audit KSP + bot3 + docs officielles, matrice 52+14, architecture, split et sizing | -| `pre.002` | **réalisé** : crate/workspace, codes erreur, settings/validation, JSON-RPC, descriptors/status, base Logging | -| `pre.003` | **réalisé** : endpoint client + pool logique + rôles/capabilities + priorité/fairness/fallback + snapshots sûrs | -| `pre.004` | **réalisé** : RPS/burst/concurrence/cooldown + deadline commune + retry/backoff + classification retry/no-resend | -| `pre.005` | méthodes canari `getHealth`, `getVersion`, `getGenesisHash`, `getBalance` + fixtures déterministes | -| `pre.006` | `std.transport` schema/document/example + registry Config + adapter Config -> Transport + sensibilité/env tests | -| `pre.007` | completeness/canaries, smoke opt-in, `cargo tree`, README/USAGE, docs finales, prompt `0.2.2`, préparation `rel.001` | +| Tranche | Objectif | +|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `pre.001` | audit KSP + bot3 + docs officielles, matrice 52+14, architecture, split et sizing | +| `pre.002` | **réalisé** : crate/workspace, codes erreur, settings/validation, JSON-RPC, descriptors/status, base Logging | +| `pre.003` | **réalisé** : endpoint client + pool logique + rôles/capabilities + priorité/fairness/fallback + snapshots sûrs | +| `pre.004` | **réalisé** : RPS/burst/concurrence/cooldown + deadline commune + retry/backoff + classification retry/no-resend | +| `pre.005` | **réalisé** : exécution HTTP JSON-RPC + `getHealth`, `getVersion`, `getGenesisHash`, `getBalance` + fixtures déterministes + centralisation des canaries workspace dans Core | +| `pre.006` | `std.transport` schema/document/example + registry Config + adapter Config -> Transport + sensibilité/env tests | +| `pre.007` | completeness/canaries, smoke opt-in, `cargo tree`, README/USAGE, docs finales, prompt `0.2.2`, préparation `rel.001` | Ce découpage est révisable si une tranche dépasse le budget ; la release réduite, contrairement au scope initial, reste raisonnablement clôturable dans la session. @@ -927,13 +927,28 @@ Restent volontairement à `pre.004` : token bucket RPS/burst, semaphore de concu - snapshots sûrs enrichis avec availability de rôle, limites, requêtes en vol, cooldown restant et compteurs success/failure/rate-limit, sans URL ni secret ; - transitions passives `Available -> Degraded/RateLimited` sur observations runtime et retour `Degraded -> Available` après succès ; - policy centralisée `evaluate_transport_retry()` : backoff exponentiel borné, budget `max_retries`, causes transport explicitement retryables, RPC application errors hors retry, et respect strict de `TransportRetryClass::NeverAfterDispatch` pour empêcher un resend automatique après dispatch ambigu ; -- `tokio` est consommé directement par Transport uniquement via la dépendance workspace avec feature locale `sync`; `time`/runtime restent fournis par le feature-set workspace existant ; +- après `pre.004-fix.001/.002`, les features d’usage sont activées localement par crate : Transport porte `reqwest/rustls`, `serde/derive`, `tokio/macros+sync+time` et `tokio/rt` seulement en dev ; le root conserve version/default-features sans activation de features d’usage ; - la suite Transport compte désormais **72 tests déclarés**, avec preuves supplémentaires sur limiter, concurrence, cooldown, deadline, fallback runtime, santé passive, policy de retry et consommation publique de l'admission async. La tranche ne parse pas encore elle-même le header HTTP `Retry-After` et n'exécute pas de POST JSON-RPC : ces signaux seront raccordés à l'exécuteur HTTP avec les méthodes canari de `pre.005`. Restent à `pre.005` : exécution HTTP JSON-RPC réelle et wrappers typés `getHealth`, `getVersion`, `getGenesisHash`, `getBalance`, avec fixtures déterministes et raccordement des erreurs HTTP/429/timeout à la résilience maintenant disponible. +### 22.4 État après `0.2.1-pre.005` + +`pre.005` matérialise la première exécution réseau HTTP complète et les quatre canaris typés de la release : + +- `workspace.package.version = 0.2.1-pre.5` ; +- exécuteur générique `execute_standard_rpc` : allocation d'id JSON-RPC KSP, admission via pool, POST `application/json`, deadline commune, mapping connexion/timeout/status HTTP, parsing JSON-RPC et mise à jour de santé passive ; +- HTTP `429` raccordé au cooldown de rôle et à la policy de retry avec parsing défensif de `Retry-After` sous forme delta-seconds ; statuts temporaires `408/500/502/503/504` raccordés au retry borné ; +- les retries réacquièrent capacité RPS/concurrence et restent bornés par la même deadline ; la classification `NeverAfterDispatch` reste centralisée pour empêcher les futurs write/submission de se resoumettre après ambiguïté ; +- wrappers typés `getHealth`, `getGenesisHash`, `getVersion`, `getBalance`, avec `ksp_core_lib::Pubkey`, commitment/minContextSlot optionnels, DTO de contexte/balance/version et validation de forme ; +- fixtures JSON déterministes et serveur HTTP local de tests : les canaris prouvent le POST réel et les formes request/response ; tests supplémentaires sur récupération après 429 et timeout reqwest ; +- les deux canaries de dépendances générales quittent `ksp-onchain-transport-lib/tests/dependency_boundary.rs` et sont centralisées sous `ksp-core-lib/tests/workspace_dependencies.rs`, conformément à `DEP-CARGO-007` ; +- Transport compte désormais **78 tests déclarés** ; Core porte 2 canaries workspace supplémentaires dans sa surface d'intégration. + +Restent à `pre.006` : document/schema/exemple `std.transport`, enregistrement Config, adapter Config -> Transport et tests de sensibilité/provenance/env. + ## 23. Séquence `0.2.x` recalibrée ```text diff --git a/docs/rules/RULES_DEPENDENCIES.md b/docs/rules/RULES_DEPENDENCIES.md index 0f843a4..4657daf 100644 --- a/docs/rules/RULES_DEPENDENCIES.md +++ b/docs/rules/RULES_DEPENDENCIES.md @@ -1,5 +1,5 @@ - + # Règles des dépendances KSP @@ -31,6 +31,7 @@ Elles complètent les règles Rust générales et le graphe de `docs/architectur - **DEP-CARGO-004** — Lorsqu'une génération majeure/mineure compatible est retenue, KSP exprime explicitement l'intention sous forme caret `^M.m` (par exemple `^4.3`) plutôt qu'avec une écriture patch telle que `4.3.0`. Même si Cargo interprète aussi par défaut cette dernière comme une contrainte compatible caret, KSP normalise la syntaxe pour rendre l'intention manifeste. Un pin exact `=M.m.p` ou un bornage différent requiert une justification explicite. - **DEP-CARGO-005** — Le `Cargo.lock` résout la version patch concrète à l'intérieur de la contrainte du workspace ; cette résolution ne remplace pas la politique de version déclarée dans le manifeste racine. - **DEP-CARGO-006** — Lorsqu'une feature externe n'est nécessaire qu'aux tests/benchmarks/examples d'une crate, son activation appartient à la section de dépendances de développement correspondante plutôt qu'aux dépendances de production. L'unification des features effectuée par Cargo lors d'un build ne transfère pas cet ownership vers le `Cargo.toml` racine. +- **DEP-CARGO-007** — Les canaries qui inspectent une politique générale du workspace ou plusieurs manifests membres appartiennent à une surface de gouvernance/fondation (actuellement les tests de `ksp-core-lib` tant qu'aucun outil d'audit dédié n'existe). Une crate métier spécialisée conserve uniquement les tests de frontière propres à son domaine et ne devient pas propriétaire d'une règle globale du workspace. ## Codecs wire et cohérence des versions