diff --git a/Cargo.toml b/Cargo.toml index d224e46..db3b2d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 125 +# version: 126 [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.5" +version = "0.2.3-pre.6" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/request_airdrop.error.json b/crates/ksp-onchain-transport-lib/fixtures/http/request_airdrop.error.json new file mode 100644 index 0000000..9934445 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/request_airdrop.error.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid param: supplied blockhash is not valid"},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/request_airdrop.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/request_airdrop.success.json new file mode 100644 index 0000000..f84341f --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/request_airdrop.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":"airdrop-fixture-signature-111111111111111111111111111111111111111111111111","id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/send_transaction.preflight_error.json b/crates/ksp-onchain-transport-lib/fixtures/http/send_transaction.preflight_error.json new file mode 100644 index 0000000..c4a13ae --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/send_transaction.preflight_error.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","error":{"code":-32002,"message":"Transaction simulation failed: Error processing Instruction 0","data":{"err":{"InstructionError":[0,"Custom"]},"logs":["Program log: fixture preflight failure"],"unitsConsumed":123}},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/send_transaction.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/send_transaction.success.json new file mode 100644 index 0000000..58ab331 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/send_transaction.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":"send-fixture-signature-22222222222222222222222222222222222222222222222222","id":1} diff --git a/crates/ksp-onchain-transport-lib/src/lib.rs b/crates/ksp-onchain-transport-lib/src/lib.rs index 0d2ddb3..67352a0 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: 14 +// version: 15 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] @@ -10,10 +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 and seven read wrappers through `pre.004`, -//! including bounded prioritization-fee, address-signature and signature-status queries. `getTransaction`, both write submissions and -//! `simulateTransaction`, plus the -//! `0.2.4` family remain staged. +//! 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. 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 0c4b3e0..67e10e2 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: 5 +// version: 6 /// Binary encoding accepted for serialized transaction input payloads. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -321,14 +321,12 @@ impl SolanaRequestAirdropConfig { } /// Returns whether the airdrop config would serialize to an empty object. - #[cfg(test)] pub(crate) fn is_empty(&self) -> bool { return self.recent_blockhash.is_none() && self.commitment.is_none(); } /// 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(blockhash) = self.recent_blockhash.as_ref() { @@ -395,7 +393,6 @@ impl SolanaSendTransactionConfig { } /// Returns whether the send config would serialize to an empty object. - #[cfg(test)] pub(crate) const fn is_empty(&self) -> bool { return self.skip_preflight.is_none() && self.preflight_commitment.is_none() @@ -406,7 +403,6 @@ impl SolanaSendTransactionConfig { /// 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(value) = self.skip_preflight { @@ -1314,6 +1310,53 @@ impl crate::HttpTransportPool { }; } + /// Executes typed `requestAirdrop` through the common KSP HTTP transport path. + /// + /// The RPC creates and submits a faucet transaction, so its audited descriptor is `WriteSubmission / NeverAfterDispatch`. The optional + /// `recentBlockhash` field is retained from the targeted Agave runtime even though the public Solana page currently documents only `commitment`. + pub async fn request_airdrop( + &self, + role: &crate::HttpRoleName, + recipient: &ksp_core_lib::Pubkey, + lamports: u64, + config: std::option::Option<&crate::SolanaRequestAirdropConfig>, + ) -> ksp_core_lib::Result { + let mut params = std::vec![serde_json::Value::String(recipient.to_string()), serde_json::json!(lamports)]; + if let std::option::Option::Some(config) = config + && !config.is_empty() + { + params.push(config.to_json_value()); + } + let value = self.execute_transaction_rpc("requestAirdrop", role, params).await; + return match value { + std::result::Result::Ok(value) => crate::decode_wire_json::("requestAirdrop", value), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } + + /// Executes typed `sendTransaction` through the common KSP HTTP transport path. + /// + /// Transport forwards an already serialized and signed transaction without decoding or modifying it. `config.maxRetries` controls node-side + /// retransmission only; KSP's HTTP retry policy remains governed by the central `WriteSubmission / NeverAfterDispatch` descriptor. + pub async fn send_transaction( + &self, + role: &crate::HttpRoleName, + transaction: &str, + config: std::option::Option<&crate::SolanaSendTransactionConfig>, + ) -> ksp_core_lib::Result { + 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("sendTransaction", role, params).await; + return match value { + std::result::Result::Ok(value) => crate::decode_wire_json::("sendTransaction", value), + 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, diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index 708f10c..a6897a0 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: 13 +// version: 14 //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. @@ -300,3 +300,24 @@ fn public_transaction_pre_005_get_transaction_complete_request_forms_are_availab assert_eq!(config.encoding(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::JsonParsed)); assert_eq!(config.max_supported_transaction_version(), std::option::Option::Some(0)); } + +#[test] +fn public_transaction_pre_006_write_wrappers_and_complete_configs_are_available_from_crate_root() { + let _request_airdrop = ksp_onchain_transport_lib::HttpTransportPool::request_airdrop; + let _send_transaction = ksp_onchain_transport_lib::HttpTransportPool::send_transaction; + let airdrop = ksp_onchain_transport_lib::SolanaRequestAirdropConfig::new( + std::option::Option::Some("recent-blockhash".to_owned()), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed), + ); + assert_eq!(airdrop.recent_blockhash(), std::option::Option::Some("recent-blockhash")); + let send = ksp_onchain_transport_lib::SolanaSendTransactionConfig::new( + std::option::Option::Some(false), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64), + std::option::Option::Some(5), + std::option::Option::Some(431_000_000), + ); + assert_eq!(send.encoding(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64)); + assert_eq!(send.max_retries(), std::option::Option::Some(5)); + assert_eq!(send.min_context_slot(), std::option::Option::Some(431_000_000)); +} diff --git a/crates/ksp-onchain-transport-lib/tests/release_completeness.rs b/crates/ksp-onchain-transport-lib/tests/release_completeness.rs index f5ca1fe..5feb930 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: 10 +// version: 11 //! Release-level completeness canaries for the `0.2.1` HTTP foundation contract. @@ -326,3 +326,36 @@ fn release_pre_005_transaction_read_subset_adds_complete_get_transaction_without assert_eq!(actual.len(), 8); assert_eq!(deferred.len(), 3); } + +#[test] +fn release_pre_006_transaction_subset_adds_both_write_submissions_without_advancing_simulation() { + let reads = std::vec![ + "getFeeForMessage", + "getLatestBlockhash", + "getRecentPrioritizationFees", + "getSignaturesForAddress", + "getSignatureStatuses", + "getTransaction", + "getTransactionCount", + "isBlockhashValid", + ]; + let writes = std::vec!["requestAirdrop", "sendTransaction"]; + for method_name in &reads { + let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.006 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.006 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); + } + let simulation = ksp_onchain_transport_lib::find_http_rpc_method("simulateTransaction").expect("simulation descriptor must remain registered"); + assert_eq!(simulation.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Simulation); + assert_eq!(reads.len(), 8); + assert_eq!(writes.len(), 2); +} 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 08d5bf8..065d711 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: 5 +// version: 6 #[test] fn transaction_encoding_strings_match_current_and_legacy_wire_labels() { @@ -290,27 +290,34 @@ fn simulation_result_distinguishes_omitted_from_explicit_null_fields() { } 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], - ); + return transaction_pool_for_urls(&[(url, 10)], std::time::Duration::from_secs(1), 0); +} + +fn transaction_pool_for_urls(urls: &[(&str, u32)], request_timeout: std::time::Duration, max_retries: u32) -> crate::HttpTransportPool { + let mut endpoints = std::vec::Vec::with_capacity(urls.len()); + for (index, (url, priority)) in urls.iter().enumerate() { + let role = crate::HttpEndpointRoleSettings::new( + crate::HttpRoleName::new("default"), + true, + std::vec![crate::HttpRequestKind::wildcard()], + *priority, + crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None), + ); + endpoints.push(crate::HttpEndpointSettings::new( + format!("fixture-{index}"), + 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(0, std::time::Duration::from_millis(1), std::time::Duration::from_millis(1)), + endpoints, + 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"); } @@ -328,6 +335,69 @@ fn serve_transaction_once(body: &'static str) -> (std::string::String, std::thre return (format!("http://{address}"), handle); } +fn serve_transaction_status_and_count(status_line: &'static str) -> (std::string::String, std::thread::JoinHandle<(usize, 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 stream, _) = listener.accept().expect("fixture server must accept first request"); + let first_request = read_transaction_request(&mut stream); + let response = format!("HTTP/1.1 {status_line}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write"); + let mut count = 1_usize; + listener.set_nonblocking(true).expect("fixture listener must become nonblocking"); + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(120); + while std::time::Instant::now() < deadline { + match listener.accept() { + std::result::Result::Ok((mut retry_stream, _)) => { + let _ = read_transaction_request(&mut retry_stream); + std::io::Write::write_all(&mut retry_stream, response.as_bytes()).expect("fixture retry response must write"); + count = count.saturating_add(1); + }, + std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(5)); + }, + std::result::Result::Err(error) => panic!("fixture listener failed while counting retries: {error}"), + } + } + return (count, first_request); + }); + return (format!("http://{address}"), handle); +} + +fn serve_transaction_timeout_and_count(delay: std::time::Duration) -> (std::string::String, std::thread::JoinHandle<(usize, 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 stream, _) = listener.accept().expect("fixture server must accept first request"); + let first_request = read_transaction_request(&mut stream); + std::thread::sleep(delay); + let mut count = 1_usize; + listener.set_nonblocking(true).expect("fixture listener must become nonblocking"); + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(120); + while std::time::Instant::now() < deadline { + match listener.accept() { + std::result::Result::Ok((mut retry_stream, _)) => { + let _ = read_transaction_request(&mut retry_stream); + count = count.saturating_add(1); + }, + std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(5)); + }, + std::result::Result::Err(error) => panic!("fixture listener failed while counting timeout retries: {error}"), + } + } + return (count, first_request); + }); + 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"); + drop(listener); + return format!("http://{address}"); +} + 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]; @@ -874,3 +944,198 @@ async fn typed_get_transaction_rejects_processed_commitment_before_io() { assert_eq!(error.context()[1].key(), "commitment"); assert_eq!(error.context()[1].value(), "processed"); } + +#[tokio::test(flavor = "current_thread")] +async fn typed_request_airdrop_exposes_runtime_config_and_canonicalizes_empty_config() { + let recipient = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let config = crate::SolanaRequestAirdropConfig::new( + std::option::Option::Some("recent-blockhash-fixture".to_owned()), + std::option::Option::Some(crate::SolanaCommitment::Finalized), + ); + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/request_airdrop.success.json")); + let pool = transaction_pool_for_url(url.as_str()); + let signature = pool + .request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1_000_000_000, std::option::Option::Some(&config)) + .await + .expect("requestAirdrop full-config fixture must succeed"); + assert_eq!(signature, "airdrop-fixture-signature-111111111111111111111111111111111111111111111111"); + let request = handle.join().expect("fixture server must join"); + let body = transaction_request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("requestAirdrop")); + assert_eq!( + body["params"], + serde_json::json!([ + "11111111111111111111111111111111", + 1000000000, + {"recentBlockhash":"recent-blockhash-fixture","commitment":"finalized"} + ]) + ); + + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/request_airdrop.success.json")); + let pool = transaction_pool_for_url(url.as_str()); + let empty = crate::SolanaRequestAirdropConfig::default(); + let _ = pool + .request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::Some(&empty)) + .await + .expect("requestAirdrop empty-config fixture must succeed"); + let request = handle.join().expect("fixture server must join"); + assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["11111111111111111111111111111111", 1])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_send_transaction_exposes_all_current_options_and_both_binary_encodings() { + let config = crate::SolanaSendTransactionConfig::new( + std::option::Option::Some(true), + std::option::Option::Some(crate::SolanaCommitment::Confirmed), + std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64), + std::option::Option::Some(7), + std::option::Option::Some(431_000_000), + ); + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.success.json")); + let pool = transaction_pool_for_url(url.as_str()); + let signature = pool + .send_transaction(&crate::HttpRoleName::new("default"), "opaque-base64-transaction", std::option::Option::Some(&config)) + .await + .expect("sendTransaction full-config fixture must succeed"); + assert_eq!(signature, "send-fixture-signature-22222222222222222222222222222222222222222222222222"); + let request = handle.join().expect("fixture server must join"); + let body = transaction_request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("sendTransaction")); + assert_eq!( + body["params"], + serde_json::json!([ + "opaque-base64-transaction", + { + "skipPreflight":true, + "preflightCommitment":"confirmed", + "encoding":"base64", + "maxRetries":7, + "minContextSlot":431000000 + } + ]) + ); + + let base58 = crate::SolanaSendTransactionConfig::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base58), + std::option::Option::None, + std::option::Option::None, + ); + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.success.json")); + let pool = transaction_pool_for_url(url.as_str()); + let _ = pool + .send_transaction(&crate::HttpRoleName::new("default"), "opaque-base58-transaction", std::option::Option::Some(&base58)) + .await + .expect("sendTransaction 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::SolanaSendTransactionConfig::default(); + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.success.json")); + let pool = transaction_pool_for_url(url.as_str()); + let _ = pool + .send_transaction(&crate::HttpRoleName::new("default"), "opaque-default-transaction", std::option::Option::Some(&empty)) + .await + .expect("sendTransaction 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_write_wrappers_preserve_rpc_application_errors_without_transport_retry() { + let recipient = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/request_airdrop.error.json")); + let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3); + let result = pool.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None).await; + assert_eq!(result.expect_err("requestAirdrop RPC error must propagate").code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR); + handle.join().expect("fixture server must join"); + + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.preflight_error.json")); + let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3); + let result = pool.send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None).await; + assert_eq!(result.expect_err("sendTransaction preflight 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_write_wrappers_never_resend_after_http_429_dispatch() { + let recipient = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let (url, handle) = serve_transaction_status_and_count("429 Too Many Requests"); + let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3); + let result = pool.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None).await; + assert_eq!(result.expect_err("airdrop 429 must stop after dispatch").code(), crate::ERROR_CODE_RATE_LIMITED); + let (count, request) = handle.join().expect("fixture server must join"); + assert_eq!(count, 1); + assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("requestAirdrop")); + + let (url, handle) = serve_transaction_status_and_count("429 Too Many Requests"); + let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3); + let result = pool.send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None).await; + assert_eq!(result.expect_err("send 429 must stop after dispatch").code(), crate::ERROR_CODE_RATE_LIMITED); + let (count, request) = handle.join().expect("fixture server must join"); + assert_eq!(count, 1); + assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("sendTransaction")); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_write_wrappers_never_resend_after_temporary_http_dispatch() { + let recipient = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let (url, handle) = serve_transaction_status_and_count("503 Service Unavailable"); + let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3); + let result = pool.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None).await; + assert_eq!(result.expect_err("airdrop 503 must stop after dispatch").code(), crate::ERROR_CODE_HTTP_REQUEST_FAILED); + let (count, _) = handle.join().expect("fixture server must join"); + assert_eq!(count, 1); + + let (url, handle) = serve_transaction_status_and_count("503 Service Unavailable"); + let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3); + let result = pool.send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None).await; + assert_eq!(result.expect_err("send 503 must stop after dispatch").code(), crate::ERROR_CODE_HTTP_REQUEST_FAILED); + let (count, _) = handle.join().expect("fixture server must join"); + assert_eq!(count, 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_write_wrappers_never_resend_after_ambiguous_timeout_dispatch() { + let recipient = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let (url, handle) = serve_transaction_timeout_and_count(std::time::Duration::from_millis(80)); + let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(20), 3); + let result = pool.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None).await; + assert_eq!(result.expect_err("airdrop timeout must stop after ambiguous dispatch").code(), crate::ERROR_CODE_TIMEOUT); + let (count, _) = handle.join().expect("fixture server must join"); + assert_eq!(count, 1); + + let (url, handle) = serve_transaction_timeout_and_count(std::time::Duration::from_millis(80)); + let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(20), 3); + let result = pool.send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None).await; + assert_eq!(result.expect_err("send timeout must stop after ambiguous dispatch").code(), crate::ERROR_CODE_TIMEOUT); + let (count, _) = handle.join().expect("fixture server must join"); + assert_eq!(count, 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_write_wrappers_can_retry_when_connection_failure_proves_not_dispatched() { + let recipient = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let closed = unused_local_url(); + let (secondary, handle) = serve_transaction_once(include_str!("../fixtures/http/request_airdrop.success.json")); + let pool = transaction_pool_for_urls(&[(closed.as_str(), 10), (secondary.as_str(), 10)], std::time::Duration::from_millis(500), 1); + let result = pool + .request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None) + .await + .expect("NotDispatched connection failure may retry requestAirdrop safely"); + assert_eq!(result, "airdrop-fixture-signature-111111111111111111111111111111111111111111111111"); + let request = handle.join().expect("secondary fixture server must join"); + assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("requestAirdrop")); + + let closed = unused_local_url(); + let (secondary, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.success.json")); + let pool = transaction_pool_for_urls(&[(closed.as_str(), 10), (secondary.as_str(), 10)], std::time::Duration::from_millis(500), 1); + let result = pool + .send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None) + .await + .expect("NotDispatched connection failure may retry sendTransaction safely"); + assert_eq!(result, "send-fixture-signature-22222222222222222222222222222222222222222222222222"); + let request = handle.join().expect("secondary fixture server must join"); + assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("sendTransaction")); +} diff --git a/deltas/0.2.3/pre.006.md b/deltas/0.2.3/pre.006.md new file mode 100644 index 0000000..0f42f2d --- /dev/null +++ b/deltas/0.2.3/pre.006.md @@ -0,0 +1,282 @@ + + + +# Delta `0.2.3-pre.006` — `requestAirdrop` + `sendTransaction` et preuve no-resend + +## Base requise + +Livraison précédente validée localement par l'opérateur : + +```text +0.2.3-pre.005 +workspace.package.version = "0.2.3-pre.5" +``` + +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 +168 unit tests +17 public API tests +11 release completeness tests +0 warning signalé par check/clippy +``` + +## Objectif + +Activer les deux écritures de la famille Transactions : + +```text +requestAirdrop +sendTransaction +``` + +Les deux wrappers restent attachés aux descriptors centraux déjà audités : + +```text +RpcOperationKind::WriteSubmission +TransportRetryClass::NeverAfterDispatch +``` + +Après cette tranche : + +```text +10 / 11 wrappers Transactions exécutés +8 Read +2 WriteSubmission +1 Simulation encore différée +``` + +`simulateTransaction` reste réservée à `pre.007`. + +## Version Cargo + +Conformément à `VER-ID-009` : + +```text +0.2.3-pre.5 -> 0.2.3-pre.6 +``` + +Aucune dépendance ni feature Cargo n'est ajoutée. + +## Complétude `requestAirdrop` + +API publique : + +```text +HttpTransportPool::request_airdrop( + role, + recipient: &Pubkey, + lamports: u64, + Option<&SolanaRequestAirdropConfig>, +) + -> Result +``` + +La page publique Solana courante documente `commitment` dans l'objet de config. La source primaire Agave `v4.2.1` conserve en plus : + +```text +recentBlockhash: Option +``` + +KSP l'expose donc conformément à `KSP-TRANSPORT-007` au lieu de réduire la surface au seul exemple/document public visible. + +Le résultat reste la signature de transaction retournée par le faucet sous forme de chaîne wire. Transport ne prend pas une dépendance Signature uniquement +pour redécoder une valeur que le provider vient de produire. + +Une config vide est canonicalisée vers l'omission du troisième paramètre, syntaxe strictement équivalente autorisée par `KSP-TRANSPORT-007`. + +## Complétude `sendTransaction` + +API publique : + +```text +HttpTransportPool::send_transaction( + role, + transaction: &str, + Option<&SolanaSendTransactionConfig>, +) + -> Result +``` + +Transport reçoit une transaction déjà construite, signée et encodée. Il ne la décode, ne la signe et ne la modifie pas. + +La config expose la surface courante complète : + +```text +encoding Base58 | Base64 +skipPreflight Option +preflightCommitment Option +maxRetries Option +minContextSlot Option +``` + +Les deux encodings binaires sont exercés par fixtures HTTP locales. + +Une config vide est canonicalisée vers l'omission du second paramètre. + +### Distinction impérative des retries + +```text +SolanaSendTransactionConfig.maxRetries = retransmissions node-side après acceptation RPC +HttpRetrySettings = retries HTTP Transport KSP +``` + +Le premier champ n'accorde aucune permission de resoumission HTTP au wrapper. + +## Preuves end-to-end de no-resend + +Les tests ne se limitent pas à `evaluate_transport_retry`. + +Ils exercent réellement : + +```text +wrapper typed +-> descriptor central +-> execute_standard_rpc +-> client HTTP fixture +``` + +Pour **chacun** de `requestAirdrop` et `sendTransaction`, un serveur local compte les requêtes reçues. + +Cas ambigus après dispatch : + +```text +HTTP 429 -> erreur rate_limited -> compteur = 1 +HTTP 503 -> erreur http_request_failed -> compteur = 1 +timeout -> erreur timeout -> compteur = 1 +``` + +Même avec plusieurs retries configurés dans `HttpRetrySettings`, aucune seconde soumission n'est émise après ces trois états ambigus. + +Cas explicitement sûr : + +```text +connexion refusée -> ERROR_CODE_HTTP_CONNECTION_FAILED -> NotDispatched +``` + +Avec deux endpoints de même priorité, la première tentative échoue avant dispatch et le retry central peut atteindre le second endpoint. Cette preuve est +exercée séparément pour les deux wrappers. + +## Erreurs RPC applicatives + +Les fixtures couvrent aussi : + +- une erreur `requestAirdrop` de paramètres/blockhash ; +- une erreur `sendTransaction` de preflight/simulation. + +Ces réponses restent `ERROR_CODE_RPC_APPLICATION_ERROR` et ne deviennent jamais des retries transport. + +## `KSP-TRANSPORT-007` rétroactif + +La question opérateur sur les wrappers antérieurs est enregistrée dans le plan `010`. + +Les audits `0.2.1`/`0.2.2` avaient déjà recherché la surface complète. Un contrôle ciblé pendant cette tranche confirme notamment l'alignement KSP avec +Agave `v4.2.1` pour : + +```text +RpcAccountInfoConfig +RpcProgramAccountsConfig +RpcLargestAccountsConfig +RpcLeaderScheduleConfig +RpcGetVoteAccountsConfig +``` + +Mais la règle n'était pas encore un critère de clôture nommé. La prévision devient donc : + +```text +pre.007 simulateTransaction complet +pre.008 audit rétroactif KSP-TRANSPORT-007 sur 0.2.1 -> 0.2.3 + corrections éventuelles +pre.009 clôture finale documentaire/validation/smoke/prompt 0.2.4 +``` + +Cette tranche supplémentaire évite de transformer la clôture en remédiation fonctionnelle tardive. + +## Documentation/source synchronisée + +`crates/ksp-onchain-transport-lib/src/lib.rs` est corrigé pour refléter la surface réellement disponible après `pre.006` : + +```text +8 reads Transactions +2 write submissions +simulateTransaction encore staged +``` + +Le plan `docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md` passe en version 5 et enregistre le nouveau découpage jusqu'à `pre.009`. + +`CHANGELOG.md` reste réservé à `0.2.3-rel.001`. + +## Tests ajoutés + +Sept tests unitaires couvrent : + +```text +requestAirdrop config complète + canonicalisation config vide +sendTransaction config complète + base58/base64 + config vide +erreurs RPC applicatives des deux writes +no-resend après HTTP 429 pour les deux writes +no-resend après HTTP 503 pour les deux writes +no-resend après timeout ambigu pour les deux writes +retry/fallback autorisé après connexion NotDispatched pour les deux writes +``` + +Un test public vérifie la disponibilité des deux wrappers/configs à la racine de crate. + +Une canarie release vérifie le sous-ensemble `pre.006` : + +```text +8 Read / RetrySafe +2 WriteSubmission / NeverAfterDispatch +simulateTransaction encore différée +``` + +Cible après application : + +```text +175 unit tests +18 public API tests +12 release completeness tests +``` + +## Fichiers du delta + +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 +``` + +Ajoutés : + +```text +crates/ksp-onchain-transport-lib/fixtures/http/request_airdrop.success.json +crates/ksp-onchain-transport-lib/fixtures/http/request_airdrop.error.json +crates/ksp-onchain-transport-lib/fixtures/http/send_transaction.success.json +crates/ksp-onchain-transport-lib/fixtures/http/send_transaction.preflight_error.json +deltas/0.2.3/pre.006.md +``` + +## Validations attendues opérateur + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-onchain-transport-lib +``` + +L'environnement de préparation du delta ne possède pas Cargo/Rustfmt. Aucune de ces commandes n'est donc déclarée réussie avant preuve opérateur. 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 17f5ead..4841a4e 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 @@ -589,6 +589,27 @@ Un candidat raisonnable pour la clôture est un smoke Transport pur combinant `g simulation live n'est retenue que si une fixture transactionnelle stable peut être fournie sans déplacer la construction/signature métier dans Transport. +## Audit rétroactif `KSP-TRANSPORT-007` planifié + +La règle `KSP-TRANSPORT-007` formalise en `pre.005` un principe déjà appliqué dans les audits de `0.2.1` et `0.2.2`, mais elle n'était pas encore un +critère de clôture nommé lors de ces releases. Un contrôle rétroactif dédié est donc ajouté avant la clôture documentaire. + +L'audit `pre.008` couvrira au minimum : + +```text +4 wrappers foundation de 0.2.1 +22 wrappers Accounts/Tokens/Cluster de 0.2.2 +11 wrappers Transactions de 0.2.3 +``` + +Il comparera la surface publique KSP aux paramètres/configs/overloads et formes de réponse des sources normatives courantes retenues. Les syntaxes +strictement équivalentes peuvent rester canonicalisées conformément à la règle. Toute possibilité supportée manquante sera corrigée dans cette tranche ; +si aucun manque n'est trouvé, la tranche fournira la preuve/canarie d'audit sans modification fonctionnelle artificielle. + +Un contrôle ciblé effectué pendant `pre.006` confirme déjà que les configs structurantes de `0.2.2` (`RpcAccountInfoConfig`, +`RpcProgramAccountsConfig`, `RpcLargestAccountsConfig`, `RpcLeaderScheduleConfig`, `RpcGetVoteAccountsConfig`) correspondent aux champs Agave +`v4.2.1` exposés par les DTOs KSP. Ce contrôle ne remplace pas l'audit exhaustif `pre.008`. + ## Prévision souple des prereleases ```text @@ -599,11 +620,14 @@ pre.004 getRecentPrioritizationFees + getSignaturesForAddress + getSignatureSta pre.005 getTransaction moderne + compatibilité legacy + transaction/meta/version wire pre.006 requestAirdrop + sendTransaction + preuves end-to-end no-resend pre.007 simulateTransaction + résultat riche + invariants de config -pre.008 réaudit final + canaries + smoke opt-in si pertinent + README/USAGE + validation + prompt 0.2.4 +pre.008 audit rétroactif KSP-TRANSPORT-007 sur 0.2.1 -> 0.2.3 + remédiations éventuelles +pre.009 réaudit final + canaries + smoke opt-in si pertinent + README/USAGE + validation + prompt 0.2.4 ``` -Une prerelease intermédiaire peut être ajoutée si le volume réel d'une tranche dépasse le budget. La dernière prerelease reste une tranche de -clôture documentaire/validation et ne doit pas devenir une implémentation massive tardive. +Une prerelease intermédiaire peut être ajoutée si le volume réel d'une tranche dépasse le budget. Après formalisation de `KSP-TRANSPORT-007` en +`pre.005`, `pre.008` est désormais réservée à un audit rétroactif explicite des wrappers HTTP déjà livrés depuis `0.2.1`, afin de ne pas transformer la +tranche de clôture en remédiation fonctionnelle tardive. La dernière prerelease reste une tranche de clôture documentaire/validation et ne doit pas +devenir une implémentation massive tardive. ## Critères de clôture de `0.2.3` @@ -683,3 +707,21 @@ La tranche suivante peut commencer par les primitives wire communes sans réouvr - aucune nouvelle dépendance Cargo et aucun wrapper `0.2.3` activé prématurément. La tranche suivante reste `pre.003` : `getFeeForMessage`, `getLatestBlockhash`, `getTransactionCount` et `isBlockhashValid`. + +## Résultat de `pre.006` + +`pre.006` active les deux écritures Transaction sans introduire de chemin de soumission parallèle : + +- `requestAirdrop` expose `Pubkey`, lamports, `commitment` et le `recentBlockhash` encore supporté par Agave `v4.2.1` ; +- `sendTransaction` transporte une transaction déjà signée/encodée et expose `base58/base64`, `skipPreflight`, `preflightCommitment`, `maxRetries` et + `minContextSlot` ; +- `sendTransaction.maxRetries` reste explicitement node-side et ne modifie jamais `HttpRetrySettings` ; +- les deux wrappers passent par leur descriptor central `WriteSubmission / NeverAfterDispatch` puis `execute_standard_rpc` ; +- les fixtures end-to-end comptent les requêtes et prouvent une seule soumission après HTTP 429, HTTP 503 et timeout après dispatch ; +- une connexion refusée classée `NotDispatched` peut encore utiliser la policy centrale de retry/fallback, démontrée pour les deux wrappers ; +- les erreurs RPC applicatives, dont un échec de preflight `sendTransaction`, restent des erreurs applicatives sans retry transport ; +- aucune dépendance transactionnelle/codec supplémentaire n'est ajoutée ; +- `simulateTransaction` reste la seule méthode `0.2.3` non encore activée. + +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`. +