From 2a7f3e8f4084d3a751365345609b2503a75d8155 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Tue, 18 Aug 2026 11:35:51 +0200 Subject: [PATCH] v0.2.3-pre.003 --- Cargo.toml | 4 +- .../http/get_fee_for_message.error.json | 1 + .../http/get_fee_for_message.null.json | 1 + .../http/get_fee_for_message.success.json | 1 + .../get_latest_blockhash.invalid_empty.json | 1 + .../http/get_latest_blockhash.success.json | 1 + .../http/get_transaction_count.success.json | 1 + .../http/is_blockhash_valid.success.json | 1 + crates/ksp-onchain-transport-lib/src/lib.rs | 7 +- .../src/rpc_transactions.rs | 156 ++++++++- .../tests/public_api.rs | 10 +- .../tests/release_completeness.rs | 20 +- .../unit_tests/rpc_transactions.rs | 184 ++++++++++- deltas/0.2.3/pre.003.md | 311 ++++++++++++++++++ 14 files changed, 687 insertions(+), 12 deletions(-) create mode 100644 crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.error.json create mode 100644 crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.null.json create mode 100644 crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.success.json create mode 100644 crates/ksp-onchain-transport-lib/fixtures/http/get_latest_blockhash.invalid_empty.json create mode 100644 crates/ksp-onchain-transport-lib/fixtures/http/get_latest_blockhash.success.json create mode 100644 crates/ksp-onchain-transport-lib/fixtures/http/get_transaction_count.success.json create mode 100644 crates/ksp-onchain-transport-lib/fixtures/http/is_blockhash_valid.success.json create mode 100644 deltas/0.2.3/pre.003.md diff --git a/Cargo.toml b/Cargo.toml index 00803e5..34af1c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 122 +# version: 123 [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.3-pre.2.fix.1" +version = "0.2.3-pre.3" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.error.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.error.json new file mode 100644 index 0000000..685a25f --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.error.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid param: unable to decode message"},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.null.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.null.json new file mode 100644 index 0000000..15d5f88 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.null.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000002},"value":null},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.success.json new file mode 100644 index 0000000..3d1bfae --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000001},"value":5000},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_latest_blockhash.invalid_empty.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_latest_blockhash.invalid_empty.json new file mode 100644 index 0000000..25123a4 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_latest_blockhash.invalid_empty.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000004},"value":{"blockhash":"","lastValidBlockHeight":431123456}},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_latest_blockhash.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_latest_blockhash.success.json new file mode 100644 index 0000000..ef780df --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_latest_blockhash.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000003},"value":{"blockhash":"11111111111111111111111111111111","lastValidBlockHeight":431123456}},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_transaction_count.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_transaction_count.success.json new file mode 100644 index 0000000..02896ab --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_transaction_count.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":987654321,"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/is_blockhash_valid.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/is_blockhash_valid.success.json new file mode 100644 index 0000000..fc0364a --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/is_blockhash_valid.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000005},"value":true},"id":1} diff --git a/crates/ksp-onchain-transport-lib/src/lib.rs b/crates/ksp-onchain-transport-lib/src/lib.rs index 68d16f2..49e7e83 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: 12 +// version: 13 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] @@ -10,8 +10,9 @@ //! independent from `ksp-config-lib`, Store and Program layers. `ksp-config-lib` now constructs these public settings through its one-way Config -> //! Transport adapter without creating a reverse dependency. Logical endpoint clients, priority-aware pools, bounded admission limits and retry/no-resend policy //! are available. The four typed Solana HTTP foundation canaries plus all 22 typed `0.2.2` Accounts, Tokens and Cluster wrappers execute real JSON-RPC -//! requests through the shared transport path. `0.2.3` now exposes its shared Transaction wire/config primitives while its 11 typed wrappers remain staged; -//! the `0.2.4` family remains fully staged. +//! requests through the shared transport path. `0.2.3` exposes its shared Transaction wire/config primitives and its first four read wrappers: +//! `getFeeForMessage`, `getLatestBlockhash`, `getTransactionCount` and `isBlockhashValid`. The remaining seven Transaction wrappers and the `0.2.4` family +//! remain staged. mod client; mod constants; diff --git a/crates/ksp-onchain-transport-lib/src/rpc_transactions.rs b/crates/ksp-onchain-transport-lib/src/rpc_transactions.rs index cdeaf0d..4215e6f 100644 --- a/crates/ksp-onchain-transport-lib/src/rpc_transactions.rs +++ b/crates/ksp-onchain-transport-lib/src/rpc_transactions.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/rpc_transactions.rs -// version: 2 +// version: 3 /// Binary encoding accepted for serialized transaction input payloads. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -617,7 +617,6 @@ impl SolanaLatestBlockhash { } /// Decodes a latest-blockhash object from the Solana JSON wire shape. - #[cfg(test)] pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result { let decoded = crate::decode_wire_json::(method, value); let wire = match decoded { @@ -1103,7 +1102,157 @@ impl SolanaSimulateTransactionResult { } } -#[cfg(test)] +impl crate::HttpTransportPool { + /// Executes typed `getFeeForMessage` through the common KSP HTTP transport path. + pub async fn get_fee_for_message( + &self, + role: &crate::HttpRoleName, + message_base64: &str, + config: std::option::Option<&crate::SolanaContextConfig>, + ) -> ksp_core_lib::Result>> { + let mut params = std::vec![serde_json::Value::String(message_base64.to_owned())]; + push_transaction_context_config(&mut params, config); + let value = self.execute_transaction_rpc("getFeeForMessage", role, params).await; + let value = match value { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let contextual = decode_transaction_contextual_wire("getFeeForMessage", value); + let (context, value) = match contextual { + std::result::Result::Ok(contextual) => contextual, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let fee = crate::decode_wire_json::>("getFeeForMessage", value); + return match fee { + std::result::Result::Ok(fee) => std::result::Result::Ok(crate::SolanaRpcResponse::new(context, fee)), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } + + /// Executes typed `getLatestBlockhash` through the common KSP HTTP transport path. + pub async fn get_latest_blockhash( + &self, + role: &crate::HttpRoleName, + config: std::option::Option<&crate::SolanaContextConfig>, + ) -> ksp_core_lib::Result> { + let mut params = std::vec::Vec::new(); + push_transaction_context_config(&mut params, config); + let value = self.execute_transaction_rpc("getLatestBlockhash", role, params).await; + let value = match value { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let contextual = decode_transaction_contextual_wire("getLatestBlockhash", value); + let (context, value) = match contextual { + std::result::Result::Ok(contextual) => contextual, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let blockhash = crate::SolanaLatestBlockhash::decode_wire("getLatestBlockhash", value); + return match blockhash { + std::result::Result::Ok(blockhash) => std::result::Result::Ok(crate::SolanaRpcResponse::new(context, blockhash)), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } + + /// Executes typed `getTransactionCount` through the common KSP HTTP transport path. + pub async fn get_transaction_count( + &self, + role: &crate::HttpRoleName, + config: std::option::Option<&crate::SolanaContextConfig>, + ) -> ksp_core_lib::Result { + let mut params = std::vec::Vec::new(); + push_transaction_context_config(&mut params, config); + let value = self.execute_transaction_rpc("getTransactionCount", role, params).await; + return match value { + std::result::Result::Ok(value) => crate::decode_wire_json::("getTransactionCount", value), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } + + /// Executes typed `isBlockhashValid` through the common KSP HTTP transport path. + pub async fn is_blockhash_valid( + &self, + role: &crate::HttpRoleName, + blockhash: &str, + config: std::option::Option<&crate::SolanaContextConfig>, + ) -> ksp_core_lib::Result> { + let mut params = std::vec![serde_json::Value::String(blockhash.to_owned())]; + push_transaction_context_config(&mut params, config); + let value = self.execute_transaction_rpc("isBlockhashValid", role, params).await; + let value = match value { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let contextual = decode_transaction_contextual_wire("isBlockhashValid", value); + let (context, value) = match contextual { + std::result::Result::Ok(contextual) => contextual, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let valid = crate::decode_wire_json::("isBlockhashValid", value); + return match valid { + std::result::Result::Ok(valid) => std::result::Result::Ok(crate::SolanaRpcResponse::new(context, valid)), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } + + async fn execute_transaction_rpc( + &self, + method_name: &'static str, + role: &crate::HttpRoleName, + params: std::vec::Vec, + ) -> ksp_core_lib::Result { + let method = transaction_descriptor(method_name); + let method = match method { + std::result::Result::Ok(method) => method, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return self.execute_standard_rpc(role, method, params).await; + } +} + +fn push_transaction_context_config(params: &mut std::vec::Vec, config: std::option::Option<&crate::SolanaContextConfig>) { + if let std::option::Option::Some(config) = config + && (config.commitment().is_some() || config.min_context_slot().is_some()) + { + params.push((*config).to_json_value()); + } + return; +} + +fn decode_transaction_contextual_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<(crate::SolanaRpcContext, serde_json::Value)> { + let decoded = crate::decode_wire_json::(method, value); + let wire = match decoded { + std::result::Result::Ok(wire) => wire, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let context = crate::SolanaRpcContext::decode_wire(method, wire.context); + return match context { + std::result::Result::Ok(context) => std::result::Result::Ok((context, wire.value)), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; +} + +fn transaction_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) + if descriptor.category() == crate::HttpRpcCategory::Transactions && descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_3 => + { + std::result::Result::Ok(descriptor) + }, + _ => std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Transaction descriptor is missing or misclassified in the audited registry") + .with_context("rpc_method", method), + ), + }; +} + +#[derive(serde::Deserialize)] +struct WireTransactionRpcResponse { + context: serde_json::Value, + value: serde_json::Value, +} + fn invalid_transaction_wire(method: &str, field: &str, message: &'static str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method).with_context("field", field); } @@ -1230,7 +1379,6 @@ fn decode_replacement_blockhash_field( }; } -#[cfg(test)] #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct WireLatestBlockhash { diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index 896b834..a98e223 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: 10 +// version: 11 //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. @@ -270,3 +270,11 @@ fn public_transaction_pre_002_shared_types_are_constructible_from_crate_root() { assert_eq!(pagination.limit(), std::option::Option::Some(10)); assert!(ksp_onchain_transport_lib::SolanaWireField::::Omitted.is_omitted()); } + +#[test] +fn public_transaction_pre_003_read_wrappers_are_available_from_crate_root() { + let _get_fee_for_message = ksp_onchain_transport_lib::HttpTransportPool::get_fee_for_message; + let _get_latest_blockhash = ksp_onchain_transport_lib::HttpTransportPool::get_latest_blockhash; + let _get_transaction_count = ksp_onchain_transport_lib::HttpTransportPool::get_transaction_count; + let _is_blockhash_valid = ksp_onchain_transport_lib::HttpTransportPool::is_blockhash_valid; +} diff --git a/crates/ksp-onchain-transport-lib/tests/release_completeness.rs b/crates/ksp-onchain-transport-lib/tests/release_completeness.rs index 33a7cb0..d4e6404 100644 --- a/crates/ksp-onchain-transport-lib/tests/release_completeness.rs +++ b/crates/ksp-onchain-transport-lib/tests/release_completeness.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs -// version: 7 +// version: 8 //! Release-level completeness canaries for the `0.2.1` HTTP foundation contract. @@ -241,3 +241,21 @@ fn release_v0_2_3_descriptor_set_and_safety_classes_are_exact_before_wrapper_com assert_eq!(writes, 2); assert_eq!(simulations, 1); } + +#[test] +fn release_pre_003_transaction_read_subset_is_exact_and_retry_safe() { + let mut expected = std::vec!["getFeeForMessage", "getLatestBlockhash", "getTransactionCount", "isBlockhashValid"]; + let mut actual = std::vec::Vec::new(); + for method_name in &expected { + let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.003 Transaction descriptor must exist"); + actual.push(descriptor.method()); + assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Transactions); + assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3); + assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read); + assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe); + } + actual.sort_unstable(); + expected.sort_unstable(); + assert_eq!(actual, expected); + assert_eq!(actual.len(), 4); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs b/crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs index cf3302b..6d5d474 100644 --- a/crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs +++ b/crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs -// version: 2 +// version: 3 #[test] fn transaction_encoding_strings_match_current_and_legacy_wire_labels() { @@ -288,3 +288,185 @@ fn simulation_result_distinguishes_omitted_from_explicit_null_fields() { assert!(result.replacement_blockhash().is_omitted()); assert!(result.loaded_addresses().is_omitted()); } + +fn transaction_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_transaction_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_transaction_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_transaction_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 transaction_request_complete(bytes.as_slice()) { + break; + } + } + return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8"); +} + +fn transaction_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 transaction_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_fee_for_message_serializes_exact_params_and_decodes_contextual_fee() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_fee_for_message.success.json")); + let pool = transaction_pool_for_url(url.as_str()); + let config = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(431_000_000)); + let response = pool + .get_fee_for_message(&crate::HttpRoleName::new("default"), "AQIDBA==", std::option::Option::Some(&config)) + .await + .expect("getFeeForMessage fixture must succeed"); + assert_eq!(response.context().slot(), 431_000_001); + assert_eq!(response.context().api_version(), std::option::Option::Some("4.2.1")); + assert_eq!(response.value(), &std::option::Option::Some(5_000)); + let request = handle.join().expect("fixture server must join"); + let body = transaction_request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("getFeeForMessage")); + assert_eq!(body["params"], serde_json::json!(["AQIDBA==", {"commitment":"confirmed","minContextSlot":431000000}])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_fee_for_message_preserves_null_and_omits_absent_config() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_fee_for_message.null.json")); + let pool = transaction_pool_for_url(url.as_str()); + let response = pool + .get_fee_for_message(&crate::HttpRoleName::new("default"), "opaque-message", std::option::Option::None) + .await + .expect("null fee fixture must succeed"); + assert_eq!(response.value(), &std::option::Option::None); + let request = handle.join().expect("fixture server must join"); + assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["opaque-message"])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_fee_for_message_preserves_rpc_application_error() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_fee_for_message.error.json")); + let pool = transaction_pool_for_url(url.as_str()); + let result = pool.get_fee_for_message(&crate::HttpRoleName::new("default"), "not-decoded-locally", std::option::Option::None).await; + let error = result.expect_err("remote invalid message must remain an RPC application error"); + assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR); + handle.join().expect("fixture server must join"); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_latest_blockhash_omits_empty_config_and_decodes_contextual_result() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_latest_blockhash.success.json")); + let pool = transaction_pool_for_url(url.as_str()); + let config = crate::SolanaContextConfig::default(); + let response = pool + .get_latest_blockhash(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config)) + .await + .expect("getLatestBlockhash fixture must succeed"); + assert_eq!(response.context().slot(), 431_000_003); + assert_eq!(response.value().blockhash(), "11111111111111111111111111111111"); + assert_eq!(response.value().last_valid_block_height(), 431_123_456); + let request = handle.join().expect("fixture server must join"); + assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!([])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_latest_blockhash_rejects_empty_blockhash_from_http_response() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_latest_blockhash.invalid_empty.json")); + let pool = transaction_pool_for_url(url.as_str()); + let result = pool.get_latest_blockhash(&crate::HttpRoleName::new("default"), std::option::Option::None).await; + let error = result.expect_err("empty latest blockhash must fail typed decoding"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE); + handle.join().expect("fixture server must join"); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_transaction_count_serializes_context_config_and_decodes_u64() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_transaction_count.success.json")); + let pool = transaction_pool_for_url(url.as_str()); + let config = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::None); + let count = pool + .get_transaction_count(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config)) + .await + .expect("getTransactionCount fixture must succeed"); + assert_eq!(count, 987_654_321); + let request = handle.join().expect("fixture server must join"); + let body = transaction_request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("getTransactionCount")); + assert_eq!(body["params"], serde_json::json!([{"commitment":"finalized"}])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_is_blockhash_valid_preserves_opaque_blockhash_and_contextual_bool() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/is_blockhash_valid.success.json")); + let pool = transaction_pool_for_url(url.as_str()); + let config = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Processed), std::option::Option::Some(431_000_004)); + let response = pool + .is_blockhash_valid(&crate::HttpRoleName::new("default"), "opaque-blockhash", std::option::Option::Some(&config)) + .await + .expect("isBlockhashValid fixture must succeed"); + assert_eq!(response.context().slot(), 431_000_005); + assert_eq!(response.value(), &true); + let request = handle.join().expect("fixture server must join"); + let body = transaction_request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("isBlockhashValid")); + assert_eq!(body["params"], serde_json::json!(["opaque-blockhash", {"commitment":"processed","minContextSlot":431000004}])); +} diff --git a/deltas/0.2.3/pre.003.md b/deltas/0.2.3/pre.003.md new file mode 100644 index 0000000..6f060aa --- /dev/null +++ b/deltas/0.2.3/pre.003.md @@ -0,0 +1,311 @@ + + + +# Delta `0.2.3-pre.003` — quatre premiers wrappers HTTP Transactions Read + +## Base requise + +Livraison précédente corrigée et validée localement par l'opérateur : + +```text +0.2.3-pre.002-fix.001 +workspace.package.version = "0.2.3-pre.2.fix.1" +``` + +La validation opérateur du 2026-08-18 a confirmé : + +```text +cargo fmt --all OK +cargo check --workspace OK +cargo clippy --workspace --all-targets OK +cargo test -p ksp-onchain-transport-lib OK +``` + +Résultats Transport de cette base : + +```text +145 unit tests +14 public API tests +8 release completeness tests +0 warning signalé par check/clippy +``` + +Le plan canonique reste `docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md` version 3. + +## Objectif + +Implémenter les quatre premiers wrappers Transaction `Read / RetrySafe` prévus par le plan : + +```text +getFeeForMessage +getLatestBlockhash +getTransactionCount +isBlockhashValid +``` + +Tous passent par le chemin HTTP commun : + +```text +wrapper typé + -> descriptor Transaction audité + -> execute_standard_rpc + -> pool/admission/retry/deadline + -> executor HTTP + -> validation JSON-RPC + -> décodage DTO KSP +``` + +Aucun wrapper n'appelle `reqwest` directement et aucune boucle de retry locale n'est ajoutée. + +## Version Cargo + +Conformément à `VER-ID-009` : + +```text +0.2.3-pre.2.fix.1 -> 0.2.3-pre.3 +``` + +Aucune dépendance ni feature Cargo n'est ajoutée. + +## Surface typée ajoutée + +### `getFeeForMessage` + +Signature publique conceptuelle : + +```text +role + message base64 opaque + Option + -> SolanaRpcResponse> +``` + +Le wrapper : + +- conserve le message sérialisé comme chaîne opaque et ne tente ni base64 decode ni désérialisation locale ; +- sérialise `commitment` et `minContextSlot` lorsqu'ils sont présents ; +- omet la config lorsqu'elle est absente ou vide ; +- préserve `value: null` sous `Option::None` ; +- laisse une erreur de décodage/paramètre distante dans le domaine `RPC_APPLICATION_ERROR`. + +### `getLatestBlockhash` + +Signature publique conceptuelle : + +```text +role + Option + -> SolanaRpcResponse +``` + +Le wrapper active en production le décodeur `SolanaLatestBlockhash` préparé en `pre.002` et conserve : + +```text +blockhash +lastValidBlockHeight +context.slot +context.apiVersion? +``` + +Un blockhash de réponse vide est rejeté comme `ERROR_CODE_INVALID_RESPONSE`. KSP ne décode pas cryptographiquement la chaîne. + +### `getTransactionCount` + +Signature publique conceptuelle : + +```text +role + Option + -> u64 +``` + +La méthode conserve la forme simple du résultat tout en sérialisant la config contextuelle optionnelle dans le tableau `params` exact. + +### `isBlockhashValid` + +Signature publique conceptuelle : + +```text +role + blockhash opaque + Option + -> SolanaRpcResponse +``` + +Le blockhash d'entrée est transmis tel quel au runtime : `pre.003` n'ajoute ni `solana-hash`, ni validation base58, ni normalisation silencieuse. + +## Infrastructure Transaction activée + +`rpc_transactions.rs` ajoute un helper interne unique pour les wrappers de cette famille : + +```text +execute_transaction_rpc + -> transaction_descriptor + -> execute_standard_rpc +``` + +`transaction_descriptor` refuse de fonctionner si la méthode demandée n'est pas classée : + +```text +HttpRpcCategory::Transactions +HttpRpcCoverageRelease::V0_2_3 +``` + +Les trois réponses contextuelles de cette tranche réutilisent un décodeur interne commun qui préserve `SolanaRpcContext` avant de décoder la +valeur métier. + +Seuls les helpers réellement utilisés deviennent production-live dans cette tranche : + +```text +SolanaLatestBlockhash::decode_wire +WireLatestBlockhash +invalid_transaction_wire +``` + +Les décodeurs/configs préparatoires des sept wrappers suivants restent sous `#[cfg(test)]` jusqu'à leur tranche propriétaire afin de conserver +une build non-test sans `dead_code` artificiel. + +## Fixtures HTTP déterministes ajoutées + +```text +crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.null.json +crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.error.json +crates/ksp-onchain-transport-lib/fixtures/http/get_latest_blockhash.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_latest_blockhash.invalid_empty.json +crates/ksp-onchain-transport-lib/fixtures/http/get_transaction_count.success.json +crates/ksp-onchain-transport-lib/fixtures/http/is_blockhash_valid.success.json +``` + +Les tests utilisent un serveur HTTP loopback local et n'accèdent pas à Internet. + +## Couverture de tests ajoutée + +Sept tests HTTP unitaires couvrent : + +- `getFeeForMessage` avec paramètres exacts et config contextuelle complète ; +- `getFeeForMessage` avec `value: null` et config absente ; +- propagation d'une erreur JSON-RPC applicative pour un message refusé par le runtime ; +- `getLatestBlockhash` avec config explicitement vide, donc omise ; +- rejet d'un blockhash vide renvoyé par le provider ; +- `getTransactionCount` avec commitment ; +- `isBlockhashValid` avec blockhash opaque, commitment et `minContextSlot`. + +Le test public compile explicitement les quatre nouvelles méthodes sur `HttpTransportPool`. + +Une canarie release fige le sous-ensemble `pre.003` : + +```text +getFeeForMessage +getLatestBlockhash +getTransactionCount +isBlockhashValid +``` + +et vérifie pour chacune : + +```text +Transactions / V0_2_3 / Read / RetrySafe +``` + +La couverture typed complète `0.2.3 exact == 11` reste volontairement fermée : les sept wrappers restants appartiennent aux tranches +`pre.004` à `pre.007`. + +Après application, la cible Transport attendue devient : + +```text +152 unit tests +15 public API tests +9 release completeness tests +``` + +## Fichiers ajoutés + +```text +crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.null.json +crates/ksp-onchain-transport-lib/fixtures/http/get_fee_for_message.error.json +crates/ksp-onchain-transport-lib/fixtures/http/get_latest_blockhash.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_latest_blockhash.invalid_empty.json +crates/ksp-onchain-transport-lib/fixtures/http/get_transaction_count.success.json +crates/ksp-onchain-transport-lib/fixtures/http/is_blockhash_valid.success.json +deltas/0.2.3/pre.003.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +crates/ksp-onchain-transport-lib/src/lib.rs +crates/ksp-onchain-transport-lib/src/rpc_transactions.rs +crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs +crates/ksp-onchain-transport-lib/tests/public_api.rs +crates/ksp-onchain-transport-lib/tests/release_completeness.rs +``` + +## Fichiers supprimés + +Aucun. + +## Fichiers volontairement inchangés + +```text +CHANGELOG.md +ROADMAP.md +docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md +crates/ksp-onchain-transport-lib/Cargo.toml +crates/ksp-onchain-transport-lib/README.md +crates/ksp-onchain-transport-lib/USAGE.md +crates/ksp-onchain-transport-lib/src/executor.rs +crates/ksp-onchain-transport-lib/src/rpc_method.rs +crates/ksp-config-lib/** +config/** +``` + +Aucune méthode `0.2.4` n'est avancée. + +## Validations exécutées + +- prise en compte de la validation opérateur propre de `pre.002-fix.001` comme base ; +- contrôle JSON syntaxique des sept nouvelles fixtures ; +- contrôle différentiel exact contre la base validée ; +- contrôle statique que les quatre wrappers passent par `execute_transaction_rpc -> execute_standard_rpc` ; +- contrôle statique de l'absence de `reqwest`, `tracing`, client RPC parallèle ou boucle de retry dans `rpc_transactions.rs` ; +- contrôle statique de l'absence de nouvelle dépendance/feature Cargo ; +- contrôle statique des helpers préparatoires restés sous `#[cfg(test)]` ; +- contrôle statique de l'absence de `unwrap`, `expect`, `panic!` et `?` dans la source de production modifiée ; +- contrôle statique des lignes Rust modifiées à `<= 160` colonnes ; +- contrôle que `CHANGELOG.md`, `ROADMAP.md`, le plan `010`, `executor.rs` et `rpc_method.rs` restent inchangés. + +## Validations non exécutées + +Le sandbox d'échange ne fournit pas `cargo`, `rustc` ou `rustfmt`. Les commandes suivantes doivent être exécutées sur le checkout opérateur avant +commit : + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-onchain-transport-lib +``` + +`cargo test --workspace` reste réservé au checkpoint de clôture/session. Aucun `cargo tree` n'est requis par cette tranche puisqu'aucune dépendance +ni feature n'a changé. + +Le commit attendu après application et validations suit `VER-GIT-001` : + +```text +v0.2.3-pre.003 +``` + +## Décisions prises + +- réutiliser `SolanaContextConfig` et `SolanaRpcResponse` sans créer de DTO de config redondant ; +- ne pas décoder localement les messages ou blockhashs d'entrée ; +- omettre une config contextuelle explicitement vide ; +- activer en production uniquement les helpers wire réellement consommés par cette tranche ; +- centraliser la sélection des descriptors Transaction sans contourner `execute_standard_rpc` ; +- conserver les erreurs JSON-RPC applicatives comme non-retryables au niveau applicatif. + +## Questions ouvertes + +Aucune question bloquante pour `pre.004`. + +## Suite + +`0.2.3-pre.004` : implémenter `getRecentPrioritizationFees`, `getSignaturesForAddress` et `getSignatureStatuses`, avec limites locales exactes +`128 / 1000 / 256`, ordre serveur, pagination et `null` positionnels.