From 704110d5099c41bc37f15b9e4b841390b17dd417 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Tue, 18 Aug 2026 16:12:07 +0200 Subject: [PATCH] v0.2.3-pre.007 --- Cargo.toml | 4 +- .../http/simulate_transaction.error.json | 8 + .../http/simulate_transaction.partial.json | 18 + .../http/simulate_transaction.success.json | 60 ++++ crates/ksp-onchain-transport-lib/src/lib.rs | 8 +- .../src/rpc_transactions.rs | 68 +++- .../tests/public_api.rs | 28 +- .../tests/release_completeness.rs | 45 ++- .../unit_tests/rpc_transactions.rs | 228 ++++++++++++- deltas/0.2.3/pre.007.md | 313 ++++++++++++++++++ .../010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md | 21 +- 11 files changed, 782 insertions(+), 19 deletions(-) create mode 100644 crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.error.json create mode 100644 crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.partial.json create mode 100644 crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.success.json create mode 100644 deltas/0.2.3/pre.007.md diff --git a/Cargo.toml b/Cargo.toml index e8da392..b5d8c80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 127 +# version: 128 [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.6.fix.1" +version = "0.2.3-pre.7" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.error.json b/crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.error.json new file mode 100644 index 0000000..fae72cd --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.error.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "error": { + "code": -32602, + "message": "invalid transaction: failed to sanitize accounts offsets correctly" + }, + "id": 1 +} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.partial.json b/crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.partial.json new file mode 100644 index 0000000..623310f --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.partial.json @@ -0,0 +1,18 @@ +{ + "jsonrpc": "2.0", + "result": { + "context": { + "slot": 431000031, + "apiVersion": "4.2.1" + }, + "value": { + "err": null, + "logs": null, + "accounts": null, + "unitsConsumed": 99, + "returnData": null, + "replacementBlockhash": null + } + }, + "id": 1 +} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.success.json new file mode 100644 index 0000000..5af11d6 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.success.json @@ -0,0 +1,60 @@ +{ + "jsonrpc": "2.0", + "result": { + "context": { + "slot": 431000030, + "apiVersion": "4.2.1" + }, + "value": { + "err": null, + "logs": [ + "Program 11111111111111111111111111111111 invoke [1]", + "Program 11111111111111111111111111111111 success" + ], + "accounts": [ + { + "lamports": 1000, + "data": [ + "", + "base64" + ], + "owner": "11111111111111111111111111111111", + "executable": false, + "rentEpoch": 0, + "space": 0 + }, + null + ], + "unitsConsumed": 150, + "loadedAccountsDataSize": 64, + "returnData": { + "programId": "11111111111111111111111111111111", + "data": [ + "AQI=", + "base64" + ] + }, + "innerInstructions": [], + "replacementBlockhash": { + "blockhash": "ComputeBudget111111111111111111111111111111", + "lastValidBlockHeight": 430123999 + }, + "fee": 5000, + "preBalances": [ + 10000, + 0 + ], + "postBalances": [ + 5000, + 5000 + ], + "preTokenBalances": [], + "postTokenBalances": [], + "loadedAddresses": { + "writable": [], + "readonly": [] + } + } + }, + "id": 1 +} diff --git a/crates/ksp-onchain-transport-lib/src/lib.rs b/crates/ksp-onchain-transport-lib/src/lib.rs index 67352a0..59b4555 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: 15 +// version: 16 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] @@ -10,9 +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` exposes its shared Transaction wire/config primitives, all eight read wrappers and both write -//! submissions through `pre.006`, including complete modern/legacy `getTransaction` coverage and centralized no-resend protection for writes. -//! `simulateTransaction` and the `0.2.4` family remain staged. +//! requests through the shared transport path. `0.2.3` exposes its shared Transaction wire/config primitives and all eleven Transaction wrappers through +//! `pre.007`: eight reads, two write submissions with centralized no-resend protection, and retry-safe `simulateTransaction`, including complete +//! modern/legacy `getTransaction` coverage. The `0.2.4` family remains 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 67e10e2..23f54a0 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: 6 +// version: 7 /// Binary encoding accepted for serialized transaction input payloads. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -452,7 +452,6 @@ impl SolanaSimulationAccountsConfig { /// Serializes the nested account config to the Solana JSON-RPC wire object. #[must_use] - #[cfg(test)] pub(crate) fn to_json_value(&self) -> serde_json::Value { let mut object = serde_json::Map::new(); if let std::option::Option::Some(encoding) = self.encoding { @@ -545,7 +544,6 @@ impl SolanaSimulateTransactionConfig { } /// Returns whether the simulation config would serialize to an empty object. - #[cfg(test)] pub(crate) fn is_empty(&self) -> bool { return self.commitment.is_none() && self.encoding.is_none() @@ -558,7 +556,6 @@ impl SolanaSimulateTransactionConfig { /// Serializes this config to the Solana JSON-RPC wire object. #[must_use] - #[cfg(test)] pub(crate) fn to_json_value(&self) -> serde_json::Value { let mut object = serde_json::Map::new(); if let std::option::Option::Some(commitment) = self.commitment { @@ -1050,7 +1047,6 @@ impl SolanaSimulateTransactionResult { } /// Decodes a simulation result while preserving optional/nullable current Agave fields. - #[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 { @@ -1357,6 +1353,65 @@ impl crate::HttpTransportPool { }; } + /// Executes typed `simulateTransaction` through the common KSP HTTP transport path. + /// + /// Transport forwards the already encoded transaction as opaque text. The wrapper enforces only deterministic RPC invariants that do not require + /// decoding transaction bytes: `sigVerify` cannot be combined with `replaceRecentBlockhash`, and simulation account-return encoding cannot use the + /// legacy `binary` / `base58` account encodings rejected by the targeted Agave runtime. + pub async fn simulate_transaction( + &self, + role: &crate::HttpRoleName, + transaction: &str, + config: std::option::Option<&crate::SolanaSimulateTransactionConfig>, + ) -> ksp_core_lib::Result> { + if let std::option::Option::Some(config) = config { + if config.sig_verify() == std::option::Option::Some(true) && config.replace_recent_blockhash() == std::option::Option::Some(true) { + return std::result::Result::Err( + ksp_core_lib::Error::new( + crate::ERROR_CODE_INVALID_RPC_PARAMETERS, + "simulateTransaction sigVerify may not be used with replaceRecentBlockhash", + ) + .with_context("rpc_method", "simulateTransaction"), + ); + } + if let std::option::Option::Some(accounts) = config.accounts() + && let std::option::Option::Some(encoding) = accounts.encoding() + && (encoding == crate::SolanaAccountEncoding::Binary || encoding == crate::SolanaAccountEncoding::Base58) + { + return std::result::Result::Err( + ksp_core_lib::Error::new( + crate::ERROR_CODE_INVALID_RPC_PARAMETERS, + "simulateTransaction account-return encoding must be base64, base64+zstd, or jsonParsed", + ) + .with_context("rpc_method", "simulateTransaction") + .with_context("accounts_encoding", encoding.as_str()), + ); + } + } + + let mut params = std::vec![serde_json::Value::String(transaction.to_owned())]; + if let std::option::Option::Some(config) = config + && !config.is_empty() + { + params.push(config.to_json_value()); + } + let value = self.execute_transaction_rpc("simulateTransaction", 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("simulateTransaction", value); + let (context, value) = match contextual { + std::result::Result::Ok(contextual) => contextual, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = crate::SolanaSimulateTransactionResult::decode_wire("simulateTransaction", value); + return match result { + std::result::Result::Ok(result) => std::result::Result::Ok(crate::SolanaRpcResponse::new(context, result)), + 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, @@ -1612,7 +1667,6 @@ fn decode_transaction_version_field( }; } -#[cfg(test)] fn decode_simulation_accounts_field( method: &str, field: crate::SolanaWireField>>, @@ -1640,7 +1694,6 @@ fn decode_simulation_accounts_field( return std::result::Result::Ok(crate::SolanaWireField::Value(accounts)); } -#[cfg(test)] fn decode_replacement_blockhash_field( method: &str, field: crate::SolanaWireField, @@ -1716,7 +1769,6 @@ struct WireConfirmedTransaction { transaction_index: crate::SolanaWireField, } -#[cfg(test)] #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct WireSimulateTransactionResult { diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index a6897a0..29a9416 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: 14 +// version: 15 //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. @@ -321,3 +321,29 @@ fn public_transaction_pre_006_write_wrappers_and_complete_configs_are_available_ assert_eq!(send.max_retries(), std::option::Option::Some(5)); assert_eq!(send.min_context_slot(), std::option::Option::Some(431_000_000)); } + +#[test] +fn public_transaction_pre_007_simulation_wrapper_and_complete_config_are_available_from_crate_root() { + let _simulate_transaction = ksp_onchain_transport_lib::HttpTransportPool::simulate_transaction; + let address = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let accounts = ksp_onchain_transport_lib::SolanaSimulationAccountsConfig::new( + std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::Base64Zstd), + std::vec![address], + ); + let config = ksp_onchain_transport_lib::SolanaSimulateTransactionConfig::new( + std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64), + std::option::Option::Some(false), + std::option::Option::Some(true), + std::option::Option::Some(431_000_000), + std::option::Option::Some(true), + std::option::Option::Some(accounts), + ); + assert_eq!(config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed)); + assert_eq!(config.encoding(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64)); + assert_eq!(config.sig_verify(), std::option::Option::Some(true)); + assert_eq!( + config.accounts().and_then(ksp_onchain_transport_lib::SolanaSimulationAccountsConfig::encoding), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::Base64Zstd), + ); +} diff --git a/crates/ksp-onchain-transport-lib/tests/release_completeness.rs b/crates/ksp-onchain-transport-lib/tests/release_completeness.rs index 5feb930..c1a926c 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: 11 +// version: 12 //! Release-level completeness canaries for the `0.2.1` HTTP foundation contract. @@ -196,7 +196,7 @@ fn release_pre_007_v0_2_2_exact_set_is_complete_without_advancing_future_release } #[test] -fn release_v0_2_3_descriptor_set_and_safety_classes_are_exact_before_wrapper_completion() { +fn release_v0_2_3_descriptor_set_and_safety_classes_are_exact() { let mut expected = std::vec![ "getFeeForMessage", "getLatestBlockhash", @@ -359,3 +359,44 @@ fn release_pre_006_transaction_subset_adds_both_write_submissions_without_advanc assert_eq!(reads.len(), 8); assert_eq!(writes.len(), 2); } + +#[test] +fn release_pre_007_completes_all_v0_2_3_transaction_wrappers_with_exact_safety_classes() { + let reads = std::vec![ + "getFeeForMessage", + "getLatestBlockhash", + "getRecentPrioritizationFees", + "getSignaturesForAddress", + "getSignatureStatuses", + "getTransaction", + "getTransactionCount", + "isBlockhashValid", + ]; + let writes = std::vec!["requestAirdrop", "sendTransaction"]; + let simulations = std::vec!["simulateTransaction"]; + for method_name in &reads { + let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.007 read descriptor must exist"); + 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); + } + for method_name in &writes { + let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.007 write descriptor must exist"); + 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::WriteSubmission); + assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::NeverAfterDispatch); + } + for method_name in &simulations { + let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.007 simulation descriptor must exist"); + 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::Simulation); + assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe); + } + assert_eq!(reads.len(), 8); + assert_eq!(writes.len(), 2); + assert_eq!(simulations.len(), 1); + assert_eq!(reads.len() + writes.len() + simulations.len(), 11); +} 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 2f1e95a..e43211b 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: 7 +// version: 8 #[test] fn transaction_encoding_strings_match_current_and_legacy_wire_labels() { @@ -391,6 +391,42 @@ fn serve_transaction_timeout_and_count(delay: std::time::Duration) -> (std::stri return (format!("http://{address}"), handle); } +fn serve_transaction_status_then_body( + status_line: &'static str, + body: &'static str, +) -> (std::string::String, std::thread::JoinHandle<(std::string::String, std::string::String)>) { + 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 first_stream, _) = listener.accept().expect("fixture server must accept first request"); + let first_request = read_transaction_request(&mut first_stream); + let first_response = format!("HTTP/1.1 {status_line}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + std::io::Write::write_all(&mut first_stream, first_response.as_bytes()).expect("fixture first response must write"); + + listener.set_nonblocking(true).expect("fixture listener must become nonblocking"); + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500); + return loop { + match listener.accept() { + std::result::Result::Ok((mut retry_stream, _)) => { + let retry_request = read_transaction_request(&mut retry_stream); + let retry_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 retry_stream, retry_response.as_bytes()).expect("fixture retry response must write"); + break (first_request, retry_request); + }, + std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if std::time::Instant::now() >= deadline { + panic!("fixture server did not receive retry before deadline"); + } + std::thread::sleep(std::time::Duration::from_millis(5)); + }, + std::result::Result::Err(error) => panic!("fixture listener failed while waiting for retry: {error}"), + } + }; + }); + return (format!("http://{address}"), handle); +} + fn unused_local_url() -> std::string::String { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("unused-port probe must bind"); let address = listener.local_addr().expect("unused-port probe address must resolve"); @@ -1139,3 +1175,193 @@ async fn typed_write_wrappers_can_retry_when_connection_failure_proves_not_dispa let request = handle.join().expect("secondary fixture server must join"); assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("sendTransaction")); } + +#[tokio::test(flavor = "current_thread")] +async fn typed_simulate_transaction_serializes_complete_config_and_preserves_rich_result() { + let address = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let accounts = crate::SolanaSimulationAccountsConfig::new(std::option::Option::Some(crate::SolanaAccountEncoding::Base64), std::vec![address]); + let config = crate::SolanaSimulateTransactionConfig::new( + std::option::Option::Some(crate::SolanaCommitment::Confirmed), + std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64), + std::option::Option::Some(true), + std::option::Option::Some(false), + std::option::Option::Some(431_000_000), + std::option::Option::Some(true), + std::option::Option::Some(accounts), + ); + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.success.json")); + let pool = transaction_pool_for_url(url.as_str()); + let response = pool + .simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-base64-transaction", std::option::Option::Some(&config)) + .await + .expect("simulateTransaction rich fixture must succeed"); + assert_eq!(response.context().slot(), 431_000_030); + assert_eq!(response.context().api_version(), std::option::Option::Some("4.2.1")); + assert!(response.value().err().is_null()); + assert_eq!(response.value().units_consumed().value(), std::option::Option::Some(&150)); + assert_eq!(response.value().loaded_accounts_data_size().value(), std::option::Option::Some(&64)); + assert_eq!(response.value().fee().value(), std::option::Option::Some(&5_000)); + assert_eq!(response.value().pre_balances().value(), std::option::Option::Some(&std::vec![10_000, 0])); + assert_eq!(response.value().post_balances().value(), std::option::Option::Some(&std::vec![5_000, 5_000])); + let returned_accounts = response.value().accounts().value().expect("simulation accounts must be present"); + assert_eq!(returned_accounts.len(), 2); + assert!(returned_accounts[0].is_some()); + assert!(returned_accounts[1].is_none()); + let replacement = response.value().replacement_blockhash().value().expect("replacement blockhash must be present"); + assert_eq!(replacement.blockhash(), "ComputeBudget111111111111111111111111111111"); + assert_eq!(replacement.last_valid_block_height(), 430_123_999); + + let request = handle.join().expect("fixture server must join"); + let body = transaction_request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("simulateTransaction")); + assert_eq!( + body["params"], + serde_json::json!([ + "opaque-base64-transaction", + { + "commitment":"confirmed", + "encoding":"base64", + "replaceRecentBlockhash":true, + "sigVerify":false, + "minContextSlot":431000000, + "innerInstructions":true, + "accounts":{"encoding":"base64","addresses":["11111111111111111111111111111111"]} + } + ]) + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_simulate_transaction_supports_base58_and_omits_empty_config() { + let base58 = crate::SolanaSimulateTransactionConfig::new( + std::option::Option::None, + std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base58), + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + ); + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.partial.json")); + let pool = transaction_pool_for_url(url.as_str()); + let _ = pool + .simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-base58-transaction", std::option::Option::Some(&base58)) + .await + .expect("simulateTransaction base58 fixture must succeed"); + let request = handle.join().expect("fixture server must join"); + assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["opaque-base58-transaction", {"encoding":"base58"}])); + + let empty = crate::SolanaSimulateTransactionConfig::default(); + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.partial.json")); + let pool = transaction_pool_for_url(url.as_str()); + let _ = pool + .simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-default-transaction", std::option::Option::Some(&empty)) + .await + .expect("simulateTransaction empty config must canonicalize to omission"); + let request = handle.join().expect("fixture server must join"); + assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["opaque-default-transaction"])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_simulate_transaction_supports_all_current_account_return_encodings() { + let address = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let encodings = [crate::SolanaAccountEncoding::Base64, crate::SolanaAccountEncoding::Base64Zstd, crate::SolanaAccountEncoding::JsonParsed]; + for encoding in encodings { + let accounts = crate::SolanaSimulationAccountsConfig::new(std::option::Option::Some(encoding), std::vec![address]); + let config = crate::SolanaSimulateTransactionConfig::new( + std::option::Option::None, + std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64), + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(accounts), + ); + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.partial.json")); + let pool = transaction_pool_for_url(url.as_str()); + let _ = pool + .simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::Some(&config)) + .await + .expect("supported simulation account encoding must execute"); + let request = handle.join().expect("fixture server must join"); + let body = transaction_request_body(request.as_str()); + assert_eq!(body["params"][1]["accounts"]["encoding"], serde_json::json!(encoding.as_str())); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_simulate_transaction_rejects_deterministic_invalid_configs_before_io() { + let pool = transaction_pool_for_url(unused_local_url().as_str()); + let conflicting = crate::SolanaSimulateTransactionConfig::new( + std::option::Option::None, + std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64), + std::option::Option::Some(true), + std::option::Option::Some(true), + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + ); + let result = pool.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::Some(&conflicting)).await; + assert_eq!(result.expect_err("conflicting simulation flags must fail before I/O").code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); + + let address = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let rejected_encodings = [crate::SolanaAccountEncoding::Binary, crate::SolanaAccountEncoding::Base58]; + for encoding in rejected_encodings { + let accounts = crate::SolanaSimulationAccountsConfig::new(std::option::Option::Some(encoding), std::vec![address]); + let config = crate::SolanaSimulateTransactionConfig::new( + std::option::Option::None, + std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64), + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(accounts), + ); + let result = pool.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::Some(&config)).await; + assert_eq!(result.expect_err("unsupported simulation account encoding must fail before I/O").code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_simulate_transaction_preserves_partial_null_and_omitted_result() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.partial.json")); + let pool = transaction_pool_for_url(url.as_str()); + let response = pool + .simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None) + .await + .expect("partial simulateTransaction fixture must succeed"); + handle.join().expect("fixture server must join"); + assert!(response.value().err().is_null()); + assert!(response.value().logs().is_null()); + assert!(response.value().accounts().is_null()); + assert_eq!(response.value().units_consumed().value(), std::option::Option::Some(&99)); + assert!(response.value().loaded_accounts_data_size().is_omitted()); + assert!(response.value().return_data().is_null()); + assert!(response.value().inner_instructions().is_omitted()); + assert!(response.value().replacement_blockhash().is_null()); + assert!(response.value().fee().is_omitted()); + assert!(response.value().loaded_addresses().is_omitted()); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_simulate_transaction_preserves_rpc_application_error() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.error.json")); + let pool = transaction_pool_for_url(url.as_str()); + let result = pool.simulate_transaction(&crate::HttpRoleName::new("default"), "invalid-opaque-transaction", std::option::Option::None).await; + assert_eq!(result.expect_err("simulation RPC error must propagate").code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR); + handle.join().expect("fixture server must join"); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_simulate_transaction_retries_temporary_http_failure_safely() { + let (url, handle) = serve_transaction_status_then_body("503 Service Unavailable", include_str!("../fixtures/http/simulate_transaction.partial.json")); + let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 1); + let response = pool + .simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-retry-safe-transaction", std::option::Option::None) + .await + .expect("retry-safe simulation must recover after temporary HTTP failure"); + assert_eq!(response.value().units_consumed().value(), std::option::Option::Some(&99)); + let (first, second) = handle.join().expect("retry fixture server must join"); + assert_eq!(transaction_request_body(first.as_str())["method"], serde_json::json!("simulateTransaction")); + assert_eq!(transaction_request_body(second.as_str())["method"], serde_json::json!("simulateTransaction")); +} diff --git a/deltas/0.2.3/pre.007.md b/deltas/0.2.3/pre.007.md new file mode 100644 index 0000000..83263d3 --- /dev/null +++ b/deltas/0.2.3/pre.007.md @@ -0,0 +1,313 @@ + + + +# Delta `0.2.3-pre.007` — `simulateTransaction` complet + +## Base requise + +Livraison précédente corrigée et validée localement par l'opérateur : + +```text +0.2.3-pre.006-fix.001 +workspace.package.version = "0.2.3-pre.6.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 +175 unit tests +18 public API tests +12 release completeness tests +1 smoke Devnet ignored / opt-in +0 warning signalé par check/clippy +``` + +## Objectif + +Activer la dernière méthode Transactions de `0.2.3` : + +```text +simulateTransaction +``` + +Après cette tranche : + +```text +11 / 11 wrappers Transactions exécutés +8 Read / RetrySafe +2 WriteSubmission / NeverAfterDispatch +1 Simulation / RetrySafe +``` + +La tranche ne décode ni ne construit une transaction Solana et n'ajoute aucune dépendance codec/SDK. + +## Version Cargo + +Conformément à `VER-ID-009` : + +```text +0.2.3-pre.6.fix.1 -> 0.2.3-pre.7 +``` + +Le header du `Cargo.toml` passe de `version: 127` à `version: 128`. + +Aucune dépendance ni feature Cargo n'est ajoutée. + +## Complétude `KSP-TRANSPORT-007` + +La page Solana RPC courante documente pour `simulateTransaction` : + +```text +transaction string +commitment +encoding = base58 | base64 +replaceRecentBlockhash +sigVerify +minContextSlot +innerInstructions +accounts +``` + +La source primaire Agave `v4.2.1` confirme les mêmes champs dans `RpcSimulateTransactionConfig`, avec des booléens runtime par défaut à `false`. + +KSP expose donc la config complète via `SolanaSimulateTransactionConfig`. Les booléens restent `Option` côté KSP afin de distinguer l'omission de +l'envoi explicite de `false` sans modifier la sémantique RPC. + +Les deux encodings de transaction réellement acceptés sont couverts : + +```text +base58 +base64 +``` + +La transaction reste une chaîne opaque : Transport ne tente pas de reproduire le décodage, la désérialisation ou la sanitization Agave. + +## Sous-config `accounts` + +Agave `v4.2.1` utilise `RpcSimulateTransactionAccountsConfig` avec : + +```text +encoding: Option +addresses: Vec +``` + +La source runtime rejette explicitement les encodings Account legacy : + +```text +binary +base58 +``` + +KSP accepte donc les variantes actuellement compatibles avec ce retour : + +```text +base64 +base64+zstd +jsonParsed +``` + +Ces trois variantes sont exercées par tests HTTP. `binary` et `base58` sont rejetées avant I/O avec `ERROR_CODE_INVALID_RPC_PARAMETERS`. + +Quand aucun encoding Account n'est fourni, KSP omet le champ et laisse le runtime appliquer son défaut `base64`. + +Agave borne dynamiquement le nombre d'adresses demandées au nombre de comptes contenu dans la transaction. Comme KSP ne décode pas la transaction dans +cette release, cette limite reste volontairement côté runtime/provider au lieu d'introduire une validation partielle ou un codec transactionnel. + +## Invariant `sigVerify` / `replaceRecentBlockhash` + +Agave `v4.2.1` refuse explicitement : + +```text +sigVerify = true +replaceRecentBlockhash = true +``` + +avec l'erreur de paramètres indiquant que `sigVerify` ne peut pas être utilisé avec `replaceRecentBlockhash`. + +Cet invariant est déterministe et ne dépend pas du contenu encodé de la transaction. KSP le rejette donc avant toute sélection/exécution HTTP. + +## Résultat riche et lossless + +`SolanaSimulateTransactionResult` devient le décodeur production du résultat contextualisé. + +Les champs courants Agave `v4.2.1` sont tous préservés : + +```text +err +logs +accounts +unitsConsumed +loadedAccountsDataSize +returnData +innerInstructions +replacementBlockhash +fee +preBalances +postBalances +preTokenBalances +postTokenBalances +loadedAddresses +``` + +La page publique Solana résume seulement un sous-ensemble de ces champs. KSP conserve également les champs runtime supplémentaires afin de respecter +`KSP-TRANSPORT-007` et de ne pas perdre de données disponibles. + +Les champs optionnels utilisent `SolanaWireField` lorsque l'absence, le `null` explicite et la valeur concrète doivent rester distinguables : + +```text +Omitted +Null +Value(T) +``` + +Les comptes retournés réutilisent `SolanaAccount` et conservent les `null` positionnels. Les sous-arbres Program/SPL riches (`returnData`, inner +instructions, token balances, loaded addresses) restent lossless via `serde_json::Value` aux frontières déjà prévues, sans imposer les crates +transaction-status à Transport. + +## Retry de simulation + +Le descriptor existant reste : + +```text +RpcOperationKind::Simulation +TransportRetryClass::RetrySafe +``` + +Contrairement à `requestAirdrop` et `sendTransaction`, la simulation n'est pas soumise à la règle no-resend d'une écriture : elle ne diffuse pas la +transaction au cluster. + +Un test end-to-end exerce réellement : + +```text +simulate_transaction +-> descriptor central +-> execute_standard_rpc +-> HTTP 503 +-> retry central +-> HTTP 200 +-> résultat typé +``` + +Le test démontre donc que la classification `RetrySafe` est utilisée par le vrai chemin wrapper/executor et pas uniquement par une fonction pure de +policy. + +## Tests ajoutés + +Sept tests unitaires ajoutent : + +```text +config complète + résultat riche +base58 + canonicalisation config vide +base64 / base64+zstd / jsonParsed pour accounts +rejet avant I/O du conflit sigVerify/replaceRecentBlockhash et de binary/base58 account encoding +préservation omitted/null/value d'un résultat partiel +préservation d'une erreur RPC applicative +retry sûr après HTTP 503 puis succès +``` + +Un test public vérifie la disponibilité du wrapper et de la config complète à la racine de crate. + +Une canarie release vérifie la couverture finale exacte : + +```text +8 Read / RetrySafe +2 WriteSubmission / NeverAfterDispatch +1 Simulation / RetrySafe +11 / 11 méthodes Transactions V0_2_3 +``` + +Résultat attendu après application : + +```text +182 unit tests +19 public API tests +13 release completeness tests +1 smoke Devnet ignored / opt-in +``` + +## Documentation synchronisée + +`crates/ksp-onchain-transport-lib/src/lib.rs` indique désormais que les 11 wrappers Transactions sont actifs après `pre.007`. + +`docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md` passe en version 6 et enregistre le résultat de la tranche. + +Le planning reste : + +```text +pre.008 audit rétroactif KSP-TRANSPORT-007 sur 0.2.1 -> 0.2.3 + remédiations éventuelles +pre.009 clôture finale documentaire/validation/smoke/prompt 0.2.4 +``` + +`CHANGELOG.md` reste réservé à `0.2.3-rel.001`. + +## 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 +docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md +``` + +## Fichiers ajoutés + +```text +crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.success.json +crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.partial.json +crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.error.json +deltas/0.2.3/pre.007.md +``` + +## Dépendances + +Aucune dépendance ajoutée, supprimée ou modifiée. + +En particulier, restent absentes : + +```text +base64 direct dans Transport +bs58 direct dans Transport +wincode +solana-client +crate RPC SDK +transaction-status client types +``` + +## Validations à exécuter + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-onchain-transport-lib +``` + +Aucune commande n'est déclarée réussie sans exécution réelle ou preuve opérateur. + +## Suite + +Après validation et commit attendu : + +```text +v0.2.3-pre.007 +``` + +passer à : + +```text +0.2.3-pre.008 +réaudit rétroactif KSP-TRANSPORT-007 sur les wrappers HTTP 0.2.1 -> 0.2.3 ++ remédiations éventuelles avant la clôture +``` diff --git a/docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md b/docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md index 4841a4e..653e9f5 100644 --- a/docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md +++ b/docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.2.3` — HTTP Transactions @@ -725,3 +725,22 @@ La tranche suivante reste `pre.003` : `getFeeForMessage`, `getLatestBlockhash`, La prévision est ajustée : `pre.008` devient l'audit rétroactif `KSP-TRANSPORT-007` et la clôture finale est déplacée à `pre.009`. +## Résultat de `pre.007` + +`pre.007` active la dernière méthode Transaction, `simulateTransaction`, et porte donc la couverture typée `0.2.3` à **11/11** : + +- transaction d'entrée conservée comme chaîne opaque, sans dépendance codec/SDK ; +- encodings d'entrée `base58` et `base64` couverts ; +- config complète conservée : `commitment`, `encoding`, `replaceRecentBlockhash`, `sigVerify`, `minContextSlot`, `innerInstructions`, `accounts` ; +- sous-config `accounts` capable d'émettre `base64`, `base64+zstd` et `jsonParsed`, avec rejet local de `binary/base58` conformément à Agave `v4.2.1` ; +- incompatibilité déterministe `sigVerify=true` + `replaceRecentBlockhash=true` rejetée avant I/O ; +- limite dynamique du nombre d'adresses `accounts` laissée au runtime puisque KSP ne décode pas la transaction ; +- résultat riche préservé losslessly, y compris les champs Agave `v4.2.1` au-delà du résumé documentaire public : `fee`, balances, token balances et loaded addresses ; +- `omitted/null/value` conservés pour les champs optionnels avec `SolanaWireField` ; +- accounts de simulation décodés via le wire Account KSP existant, avec `null` positionnels conservés ; +- erreur RPC applicative conservée sans transformation en erreur transport ; +- descriptor central `Simulation / RetrySafe` exercé end-to-end par un scénario HTTP 503 puis succès, démontrant que la simulation peut être retentée ; +- canarie release exacte `8 Read / 2 WriteSubmission / 1 Simulation`, soit les 11 méthodes `V0_2_3`. + +Aucune dépendance n'est ajoutée. Le travail fonctionnel initialement planifié pour les 11 Transactions est désormais implémenté ; `pre.008` reste volontairement +réservée au réaudit rétroactif `KSP-TRANSPORT-007` des wrappers HTTP `0.2.1` -> `0.2.3`, puis `pre.009` à la clôture.