diff --git a/Cargo.toml b/Cargo.toml index 34af1c9..7b3aa88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 123 +# version: 124 [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.3" +version = "0.2.3-pre.4" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_recent_prioritization_fees.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_recent_prioritization_fees.success.json new file mode 100644 index 0000000..03a2a0a --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_recent_prioritization_fees.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":[{"slot":431000030,"prioritizationFee":1200},{"slot":431000029,"prioritizationFee":0}],"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.count_mismatch.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.count_mismatch.json new file mode 100644 index 0000000..a1cfb12 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.count_mismatch.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000052},"value":[null]},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.empty.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.empty.json new file mode 100644 index 0000000..4c1c3da --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.empty.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000051},"value":[]},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.max_256.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.max_256.json new file mode 100644 index 0000000..1b0c93c --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.max_256.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000053},"value":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.success.json new file mode 100644 index 0000000..9024d10 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000050},"value":[null,{"slot":431000049,"confirmations":null,"status":{"Ok":null},"err":null,"confirmationStatus":"finalized"},{"slot":431000048,"confirmations":2,"status":{"Err":{"InstructionError":[0,"Custom"]}},"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_signatures_for_address.error.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_signatures_for_address.error.json new file mode 100644 index 0000000..3ef84bf --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_signatures_for_address.error.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","error":{"code":-32015,"message":"Transaction history is not available from this node"},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_signatures_for_address.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_signatures_for_address.success.json new file mode 100644 index 0000000..ad44f32 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_signatures_for_address.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":[{"signature":"newest-signature","slot":431000040,"err":null,"memo":"memo-newest","blockTime":1787000100,"confirmationStatus":"finalized","transactionIndex":9},{"signature":"older-signature","slot":431000039,"err":{"InstructionError":[0,"Custom"]},"memo":null,"blockTime":null,"confirmationStatus":"confirmed"}],"id":1} diff --git a/crates/ksp-onchain-transport-lib/src/lib.rs b/crates/ksp-onchain-transport-lib/src/lib.rs index 49e7e83..0d2ddb3 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: 13 +// version: 14 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] @@ -10,9 +10,10 @@ //! 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 its first four read wrappers: -//! `getFeeForMessage`, `getLatestBlockhash`, `getTransactionCount` and `isBlockhashValid`. The remaining seven Transaction wrappers 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 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. 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 4215e6f..3b91a86 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: 3 +// version: 4 /// Binary encoding accepted for serialized transaction input payloads. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -236,14 +236,12 @@ impl SolanaSignaturesForAddressConfig { } /// Returns whether the pagination config would serialize to an empty object. - #[cfg(test)] pub(crate) fn is_empty(&self) -> bool { return self.before.is_none() && self.until.is_none() && self.limit.is_none() && self.commitment().is_none() && self.min_context_slot().is_none(); } /// Serializes this pagination config to the Solana JSON-RPC wire object. #[must_use] - #[cfg(test)] pub(crate) fn to_json_value(&self) -> serde_json::Value { let context = self.context.to_json_value(); let mut object = match context { @@ -283,14 +281,12 @@ impl SolanaSignatureStatusesConfig { } /// Returns whether the status config would serialize to an empty object. - #[cfg(test)] pub(crate) const fn is_empty(&self) -> bool { return self.search_transaction_history.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(value) = self.search_transaction_history { @@ -651,7 +647,6 @@ impl SolanaPrioritizationFee { } /// Decodes one prioritization-fee sample 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); return match decoded { @@ -728,7 +723,6 @@ impl SolanaSignatureInfo { } /// Decodes one signature record 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 { @@ -794,7 +788,6 @@ impl SolanaSignatureStatus { } /// Decodes one present signature status 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 { @@ -1102,6 +1095,10 @@ impl SolanaSimulateTransactionResult { } } +const MAX_RECENT_PRIORITIZATION_FEE_ACCOUNTS: usize = 128; +const MAX_SIGNATURES_FOR_ADDRESS_LIMIT: usize = 1_000; +const MAX_SIGNATURE_STATUSES: usize = 256; + impl crate::HttpTransportPool { /// Executes typed `getFeeForMessage` through the common KSP HTTP transport path. pub async fn get_fee_for_message( @@ -1154,6 +1151,106 @@ impl crate::HttpTransportPool { }; } + /// Executes typed `getRecentPrioritizationFees` through the common KSP HTTP transport path. + pub async fn get_recent_prioritization_fees( + &self, + role: &crate::HttpRoleName, + writable_accounts: std::option::Option<&[ksp_core_lib::Pubkey]>, + ) -> ksp_core_lib::Result> { + if let std::option::Option::Some(writable_accounts) = writable_accounts + && writable_accounts.len() > MAX_RECENT_PRIORITIZATION_FEE_ACCOUNTS + { + return invalid_transaction_parameters( + "getRecentPrioritizationFees", + "getRecentPrioritizationFees accepts at most 128 account addresses", + "account_count", + writable_accounts.len(), + ); + } + let mut params = std::vec::Vec::new(); + if let std::option::Option::Some(writable_accounts) = writable_accounts { + let mut addresses = std::vec::Vec::with_capacity(writable_accounts.len()); + for account in writable_accounts { + addresses.push(serde_json::Value::String(account.to_string())); + } + params.push(serde_json::Value::Array(addresses)); + } + let value = self.execute_transaction_rpc("getRecentPrioritizationFees", role, params).await; + let value = match value { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return decode_prioritization_fees("getRecentPrioritizationFees", value); + } + + /// Executes typed `getSignaturesForAddress` through the common KSP HTTP transport path. + pub async fn get_signatures_for_address( + &self, + role: &crate::HttpRoleName, + address: &ksp_core_lib::Pubkey, + config: std::option::Option<&crate::SolanaSignaturesForAddressConfig>, + ) -> ksp_core_lib::Result> { + if let std::option::Option::Some(limit) = config.and_then(crate::SolanaSignaturesForAddressConfig::limit) + && (limit == 0 || limit > MAX_SIGNATURES_FOR_ADDRESS_LIMIT) + { + return invalid_transaction_parameters("getSignaturesForAddress", "getSignaturesForAddress limit must be between 1 and 1000", "limit", limit); + } + let mut params = std::vec![serde_json::Value::String(address.to_string())]; + if let std::option::Option::Some(config) = config + && !config.is_empty() + { + params.push(config.to_json_value()); + } + let value = self.execute_transaction_rpc("getSignaturesForAddress", role, params).await; + let value = match value { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return decode_signature_infos("getSignaturesForAddress", value); + } + + /// Executes typed `getSignatureStatuses` through the common KSP HTTP transport path. + pub async fn get_signature_statuses( + &self, + role: &crate::HttpRoleName, + signatures: &[std::string::String], + config: std::option::Option<&crate::SolanaSignatureStatusesConfig>, + ) -> ksp_core_lib::Result>>> { + if signatures.len() > MAX_SIGNATURE_STATUSES { + return invalid_transaction_parameters( + "getSignatureStatuses", + "getSignatureStatuses accepts at most 256 signatures", + "signature_count", + signatures.len(), + ); + } + let mut signature_values = std::vec::Vec::with_capacity(signatures.len()); + for signature in signatures { + signature_values.push(serde_json::Value::String(signature.clone())); + } + let mut params = std::vec![serde_json::Value::Array(signature_values)]; + if let std::option::Option::Some(config) = config + && !config.is_empty() + { + params.push((*config).to_json_value()); + } + let value = self.execute_transaction_rpc("getSignatureStatuses", 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("getSignatureStatuses", value); + let (context, value) = match contextual { + std::result::Result::Ok(contextual) => contextual, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let statuses = decode_signature_statuses("getSignatureStatuses", value, signatures.len()); + return match statuses { + std::result::Result::Ok(statuses) => std::result::Result::Ok(crate::SolanaRpcResponse::new(context, statuses)), + 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, @@ -1232,6 +1329,84 @@ fn decode_transaction_contextual_wire(method: &str, value: serde_json::Value) -> }; } +fn decode_prioritization_fees(method: &str, value: serde_json::Value) -> ksp_core_lib::Result> { + let decoded = crate::decode_wire_json::>(method, value); + let values = match decoded { + std::result::Result::Ok(values) => values, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut fees = std::vec::Vec::with_capacity(values.len()); + for value in values { + let fee = crate::SolanaPrioritizationFee::decode_wire(method, value); + match fee { + std::result::Result::Ok(fee) => fees.push(fee), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + } + return std::result::Result::Ok(fees); +} + +fn decode_signature_infos(method: &str, value: serde_json::Value) -> ksp_core_lib::Result> { + let decoded = crate::decode_wire_json::>(method, value); + let values = match decoded { + std::result::Result::Ok(values) => values, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut infos = std::vec::Vec::with_capacity(values.len()); + for value in values { + let info = crate::SolanaSignatureInfo::decode_wire(method, value); + match info { + std::result::Result::Ok(info) => infos.push(info), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + } + return std::result::Result::Ok(infos); +} + +fn decode_signature_statuses( + method: &str, + value: serde_json::Value, + expected_count: usize, +) -> ksp_core_lib::Result>> { + let decoded = crate::decode_wire_json::>>(method, value); + let values = match decoded { + std::result::Result::Ok(values) => values, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if values.len() != expected_count { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "getSignatureStatuses result count does not match the requested signature count") + .with_context("rpc_method", method) + .with_context("expected_count", expected_count.to_string()) + .with_context("actual_count", values.len().to_string()), + ); + } + let mut statuses = std::vec::Vec::with_capacity(values.len()); + for value in values { + let value = match value { + std::option::Option::Some(value) => value, + std::option::Option::None => { + statuses.push(std::option::Option::None); + continue; + }, + }; + let status = crate::SolanaSignatureStatus::decode_wire(method, value); + match status { + std::result::Result::Ok(status) => statuses.push(std::option::Option::Some(status)), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + } + return std::result::Result::Ok(statuses); +} + +fn invalid_transaction_parameters(method: &str, message: &str, field: &'static str, value: usize) -> ksp_core_lib::Result { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, message) + .with_context("rpc_method", method) + .with_context(field, value.to_string()), + ); +} + fn transaction_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> { let descriptor = crate::find_http_rpc_method(method); return match descriptor { @@ -1257,7 +1432,6 @@ fn invalid_transaction_wire(method: &str, field: &str, message: &'static str) -> return ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method).with_context("field", field); } -#[cfg(test)] fn decode_confirmation_status( method: &str, field: &str, @@ -1386,7 +1560,6 @@ struct WireLatestBlockhash { last_valid_block_height: u64, } -#[cfg(test)] #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct WirePrioritizationFee { @@ -1394,7 +1567,6 @@ struct WirePrioritizationFee { prioritization_fee: u64, } -#[cfg(test)] #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct WireSignatureInfo { @@ -1412,7 +1584,6 @@ struct WireSignatureInfo { transaction_index: std::option::Option, } -#[cfg(test)] #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct WireSignatureStatus { diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index a98e223..16c3260 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: 11 +// version: 12 //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. @@ -278,3 +278,10 @@ fn public_transaction_pre_003_read_wrappers_are_available_from_crate_root() { let _get_transaction_count = ksp_onchain_transport_lib::HttpTransportPool::get_transaction_count; let _is_blockhash_valid = ksp_onchain_transport_lib::HttpTransportPool::is_blockhash_valid; } + +#[test] +fn public_transaction_pre_004_additional_read_wrappers_are_available_from_crate_root() { + let _get_recent_prioritization_fees = ksp_onchain_transport_lib::HttpTransportPool::get_recent_prioritization_fees; + let _get_signatures_for_address = ksp_onchain_transport_lib::HttpTransportPool::get_signatures_for_address; + let _get_signature_statuses = ksp_onchain_transport_lib::HttpTransportPool::get_signature_statuses; +} diff --git a/crates/ksp-onchain-transport-lib/tests/release_completeness.rs b/crates/ksp-onchain-transport-lib/tests/release_completeness.rs index d4e6404..141e1f8 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: 8 +// version: 9 //! Release-level completeness canaries for the `0.2.1` HTTP foundation contract. @@ -259,3 +259,35 @@ fn release_pre_003_transaction_read_subset_is_exact_and_retry_safe() { assert_eq!(actual, expected); assert_eq!(actual.len(), 4); } + +#[test] +fn release_pre_004_transaction_read_subset_is_exact_and_retry_safe() { + let mut expected = std::vec![ + "getFeeForMessage", + "getLatestBlockhash", + "getRecentPrioritizationFees", + "getSignaturesForAddress", + "getSignatureStatuses", + "getTransactionCount", + "isBlockhashValid", + ]; + let deferred = std::vec!["getTransaction", "requestAirdrop", "sendTransaction", "simulateTransaction"]; + 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.004 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); + } + for method_name in &deferred { + let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("deferred Transaction descriptor must remain registered"); + assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3); + } + actual.sort_unstable(); + expected.sort_unstable(); + assert_eq!(actual, expected); + assert_eq!(actual.len(), 7); + assert_eq!(deferred.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 6d5d474..8362599 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: 3 +// version: 4 #[test] fn transaction_encoding_strings_match_current_and_legacy_wire_labels() { @@ -470,3 +470,219 @@ async fn typed_is_blockhash_valid_preserves_opaque_blockhash_and_contextual_bool assert_eq!(body["method"], serde_json::json!("isBlockhashValid")); assert_eq!(body["params"], serde_json::json!(["opaque-blockhash", {"commitment":"processed","minContextSlot":431000004}])); } + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_recent_prioritization_fees_serializes_addresses_and_preserves_server_order() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_recent_prioritization_fees.success.json")); + let pool = transaction_pool_for_url(url.as_str()); + let first = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let second = "SysvarRent111111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let first_text = first.to_string(); + let second_text = second.to_string(); + let accounts = std::vec![first, second]; + let fees = pool + .get_recent_prioritization_fees(&crate::HttpRoleName::new("default"), std::option::Option::Some(accounts.as_slice())) + .await + .expect("getRecentPrioritizationFees fixture must succeed"); + assert_eq!(fees.len(), 2); + assert_eq!(fees[0].slot(), 431_000_030); + assert_eq!(fees[0].prioritization_fee(), 1_200); + assert_eq!(fees[1].slot(), 431_000_029); + assert_eq!(fees[1].prioritization_fee(), 0); + let request = handle.join().expect("fixture server must join"); + let body = transaction_request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("getRecentPrioritizationFees")); + assert_eq!(body["params"], serde_json::json!([[first_text, second_text]])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_recent_prioritization_fees_omits_optional_accounts_and_rejects_more_than_128_before_io() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_recent_prioritization_fees.success.json")); + let pool = transaction_pool_for_url(url.as_str()); + let fees = pool + .get_recent_prioritization_fees(&crate::HttpRoleName::new("default"), std::option::Option::None) + .await + .expect("getRecentPrioritizationFees without addresses must succeed"); + assert_eq!(fees.len(), 2); + let request = handle.join().expect("fixture server must join"); + assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!([])); + + let account = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let max_accounts = std::vec![account; 128]; + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_recent_prioritization_fees.success.json")); + let max_pool = transaction_pool_for_url(url.as_str()); + let fees = max_pool + .get_recent_prioritization_fees(&crate::HttpRoleName::new("default"), std::option::Option::Some(max_accounts.as_slice())) + .await + .expect("exactly 128 prioritization-fee accounts must remain accepted"); + assert_eq!(fees.len(), 2); + let request = handle.join().expect("fixture server must join"); + let body = transaction_request_body(request.as_str()); + assert_eq!(body["params"][0].as_array().expect("address parameter must be an array").len(), 128); + + let invalid_pool = transaction_pool_for_url("http://127.0.0.1:9"); + let accounts = std::vec![account; 129]; + let result = invalid_pool.get_recent_prioritization_fees(&crate::HttpRoleName::new("default"), std::option::Option::Some(accounts.as_slice())).await; + let error = result.expect_err("more than 128 prioritization-fee accounts must reject before I/O"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); + assert_eq!(error.context()[1].key(), "account_count"); + assert_eq!(error.context()[1].value(), "129"); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_signatures_for_address_serializes_pagination_and_preserves_newest_first_result() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signatures_for_address.success.json")); + let pool = transaction_pool_for_url(url.as_str()); + let address = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let config = crate::SolanaSignaturesForAddressConfig::new( + std::option::Option::Some("before-signature".to_owned()), + std::option::Option::Some("until-signature".to_owned()), + std::option::Option::Some(1_000), + std::option::Option::Some(crate::SolanaCommitment::Finalized), + std::option::Option::Some(431_000_000), + ); + let signatures = pool + .get_signatures_for_address(&crate::HttpRoleName::new("default"), &address, std::option::Option::Some(&config)) + .await + .expect("getSignaturesForAddress fixture must succeed"); + assert_eq!(signatures.len(), 2); + assert_eq!(signatures[0].signature(), "newest-signature"); + assert_eq!(signatures[0].slot(), 431_000_040); + assert_eq!(signatures[0].memo(), std::option::Option::Some("memo-newest")); + assert_eq!(signatures[0].block_time(), std::option::Option::Some(1_787_000_100)); + assert_eq!(signatures[0].confirmation_status(), std::option::Option::Some(crate::SolanaTransactionConfirmationStatus::Finalized)); + assert_eq!(signatures[0].transaction_index(), std::option::Option::Some(9)); + assert_eq!(signatures[1].signature(), "older-signature"); + assert!(signatures[1].err().is_some()); + assert_eq!(signatures[1].memo(), std::option::Option::None); + assert_eq!(signatures[1].block_time(), std::option::Option::None); + assert_eq!(signatures[1].transaction_index(), std::option::Option::None); + let request = handle.join().expect("fixture server must join"); + let body = transaction_request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("getSignaturesForAddress")); + assert_eq!( + body["params"], + serde_json::json!([ + address.to_string(), + { + "before":"before-signature", + "until":"until-signature", + "limit":1000, + "commitment":"finalized", + "minContextSlot":431000000 + } + ]) + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_signatures_for_address_rejects_limit_outside_1_to_1000_before_io() { + let pool = transaction_pool_for_url("http://127.0.0.1:9"); + let address = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + for limit in [0_usize, 1_001_usize] { + let config = crate::SolanaSignaturesForAddressConfig::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(limit), + std::option::Option::None, + std::option::Option::None, + ); + let result = pool.get_signatures_for_address(&crate::HttpRoleName::new("default"), &address, std::option::Option::Some(&config)).await; + let error = result.expect_err("out-of-range getSignaturesForAddress limit must reject before I/O"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); + assert_eq!(error.context()[1].key(), "limit"); + assert_eq!(error.context()[1].value(), limit.to_string()); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_signatures_for_address_preserves_rpc_application_error() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signatures_for_address.error.json")); + let pool = transaction_pool_for_url(url.as_str()); + let address = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let result = pool.get_signatures_for_address(&crate::HttpRoleName::new("default"), &address, std::option::Option::None).await; + let error = result.expect_err("remote transaction-history error 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_signature_statuses_preserves_positional_nulls_and_history_config() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signature_statuses.success.json")); + let pool = transaction_pool_for_url(url.as_str()); + let signatures = std::vec!["missing-signature".to_owned(), "finalized-signature".to_owned(), "confirmed-signature".to_owned()]; + let config = crate::SolanaSignatureStatusesConfig::new(std::option::Option::Some(true)); + let response = pool + .get_signature_statuses(&crate::HttpRoleName::new("default"), signatures.as_slice(), std::option::Option::Some(&config)) + .await + .expect("getSignatureStatuses fixture must succeed"); + assert_eq!(response.context().slot(), 431_000_050); + assert_eq!(response.context().api_version(), std::option::Option::Some("4.2.1")); + assert_eq!(response.value().len(), 3); + assert!(response.value()[0].is_none()); + let finalized = response.value()[1].as_ref().expect("second positional status must be present"); + assert_eq!(finalized.slot(), 431_000_049); + assert_eq!(finalized.confirmations(), std::option::Option::None); + assert_eq!(finalized.status(), &serde_json::json!({"Ok":null})); + assert_eq!(finalized.confirmation_status(), std::option::Option::Some(crate::SolanaTransactionConfirmationStatus::Finalized)); + let confirmed = response.value()[2].as_ref().expect("third positional status must be present"); + assert_eq!(confirmed.confirmations(), std::option::Option::Some(2)); + assert!(confirmed.err().is_some()); + let request = handle.join().expect("fixture server must join"); + let body = transaction_request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("getSignatureStatuses")); + assert_eq!(body["params"], serde_json::json!([signatures, {"searchTransactionHistory":true}])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_signature_statuses_accepts_empty_input_and_omits_empty_config() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signature_statuses.empty.json")); + let pool = transaction_pool_for_url(url.as_str()); + let signatures = std::vec::Vec::::new(); + let config = crate::SolanaSignatureStatusesConfig::default(); + let response = pool + .get_signature_statuses(&crate::HttpRoleName::new("default"), signatures.as_slice(), std::option::Option::Some(&config)) + .await + .expect("empty getSignatureStatuses input must remain accepted"); + assert!(response.value().is_empty()); + 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_signature_statuses_accepts_256_and_rejects_more_than_256_before_io() { + let signatures = std::vec!["signature".to_owned(); 256]; + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signature_statuses.max_256.json")); + let pool = transaction_pool_for_url(url.as_str()); + let response = pool + .get_signature_statuses(&crate::HttpRoleName::new("default"), signatures.as_slice(), std::option::Option::None) + .await + .expect("exactly 256 signatures must remain accepted"); + assert_eq!(response.value().len(), 256); + let request = handle.join().expect("fixture server must join"); + let body = transaction_request_body(request.as_str()); + assert_eq!(body["params"][0].as_array().expect("signature parameter must be an array").len(), 256); + + let invalid_pool = transaction_pool_for_url("http://127.0.0.1:9"); + let signatures = std::vec!["signature".to_owned(); 257]; + let result = invalid_pool.get_signature_statuses(&crate::HttpRoleName::new("default"), signatures.as_slice(), std::option::Option::None).await; + let error = result.expect_err("more than 256 signatures must reject before I/O"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); + assert_eq!(error.context()[1].key(), "signature_count"); + assert_eq!(error.context()[1].value(), "257"); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_signature_statuses_rejects_response_count_mismatch() { + let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signature_statuses.count_mismatch.json")); + let pool = transaction_pool_for_url(url.as_str()); + let requested = std::vec!["first".to_owned(), "second".to_owned()]; + let result = pool.get_signature_statuses(&crate::HttpRoleName::new("default"), requested.as_slice(), std::option::Option::None).await; + let error = result.expect_err("status response count mismatch must reject typed decoding"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE); + assert_eq!(error.context()[1].key(), "expected_count"); + assert_eq!(error.context()[1].value(), "2"); + assert_eq!(error.context()[2].key(), "actual_count"); + assert_eq!(error.context()[2].value(), "1"); + handle.join().expect("fixture server must join"); +} diff --git a/deltas/0.2.3/pre.004.md b/deltas/0.2.3/pre.004.md new file mode 100644 index 0000000..8beb7e3 --- /dev/null +++ b/deltas/0.2.3/pre.004.md @@ -0,0 +1,346 @@ + + + +# Delta `0.2.3-pre.004` — lectures Transaction avec cardinalités et statuts positionnels + +## Base requise + +Livraison précédente validée localement par l'opérateur : + +```text +0.2.3-pre.003 +workspace.package.version = "0.2.3-pre.3" +``` + +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 +152 unit tests +15 public API tests +9 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 trois wrappers Transaction `Read / RetrySafe` prévus par la tranche `pre.004` : + +```text +getRecentPrioritizationFees +getSignaturesForAddress +getSignatureStatuses +``` + +Ils rejoignent les quatre reads de `pre.003`, portant la surface Transaction typée exécutée à : + +```text +7 / 11 wrappers +``` + +Les quatre méthodes restantes restent volontairement différées : + +```text +getTransaction +requestAirdrop +sendTransaction +simulateTransaction +``` + +## Version Cargo + +Conformément à `VER-ID-009` : + +```text +0.2.3-pre.3 -> 0.2.3-pre.4 +``` + +Aucune dépendance ni feature Cargo n'est ajoutée. + +## `getRecentPrioritizationFees` + +Surface publique conceptuelle : + +```text +role + Option<&[Pubkey]> + -> Vec +``` + +Le tableau d'adresses reste optionnel : + +- `None` omet entièrement le paramètre ; +- `Some(slice)` sérialise exactement un tableau d'adresses ; +- l'ordre des adresses caller est conservé ; +- les résultats restent dans l'ordre renvoyé par le provider. + +La cardinalité runtime documentée est appliquée avant I/O : + +```text +0..=128 accepté +129+ ERROR_CODE_INVALID_RPC_PARAMETERS +``` + +Le test couvre explicitement la borne `128` puis le rejet de `129`. + +Aucune règle locale n'est créée à partir de la profondeur actuelle de cache des prioritization fees : la valeur d'environ 150 blocs est une +caractéristique runtime du noeud et non une cardinalité client. + +## `getSignaturesForAddress` + +Surface publique conceptuelle : + +```text +role + Pubkey + Option + -> Vec +``` + +La config préparée en `pre.002` devient production-live pour cette méthode : + +```text +before +until +limit +commitment +minContextSlot +``` + +Une config absente ou entièrement vide est omise. La limite est validée avant I/O exactement selon le runtime audité : + +```text +1..=1000 accepté quand limit est fourni +0 rejeté +1001+ rejeté +``` + +La fixture de succès utilise explicitement `limit = 1000`, puis les tests rejettent `0` et `1001`. + +Le résultat conserve notamment : + +```text +signature +slot +err nullable +memo nullable +blockTime nullable +confirmationStatus nullable +transactionIndex optionnel +``` + +L'ordre `newest -> oldest` du provider n'est jamais retrié côté KSP. Les signatures restent des chaînes wire opaques dans cette release. + +Une fixture JSON-RPC d'erreur vérifie qu'une indisponibilité de transaction history reste une `RPC_APPLICATION_ERROR`, sans être transformée en +retry Transport. + +## `getSignatureStatuses` + +Surface publique conceptuelle : + +```text +role + &[String] + Option + -> SolanaRpcResponse>> +``` + +La config `searchTransactionHistory` préparée en `pre.002` devient production-live. Une config vide est omise. + +Le tableau d'entrée suit exactement la borne Agave auditée : + +```text +0..=256 accepté +257+ ERROR_CODE_INVALID_RPC_PARAMETERS +``` + +Le tableau vide reste volontairement accepté, conformément au runtime `v4.2.1` audité en `pre.001`. La borne `256` est exercée par une fixture +contenant 256 positions `null` et `257` est rejeté avant I/O. + +Le résultat conserve strictement la correspondance positionnelle : + +```text +entrée[i] <-> value[i] +null -> None +objet -> Some(SolanaSignatureStatus) +``` + +KSP vérifie maintenant que le provider retourne exactement autant de positions que de signatures demandées. Une longueur différente produit +`ERROR_CODE_INVALID_RESPONSE`, car continuer masquerait la correspondance signature/statut. + +Pour un statut présent, les champs suivants restent préservés : + +```text +slot +confirmations nullable +status legacy lossless JSON +err nullable +confirmationStatus nullable +``` + +La forme `status` demeure un champ legacy du wire courant ; elle n'est pas confondue avec les anciennes méthodes RPC Deprecated. + +## Helpers activés en production + +Seuls les helpers nécessaires à cette tranche quittent `#[cfg(test)]` : + +```text +SolanaSignaturesForAddressConfig::{is_empty,to_json_value} +SolanaSignatureStatusesConfig::{is_empty,to_json_value} +SolanaPrioritizationFee::decode_wire +SolanaSignatureInfo::decode_wire +SolanaSignatureStatus::decode_wire +decode_confirmation_status +WirePrioritizationFee +WireSignatureInfo +WireSignatureStatus +``` + +Les helpers de `getTransaction`, `requestAirdrop`, `sendTransaction` et `simulateTransaction` restent staged/test-only jusqu'à leur tranche +propriétaire. Aucun `#[allow(dead_code)]` n'est introduit. + +## Fixtures HTTP déterministes ajoutées + +```text +crates/ksp-onchain-transport-lib/fixtures/http/get_recent_prioritization_fees.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_signatures_for_address.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_signatures_for_address.error.json +crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.empty.json +crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.max_256.json +crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.count_mismatch.json +``` + +Les tests HTTP utilisent uniquement un serveur loopback local. + +## Couverture de tests ajoutée + +Neuf tests unitaires HTTP supplémentaires couvrent : + +- paramètres exacts de `getRecentPrioritizationFees` et ordre serveur ; +- omission du paramètre optionnel ; +- acceptation de 128 adresses et rejet local de 129 ; +- pagination/config complète de `getSignaturesForAddress` avec `limit = 1000` ; +- rejet local de `limit = 0` et `limit = 1001` ; +- propagation d'une erreur JSON-RPC applicative ; +- `getSignatureStatuses` avec `searchTransactionHistory = true` et positions `null` ; +- tableau vide + config vide omise ; +- borne 256 acceptée, 257 rejetée et longueur de réponse incohérente rejetée. + +Un test public compile les trois nouveaux wrappers depuis la crate root. + +Une nouvelle canarie release fige le sous-ensemble Read exécuté cumulé de `pre.003 + pre.004` : + +```text +getFeeForMessage +getLatestBlockhash +getRecentPrioritizationFees +getSignaturesForAddress +getSignatureStatuses +getTransactionCount +isBlockhashValid +``` + +Chaque descriptor reste : + +```text +Transactions / V0_2_3 / Read / RetrySafe +``` + +La canarie confirme aussi que les quatre méthodes différées restent enregistrées dans `V0_2_3` sans les compter comme surface typée achevée. + +Après application, la cible Transport attendue devient : + +```text +161 unit tests +16 public API tests +10 release completeness tests +``` + +## Fichiers ajoutés + +```text +crates/ksp-onchain-transport-lib/fixtures/http/get_recent_prioritization_fees.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_signatures_for_address.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_signatures_for_address.error.json +crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.empty.json +crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.max_256.json +crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.count_mismatch.json +deltas/0.2.3/pre.004.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 +``` + +Restent inchangés dans cette tranche : + +```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/src/executor.rs +crates/ksp-onchain-transport-lib/src/resilience.rs +crates/ksp-onchain-transport-lib/src/rpc_method.rs +``` + +## Contrôles statiques effectués dans l'environnement d'échange + +- les sept nouvelles fixtures sont du JSON valide ; +- aucune dépendance n'est ajoutée ; +- aucun `reqwest`, `tracing`, retry local ou client parallèle n'est introduit par le diff ; +- aucun `#[allow(dead_code)]` n'est introduit ; +- les helpers des tranches futures restent `#[cfg(test)]` ; +- aucune ligne Rust modifiée ne dépasse 160 colonnes ; +- `CHANGELOG.md`, `ROADMAP.md`, le plan `010`, `executor.rs`, `resilience.rs` et `rpc_method.rs` restent identiques à `pre.003`. + +`cargo`, `rustc` et `rustfmt` ne sont pas installés dans l'environnement d'échange. Aucune validation Cargo de `pre.004` n'est donc déclarée +réussie ici. + +## Validation opérateur requise + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-onchain-transport-lib +``` + +La validation doit notamment confirmer : + +```text +0 warning check/clippy +161 unit tests +16 public API tests +10 release completeness tests +``` + +Le smoke Devnet reste opt-in et n'est pas requis pour cette tranche locale déterministe. + +## Hors scope maintenu + +`pre.004` ne traite pas : + +- `getTransaction` moderne/legacy ; +- `requestAirdrop` ; +- `sendTransaction` ; +- la preuve end-to-end no-resend des writes ; +- `simulateTransaction` ; +- les 15 méthodes `0.2.4` Blocks/Economics ; +- un nouveau smoke cross-crates. + +La prochaine tranche reste `pre.005` : `getTransaction` moderne, compatibilité legacy et wire transaction/meta/version.