diff --git a/Cargo.toml b/Cargo.toml index 6321070..b807b4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ 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.2-pre.5" +version = "0.2.2-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/get_leader_schedule.null.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_leader_schedule.null.json new file mode 100644 index 0000000..e00e008 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_leader_schedule.null.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":null,"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_leader_schedule.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_leader_schedule.success.json new file mode 100644 index 0000000..9ba83b9 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_leader_schedule.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"11111111111111111111111111111111":[0,2,4],"ComputeBudget111111111111111111111111111111":[1,3]} ,"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_slot.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_slot.success.json new file mode 100644 index 0000000..9e93567 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_slot.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":430000020,"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leader.invalid_pubkey.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leader.invalid_pubkey.json new file mode 100644 index 0000000..ef28069 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leader.invalid_pubkey.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":"not-a-pubkey","id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leader.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leader.success.json new file mode 100644 index 0000000..ff21050 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leader.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":"ComputeBudget111111111111111111111111111111","id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leaders.invalid_pubkey.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leaders.invalid_pubkey.json new file mode 100644 index 0000000..fd3e7d8 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leaders.invalid_pubkey.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":["11111111111111111111111111111111","not-a-pubkey"],"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leaders.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leaders.success.json new file mode 100644 index 0000000..88df8cb --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leaders.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":["11111111111111111111111111111111","ComputeBudget111111111111111111111111111111"],"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_vote_accounts.invalid_pubkey.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_vote_accounts.invalid_pubkey.json new file mode 100644 index 0000000..f90d355 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_vote_accounts.invalid_pubkey.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"current":[{"votePubkey":"not-a-pubkey","nodePubkey":"11111111111111111111111111111111","activatedStake":1,"commission":5,"epochVoteAccount":true,"epochCredits":[],"lastVote":2,"rootSlot":1}],"delinquent":[]},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_vote_accounts.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_vote_accounts.success.json new file mode 100644 index 0000000..0e3ae1f --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_vote_accounts.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"current":[{"votePubkey":"11111111111111111111111111111111","nodePubkey":"ComputeBudget111111111111111111111111111111","activatedStake":424242,"commission":8,"inflationRewardsCommissionBps":750,"epochVoteAccount":true,"epochCredits":[[700,100,90],[701,115,100]],"lastVote":999,"rootSlot":990}],"delinquent":[{"votePubkey":"ComputeBudget111111111111111111111111111111","nodePubkey":"11111111111111111111111111111111","activatedStake":1,"commission":5,"epochVoteAccount":false,"epochCredits":[],"lastVote":0,"rootSlot":0}]},"id":1} diff --git a/crates/ksp-onchain-transport-lib/src/lib.rs b/crates/ksp-onchain-transport-lib/src/lib.rs index ebefa6a..79dcfb8 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: 10 +// version: 11 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] @@ -9,8 +9,8 @@ //! This crate owns runtime HTTP transport settings, Solana HTTP JSON-RPC envelopes and the audited standard method registry. It deliberately remains //! independent from `ksp-config-lib`, Store and Program layers. `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 the `0.2.2` Accounts and Tokens wrappers execute real JSON-RPC requests through -//! the shared transport path while the remaining audited methods stay staged by subsequent `0.2.x` prereleases. +//! 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 while the `0.2.3` and `0.2.4` audited families remain staged. mod client; mod constants; diff --git a/crates/ksp-onchain-transport-lib/src/rpc_cluster.rs b/crates/ksp-onchain-transport-lib/src/rpc_cluster.rs index 2bdf42b..5b4b4cf 100644 --- a/crates/ksp-onchain-transport-lib/src/rpc_cluster.rs +++ b/crates/ksp-onchain-transport-lib/src/rpc_cluster.rs @@ -1,5 +1,7 @@ // file: crates/ksp-onchain-transport-lib/src/rpc_cluster.rs -// version: 3 +// version: 4 + +const MAX_GET_SLOT_LEADERS: u64 = 5_000; /// Contact information returned for one cluster node. #[derive(Clone, Debug, Eq, PartialEq)] @@ -295,12 +297,10 @@ impl SolanaLeaderScheduleConfig { pub const fn commitment(&self) -> std::option::Option { return self.commitment; } - #[cfg(test)] - fn is_empty(&self) -> bool { + pub(crate) const fn is_empty(&self) -> bool { return self.identity.is_none() && self.commitment.is_none(); } - #[cfg(test)] - fn to_json_value(&self) -> serde_json::Value { + pub(crate) fn to_json_value(&self) -> serde_json::Value { let mut object = serde_json::Map::new(); if let std::option::Option::Some(identity) = self.identity.as_ref() { object.insert("identity".to_owned(), serde_json::Value::String(identity.to_string())); @@ -335,7 +335,6 @@ impl Default for SolanaLeaderScheduleRequest { impl SolanaLeaderScheduleRequest { /// Serializes the typed overload to the exact positional JSON-RPC params. #[must_use] - #[cfg(test)] pub(crate) fn to_json_params(&self) -> std::vec::Vec { return match self { Self::CurrentEpoch(std::option::Option::None) => std::vec::Vec::new(), @@ -362,7 +361,6 @@ impl SolanaLeaderSchedule { } /// Decodes a leader schedule map 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 { @@ -422,9 +420,11 @@ impl SolanaVoteAccountsConfig { pub const fn delinquent_slot_distance(&self) -> std::option::Option { return self.delinquent_slot_distance; } + pub(crate) const fn is_empty(&self) -> bool { + return self.commitment.is_none() && self.vote_pubkey.is_none() && self.keep_unstaked_delinquents.is_none() && self.delinquent_slot_distance.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(commitment) = self.commitment { @@ -531,7 +531,6 @@ impl SolanaVoteAccountInfo { } /// Decodes one vote-account 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 { @@ -586,7 +585,6 @@ impl SolanaVoteAccountStatus { } /// Decodes the complete vote-account status response 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 { @@ -621,7 +619,7 @@ struct WireIdentity { impl crate::HttpTransportPool { /// Executes typed `getClusterNodes` through the common KSP HTTP transport path. pub async fn get_cluster_nodes(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result> { - let value = self.execute_cluster_simple_rpc("getClusterNodes", role, std::vec::Vec::new()).await; + let value = self.execute_cluster_rpc("getClusterNodes", role, std::vec::Vec::new()).await; let value = match value { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), @@ -649,12 +647,8 @@ impl crate::HttpTransportPool { config: std::option::Option<&crate::SolanaContextConfig>, ) -> ksp_core_lib::Result { let mut params = std::vec::Vec::new(); - if let std::option::Option::Some(config) = config - && (config.commitment().is_some() || config.min_context_slot().is_some()) - { - params.push(config.to_json_value()); - } - let value = self.execute_cluster_simple_rpc("getEpochInfo", role, params).await; + push_context_config(&mut params, config); + let value = self.execute_cluster_rpc("getEpochInfo", role, params).await; return match value { std::result::Result::Ok(value) => crate::SolanaEpochInfo::decode_wire("getEpochInfo", value), std::result::Result::Err(error) => std::result::Result::Err(error), @@ -663,7 +657,7 @@ impl crate::HttpTransportPool { /// Executes typed `getEpochSchedule` through the common KSP HTTP transport path. pub async fn get_epoch_schedule(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result { - let value = self.execute_cluster_simple_rpc("getEpochSchedule", role, std::vec::Vec::new()).await; + let value = self.execute_cluster_rpc("getEpochSchedule", role, std::vec::Vec::new()).await; return match value { std::result::Result::Ok(value) => crate::SolanaEpochSchedule::decode_wire("getEpochSchedule", value), std::result::Result::Err(error) => std::result::Result::Err(error), @@ -672,7 +666,7 @@ impl crate::HttpTransportPool { /// Executes typed `getHighestSnapshotSlot` through the common KSP HTTP transport path. pub async fn get_highest_snapshot_slot(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result { - let value = self.execute_cluster_simple_rpc("getHighestSnapshotSlot", role, std::vec::Vec::new()).await; + let value = self.execute_cluster_rpc("getHighestSnapshotSlot", role, std::vec::Vec::new()).await; return match value { std::result::Result::Ok(value) => crate::SolanaSnapshotSlotInfo::decode_wire("getHighestSnapshotSlot", value), std::result::Result::Err(error) => std::result::Result::Err(error), @@ -681,7 +675,7 @@ impl crate::HttpTransportPool { /// Executes typed `getIdentity` through the common KSP HTTP transport path. pub async fn get_identity(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result { - let value = self.execute_cluster_simple_rpc("getIdentity", role, std::vec::Vec::new()).await; + let value = self.execute_cluster_rpc("getIdentity", role, std::vec::Vec::new()).await; let value = match value { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), @@ -704,15 +698,101 @@ impl crate::HttpTransportPool { return self.get_cluster_simple_slot("getMaxShredInsertSlot", role).await; } + /// Executes typed `getLeaderSchedule` through the common KSP HTTP transport path. + pub async fn get_leader_schedule( + &self, + role: &crate::HttpRoleName, + request: &crate::SolanaLeaderScheduleRequest, + ) -> ksp_core_lib::Result> { + let value = self.execute_cluster_rpc("getLeaderSchedule", role, request.to_json_params()).await; + let value = match value { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if value.is_null() { + return std::result::Result::Ok(std::option::Option::None); + } + let schedule = crate::SolanaLeaderSchedule::decode_wire("getLeaderSchedule", value); + return match schedule { + std::result::Result::Ok(schedule) => std::result::Result::Ok(std::option::Option::Some(schedule)), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } + + /// Executes typed `getSlot` through the common KSP HTTP transport path. + pub async fn get_slot(&self, role: &crate::HttpRoleName, config: std::option::Option<&crate::SolanaContextConfig>) -> ksp_core_lib::Result { + let mut params = std::vec::Vec::new(); + push_context_config(&mut params, config); + let value = self.execute_cluster_rpc("getSlot", role, params).await; + return match value { + std::result::Result::Ok(value) => crate::decode_wire_json::("getSlot", value), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } + + /// Executes typed `getSlotLeader` through the common KSP HTTP transport path. + pub async fn get_slot_leader( + &self, + role: &crate::HttpRoleName, + config: std::option::Option<&crate::SolanaContextConfig>, + ) -> ksp_core_lib::Result { + let mut params = std::vec::Vec::new(); + push_context_config(&mut params, config); + let value = self.execute_cluster_rpc("getSlotLeader", 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 decoded = crate::decode_wire_json::("getSlotLeader", value); + let leader = match decoded { + std::result::Result::Ok(leader) => leader, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::parse_wire_pubkey("getSlotLeader", "leader", leader.as_str()); + } + + /// Executes typed `getSlotLeaders` through the common KSP HTTP transport path. + pub async fn get_slot_leaders(&self, role: &crate::HttpRoleName, start_slot: u64, limit: u64) -> ksp_core_lib::Result> { + if limit == 0 || limit > MAX_GET_SLOT_LEADERS { + return invalid_cluster_parameters("getSlotLeaders", "getSlotLeaders limit must be between 1 and 5000", "limit", limit); + } + let params = std::vec![serde_json::json!(start_slot), serde_json::json!(limit)]; + let value = self.execute_cluster_rpc("getSlotLeaders", 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_pubkey_list("getSlotLeaders", "leader", value); + } + + /// Executes typed `getVoteAccounts` through the common KSP HTTP transport path. + pub async fn get_vote_accounts( + &self, + role: &crate::HttpRoleName, + config: std::option::Option<&crate::SolanaVoteAccountsConfig>, + ) -> ksp_core_lib::Result { + let mut params = std::vec::Vec::new(); + if let std::option::Option::Some(config) = config + && !config.is_empty() + { + params.push(config.to_json_value()); + } + let value = self.execute_cluster_rpc("getVoteAccounts", role, params).await; + return match value { + std::result::Result::Ok(value) => crate::SolanaVoteAccountStatus::decode_wire("getVoteAccounts", value), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } + async fn get_cluster_simple_slot(&self, method_name: &'static str, role: &crate::HttpRoleName) -> ksp_core_lib::Result { - let value = self.execute_cluster_simple_rpc(method_name, role, std::vec::Vec::new()).await; + let value = self.execute_cluster_rpc(method_name, role, std::vec::Vec::new()).await; return match value { std::result::Result::Ok(value) => crate::decode_wire_json::(method_name, value), std::result::Result::Err(error) => std::result::Result::Err(error), }; } - async fn execute_cluster_simple_rpc( + async fn execute_cluster_rpc( &self, method_name: &'static str, role: &crate::HttpRoleName, @@ -727,6 +807,40 @@ impl crate::HttpTransportPool { } } +fn push_context_config(params: &mut std::vec::Vec, config: std::option::Option<&crate::SolanaContextConfig>) { + if let std::option::Option::Some(config) = config + && (config.commitment().is_some() || config.min_context_slot().is_some()) + { + params.push((*config).to_json_value()); + } + return; +} + +fn decode_pubkey_list(method: &str, field: &'static 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 pubkeys = std::vec::Vec::with_capacity(values.len()); + for value in values { + let pubkey = crate::parse_wire_pubkey(method, field, value.as_str()); + match pubkey { + std::result::Result::Ok(pubkey) => pubkeys.push(pubkey), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + } + return std::result::Result::Ok(pubkeys); +} + +fn invalid_cluster_parameters(method: &str, message: &str, field: &'static str, value: u64) -> 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 cluster_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> { let descriptor = crate::find_http_rpc_method(method); return match descriptor { @@ -803,7 +917,6 @@ struct WireSnapshotSlotInfo { incremental: std::option::Option, } -#[cfg(test)] #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct WireVoteAccountInfo { @@ -819,7 +932,6 @@ struct WireVoteAccountInfo { root_slot: u64, } -#[cfg(test)] #[derive(serde::Deserialize)] struct WireVoteAccountStatus { current: std::vec::Vec, diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index f8d49ff..9bfd69f 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: 8 +// version: 9 //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. @@ -232,3 +232,14 @@ fn public_pre_005_simple_cluster_wrappers_are_available_from_crate_root() { let _get_max_retransmit_slot = ksp_onchain_transport_lib::HttpTransportPool::get_max_retransmit_slot; let _get_max_shred_insert_slot = ksp_onchain_transport_lib::HttpTransportPool::get_max_shred_insert_slot; } + +#[test] +fn public_pre_006_remaining_cluster_wrappers_are_available_from_crate_root() { + let _get_leader_schedule = ksp_onchain_transport_lib::HttpTransportPool::get_leader_schedule; + let _get_slot = ksp_onchain_transport_lib::HttpTransportPool::get_slot; + let _get_slot_leader = ksp_onchain_transport_lib::HttpTransportPool::get_slot_leader; + let _get_slot_leaders = ksp_onchain_transport_lib::HttpTransportPool::get_slot_leaders; + let _get_vote_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_vote_accounts; + let request = ksp_onchain_transport_lib::SolanaLeaderScheduleRequest::default(); + assert!(matches!(request, ksp_onchain_transport_lib::SolanaLeaderScheduleRequest::CurrentEpoch(std::option::Option::None))); +} diff --git a/crates/ksp-onchain-transport-lib/tests/release_completeness.rs b/crates/ksp-onchain-transport-lib/tests/release_completeness.rs index e48a8ed..b58aba5 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: 4 +// version: 5 //! Release-level completeness canaries for the `0.2.1` HTTP foundation contract. @@ -111,3 +111,35 @@ fn release_pre_005_simple_cluster_subset_is_exact_and_retry_safe() { assert_eq!(expected.len(), 7); assert_eq!(deferred.len(), 5); } + +#[test] +fn release_pre_006_completes_cluster_subset_exactly_and_retry_safe() { + let mut expected = std::vec![ + "getClusterNodes", + "getEpochInfo", + "getEpochSchedule", + "getHighestSnapshotSlot", + "getIdentity", + "getLeaderSchedule", + "getMaxRetransmitSlot", + "getMaxShredInsertSlot", + "getSlot", + "getSlotLeader", + "getSlotLeaders", + "getVoteAccounts", + ]; + let mut actual = std::vec::Vec::new(); + for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() { + if descriptor.coverage_release() == ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2 + && descriptor.category() == ksp_onchain_transport_lib::HttpRpcCategory::Cluster + { + actual.push(descriptor.method()); + assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read); + assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe); + } + } + actual.sort_unstable(); + expected.sort_unstable(); + assert_eq!(actual, expected); + assert_eq!(actual.len(), 12); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs b/crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs index b8706ef..48ad0a1 100644 --- a/crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs +++ b/crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs -// version: 3 +// version: 4 #[test] fn cluster_node_fixture_preserves_v4_client_id_and_optional_fields() { @@ -52,7 +52,7 @@ fn leader_schedule_request_encodes_current_epoch_and_slot_overloads_without_ambi } #[test] -fn staged_epoch_snapshot_and_leader_helpers_preserve_wire_shapes() { +fn epoch_snapshot_and_leader_helpers_preserve_wire_shapes() { let epoch = crate::SolanaEpochInfo::decode_wire( "getEpochInfo", serde_json::json!({"absoluteSlot":10,"blockHeight":9,"epoch":2,"slotIndex":3,"slotsInEpoch":32,"transactionCount":null}), @@ -78,7 +78,7 @@ fn staged_epoch_snapshot_and_leader_helpers_preserve_wire_shapes() { } #[test] -fn staged_vote_status_and_config_helpers_preserve_wire_shapes() { +fn vote_status_and_config_helpers_preserve_wire_shapes() { let vote_pubkey = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); let config = crate::SolanaVoteAccountsConfig::new( std::option::Option::Some(crate::SolanaCommitment::Finalized), @@ -321,3 +321,168 @@ async fn typed_get_max_cluster_slots_decode_u64_without_params() { assert_eq!(shred_body["method"], serde_json::json!("getMaxShredInsertSlot")); assert_eq!(shred_body["params"], serde_json::json!([])); } + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_leader_schedule_serializes_slot_config_and_decodes_map() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_leader_schedule.success.json")); + let pool = pool_for_url(url.as_str()); + let identity = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let config = + crate::SolanaLeaderScheduleConfig::new(std::option::Option::Some(identity.clone()), std::option::Option::Some(crate::SolanaCommitment::Finalized)); + let request = crate::SolanaLeaderScheduleRequest::Slot { slot: 430_000_000, config: std::option::Option::Some(config) }; + let schedule = pool + .get_leader_schedule(&crate::HttpRoleName::new("default"), &request) + .await + .expect("leader schedule fixture must succeed") + .expect("leader schedule fixture must not be null"); + assert_eq!(schedule.entries().get(&identity), std::option::Option::Some(&std::vec![0, 2, 4])); + let request = handle.join().expect("fixture server must join"); + let body = request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("getLeaderSchedule")); + assert_eq!(body["params"], serde_json::json!([430000000,{"identity":"11111111111111111111111111111111","commitment":"finalized"}])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_leader_schedule_preserves_null_result() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_leader_schedule.null.json")); + let pool = pool_for_url(url.as_str()); + let schedule = pool + .get_leader_schedule(&crate::HttpRoleName::new("default"), &crate::SolanaLeaderScheduleRequest::default()) + .await + .expect("nullable leader schedule fixture must succeed"); + assert_eq!(schedule, std::option::Option::None); + let request = handle.join().expect("fixture server must join"); + assert_eq!(request_body(request.as_str())["params"], serde_json::json!([])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_slot_serializes_context_config_and_omits_empty_config() { + let (configured_url, configured_handle) = serve_once(include_str!("../fixtures/http/get_slot.success.json")); + let configured_pool = pool_for_url(configured_url.as_str()); + let config = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(429_999_999)); + let slot = configured_pool.get_slot(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config)).await.expect("slot fixture must succeed"); + assert_eq!(slot, 430_000_020); + let request = configured_handle.join().expect("fixture server must join"); + assert_eq!(request_body(request.as_str())["params"], serde_json::json!([{"commitment":"confirmed","minContextSlot":429999999}])); + + let (empty_url, empty_handle) = serve_once(include_str!("../fixtures/http/get_slot.success.json")); + let empty_pool = pool_for_url(empty_url.as_str()); + let empty = crate::SolanaContextConfig::default(); + let slot = empty_pool + .get_slot(&crate::HttpRoleName::new("default"), std::option::Option::Some(&empty)) + .await + .expect("slot fixture with empty config must succeed"); + assert_eq!(slot, 430_000_020); + let request = empty_handle.join().expect("fixture server must join"); + assert_eq!(request_body(request.as_str())["params"], serde_json::json!([])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_slot_leader_serializes_context_config_and_decodes_pubkey() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_slot_leader.success.json")); + let pool = pool_for_url(url.as_str()); + let config = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(430_000_000)); + let leader = pool + .get_slot_leader(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config)) + .await + .expect("slot leader fixture must succeed"); + assert_eq!(leader.to_string(), "ComputeBudget111111111111111111111111111111"); + let request = handle.join().expect("fixture server must join"); + assert_eq!(request_body(request.as_str())["params"], serde_json::json!([{"commitment":"finalized","minContextSlot":430000000}])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_slot_leader_rejects_invalid_wire_pubkey() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_slot_leader.invalid_pubkey.json")); + let pool = pool_for_url(url.as_str()); + let result = pool.get_slot_leader(&crate::HttpRoleName::new("default"), std::option::Option::None).await; + let error = result.expect_err("invalid slot leader must reject typed response"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE); + handle.join().expect("fixture server must join"); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_slot_leaders_preserves_order_and_serializes_range() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_slot_leaders.success.json")); + let pool = pool_for_url(url.as_str()); + let leaders = pool.get_slot_leaders(&crate::HttpRoleName::new("default"), 430_000_020, 2).await.expect("slot leaders fixture must succeed"); + assert_eq!(leaders.len(), 2); + assert_eq!(leaders[0].to_string(), "11111111111111111111111111111111"); + assert_eq!(leaders[1].to_string(), "ComputeBudget111111111111111111111111111111"); + let request = handle.join().expect("fixture server must join"); + let body = request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("getSlotLeaders")); + assert_eq!(body["params"], serde_json::json!([430000020, 2])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_slot_leaders_rejects_out_of_range_limit_before_io() { + let pool = pool_for_url("http://127.0.0.1:9"); + let zero = pool.get_slot_leaders(&crate::HttpRoleName::new("default"), 1, 0).await; + let zero_error = zero.expect_err("zero slot-leader limit must reject before I/O"); + assert_eq!(zero_error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); + let above = pool.get_slot_leaders(&crate::HttpRoleName::new("default"), 1, 5_001).await; + let above_error = above.expect_err("slot-leader limit above 5000 must reject before I/O"); + assert_eq!(above_error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_slot_leaders_rejects_invalid_wire_pubkey() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_slot_leaders.invalid_pubkey.json")); + let pool = pool_for_url(url.as_str()); + let result = pool.get_slot_leaders(&crate::HttpRoleName::new("default"), 1, 2).await; + let error = result.expect_err("invalid slot leader list item must reject typed response"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE); + handle.join().expect("fixture server must join"); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_vote_accounts_serializes_full_config_and_preserves_optional_bps() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_vote_accounts.success.json")); + let pool = pool_for_url(url.as_str()); + let vote_pubkey = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let config = crate::SolanaVoteAccountsConfig::new( + std::option::Option::Some(crate::SolanaCommitment::Finalized), + std::option::Option::Some(vote_pubkey), + std::option::Option::Some(true), + std::option::Option::Some(128), + ); + let status = pool + .get_vote_accounts(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config)) + .await + .expect("vote accounts fixture must succeed"); + assert_eq!(status.current().len(), 1); + assert_eq!(status.delinquent().len(), 1); + assert_eq!(status.current()[0].inflation_rewards_commission_bps(), std::option::Option::Some(750)); + assert_eq!(status.current()[0].epoch_credits().len(), 2); + assert_eq!(status.delinquent()[0].inflation_rewards_commission_bps(), std::option::Option::None); + let request = handle.join().expect("fixture server must join"); + assert_eq!( + request_body(request.as_str())["params"], + serde_json::json!([{"commitment":"finalized","votePubkey":"11111111111111111111111111111111","keepUnstakedDelinquents":true,"delinquentSlotDistance":128}]) + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_vote_accounts_omits_explicitly_empty_config() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_vote_accounts.success.json")); + let pool = pool_for_url(url.as_str()); + let config = crate::SolanaVoteAccountsConfig::default(); + let status = pool + .get_vote_accounts(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config)) + .await + .expect("vote accounts fixture with empty config must succeed"); + assert_eq!(status.current().len(), 1); + let request = handle.join().expect("fixture server must join"); + assert_eq!(request_body(request.as_str())["params"], serde_json::json!([])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_vote_accounts_rejects_invalid_wire_pubkey() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_vote_accounts.invalid_pubkey.json")); + let pool = pool_for_url(url.as_str()); + let result = pool.get_vote_accounts(&crate::HttpRoleName::new("default"), std::option::Option::None).await; + let error = result.expect_err("invalid vote-account pubkey must reject typed response"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE); + handle.join().expect("fixture server must join"); +} diff --git a/deltas/0.2.2/pre.006.md b/deltas/0.2.2/pre.006.md new file mode 100644 index 0000000..00e69c7 --- /dev/null +++ b/deltas/0.2.2/pre.006.md @@ -0,0 +1,298 @@ + + + +# Delta `0.2.2-pre.006` — cinq wrappers HTTP Cluster restants typés + +## Base requise + +Livraison précédente validée localement par l'opérateur : + +```text +0.2.2-pre.005 +workspace.package.version = "0.2.2-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 +116 unit tests +12 public API tests +5 release completeness tests +0 warning signalé par check/clippy +``` + +Le plan canonique reste `docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md` version 3. + +## Contrôle documentaire avant nouvelle tranche + +Le découpage durable reste cohérent : + +- `ROADMAP.md` conserve `0.2.2` en cours `[/]` ; +- le plan `009` attribue exactement à `pre.006` les cinq méthodes Cluster restantes ; +- les 5 Accounts de `pre.003`, les 5 Tokens de `pre.004` et les 7 Cluster simples de `pre.005` restent inchangés ; +- `pre.007` reste la tranche de complétude/smoke/docs/préparation stable prévue ; +- aucun index documentaire durable ne nécessite de modification à cette étape. + +## Objectif + +Implémenter les cinq wrappers publics typés Cluster restants : + +```text +getLeaderSchedule +getSlot +getSlotLeader +getSlotLeaders +getVoteAccounts +``` + +Après cette tranche, les 22 méthodes HTTP Accounts + Tokens + Cluster attribuées à `0.2.2` possèdent toutes leur wrapper typé KSP. + +Tous les wrappers passent par la foundation HTTP commune : + +```text +wrapper typé + -> descriptor audité + -> execute_standard_rpc + -> pool/admission/retry/deadline + -> reqwest HTTP + -> JSON-RPC validation + -> décodage DTO KSP +``` + +Aucun wrapper Cluster ne contacte `reqwest` directement. + +## Version Cargo + +Conformément au cycle prerelease KSP : + +```text +0.2.2-pre.5 -> 0.2.2-pre.6 +``` + +Aucune dépendance ni feature Cargo n'est ajoutée ou retirée. + +## Réaudit ciblé du contrat courant + +Le contrat préparé dans le plan `009` a été recroisé avec la source primaire Agave `v4.2.1`, notamment : + +```text +rpc-client-types/src/config.rs +rpc/src/rpc.rs +``` + +Les invariants utiles à cette tranche restent : + +- `getLeaderSchedule` accepte l'overload slot/config déjà modélisé par `SolanaLeaderScheduleRequest` ; +- `RpcLeaderScheduleConfig` contient `identity` et `commitment` ; +- le résultat de `getLeaderSchedule` reste nullable ; +- `getSlot` et `getSlotLeader` utilisent la config commune `commitment/minContextSlot` ; +- `getSlotLeaders` prend `startSlot + limit`, avec la limite `1..=5000` retenue par le plan ; +- `RpcGetVoteAccountsConfig` contient `votePubkey`, `commitment`, `keepUnstakedDelinquents`, `delinquentSlotDistance` ; +- `getVoteAccounts` renvoie les groupes `current` et `delinquent` ; +- `inflationRewardsCommissionBps` reste optionnel pour compatibilité avec les noeuds antérieurs ; +- l'historique `epochCredits` reste exposé comme historique RPC borné, sans être interprété comme historique complet du vote account. + +## Surface typée Cluster finale + +### `getLeaderSchedule` + +```text +role + SolanaLeaderScheduleRequest -> Option +``` + +`SolanaLeaderScheduleRequest` conserve les deux formes cohérentes : + +```text +CurrentEpoch(config?) +Slot { slot, config? } +``` + +Un config vide est omis. Un résultat JSON `null` devient `None`. Une map non nulle valide chaque identité et la convertit en `ksp_core_lib::Pubkey`. + +### `getSlot` + +```text +role + Option -> u64 +``` + +La config commune est sérialisée uniquement lorsqu'elle contient `commitment` et/ou `minContextSlot`. Une erreur distante `MinContextSlotNotReached` reste une erreur RPC applicative. + +### `getSlotLeader` + +```text +role + Option -> Pubkey +``` + +La valeur texte du leader est validée avant exposition comme `Pubkey`, sans recopier une valeur wire invalide dans le diagnostic. + +### `getSlotLeaders` + +```text +role + startSlot u64 + limit u64 -> Vec +``` + +KSP rejette localement avant I/O : + +```text +limit == 0 +limit > 5000 +``` + +L'ordre du tableau retourné est conservé, car chaque position correspond aux slots successifs demandés. + +### `getVoteAccounts` + +```text +role + Option -> SolanaVoteAccountStatus +``` + +Un config vide est omis. Les deux groupes `current` et `delinquent` sont conservés, ainsi que : + +```text +votePubkey +nodePubkey +activatedStake +commission +inflationRewardsCommissionBps Option +epochVoteAccount +epochCredits Vec +lastVote +rootSlot +``` + +Aucune conversion métier staking/rewards n'est introduite dans Transport. + +## Activation finale des helpers préparés en `pre.002` + +Les helpers spécifiques aux cinq méthodes de cette tranche deviennent production-live maintenant qu'ils ont des consommateurs runtime : + +- `SolanaLeaderScheduleConfig::{is_empty,to_json_value}` ; +- `SolanaLeaderScheduleRequest::to_json_params` ; +- `SolanaLeaderSchedule::decode_wire` ; +- `SolanaVoteAccountsConfig::{is_empty,to_json_value}` ; +- `SolanaVoteAccountInfo::decode_wire` ; +- `SolanaVoteAccountStatus::decode_wire` ; +- `WireVoteAccountInfo` et `WireVoteAccountStatus`. + +Aucun `#[allow(dead_code)]` n'est ajouté. + +Le helper privé d'exécution Cluster est renommé de `execute_cluster_simple_rpc` vers `execute_cluster_rpc`, car il dessert désormais les douze wrappers Cluster. + +## Fixtures HTTP déterministes ajoutées + +```text +crates/ksp-onchain-transport-lib/fixtures/http/get_leader_schedule.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_leader_schedule.null.json +crates/ksp-onchain-transport-lib/fixtures/http/get_slot.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leader.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leader.invalid_pubkey.json +crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leaders.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leaders.invalid_pubkey.json +crates/ksp-onchain-transport-lib/fixtures/http/get_vote_accounts.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_vote_accounts.invalid_pubkey.json +``` + +Les tests utilisent uniquement un serveur HTTP loopback local. + +## Couverture de tests ajoutée + +Les tests couvrent notamment : + +- sérialisation slot + config de `getLeaderSchedule` ; +- résultat nullable de `getLeaderSchedule` ; +- sérialisation `commitment/minContextSlot` de `getSlot` et `getSlotLeader` ; +- omission d'un `SolanaContextConfig` vide ; +- validation de la `Pubkey` de `getSlotLeader` ; +- conservation de l'ordre de `getSlotLeaders` ; +- rejet local des limites `0` et `5001` avant I/O ; +- rejet d'une identité invalide dans la liste `getSlotLeaders` ; +- sérialisation complète de `SolanaVoteAccountsConfig` ; +- omission d'un config vote accounts vide ; +- conservation de `inflationRewardsCommissionBps` présent et absent ; +- conservation des triples `epochCredits` ; +- rejet d'une `votePubkey` invalide. + +Le test public compile explicitement les cinq nouvelles méthodes depuis `HttpTransportPool`. + +Un canari release fige désormais les douze méthodes Cluster exactes de `0.2.2` et vérifie `Read + RetrySafe` sur tout le sous-ensemble. + +Après application, la cible Transport attendue devient : + +```text +127 unit tests +13 public API tests +6 release completeness tests +``` + +## Rustdoc de crate + +La documentation de tête de `ksp-onchain-transport-lib` est ajustée pour ne plus présenter Cluster comme une famille encore staged : après `pre.006`, les 22 wrappers `0.2.2` sont implémentés. Les familles `0.2.3` et `0.2.4` restent staged. + +## Fichiers ajoutés + +```text +crates/ksp-onchain-transport-lib/fixtures/http/get_leader_schedule.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_leader_schedule.null.json +crates/ksp-onchain-transport-lib/fixtures/http/get_slot.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leader.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leader.invalid_pubkey.json +crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leaders.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_slot_leaders.invalid_pubkey.json +crates/ksp-onchain-transport-lib/fixtures/http/get_vote_accounts.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_vote_accounts.invalid_pubkey.json +deltas/0.2.2/pre.006.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +crates/ksp-onchain-transport-lib/src/lib.rs +crates/ksp-onchain-transport-lib/src/rpc_cluster.rs +crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs +crates/ksp-onchain-transport-lib/tests/public_api.rs +crates/ksp-onchain-transport-lib/tests/release_completeness.rs +``` + +## Fichiers supprimés + +Aucun. + +## Documentation durable + +Aucune modification de ROADMAP/plan/index n'est requise dans cette tranche : le plan existant décrit déjà ce découpage et `pre.007` reste la tranche de clôture prerelease/documentation. + +`CHANGELOG.md` reste réservé à la clôture stable. + +## Validation à exécuter sur le checkout opérateur + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-onchain-transport-lib +``` + +La livraison n'affirme pas que ces commandes ont été exécutées dans l'environnement de génération. + +## Critères de validation de `pre.006` + +`pre.006` est validable lorsque : + +- les quatre commandes ci-dessus passent sans warning nouveau ; +- les 127 unit tests passent ; +- les 13 tests public API passent ; +- les 6 tests release completeness passent ; +- aucun helper Cluster production-live n'est `dead_code` ; +- les douze wrappers Cluster `0.2.2` sont accessibles depuis `HttpTransportPool` ; +- le total `0.2.2` reste exactement 5 Accounts + 5 Tokens + 12 Cluster = 22 wrappers ; +- aucune méthode `0.2.3`/`0.2.4` n'est activée prématurément.