diff --git a/Cargo.toml b/Cargo.toml index 8aa05eb..582352b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 112 +# version: 113 [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.2-pre.2.fix.2" +version = "0.2.2-pre.3" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.invalid_owner.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.invalid_owner.json new file mode 100644 index 0000000..e6224c0 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.invalid_owner.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000003},"value":{"data":["","base64"],"executable":false,"lamports":1,"owner":"not-a-pubkey","rentEpoch":0,"space":0}},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.null.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.null.json new file mode 100644 index 0000000..122da13 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.null.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000002},"value":null},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.success.json new file mode 100644 index 0000000..ee09f06 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000001},"value":{"data":["AQIDBA==","base64"],"executable":false,"lamports":2039280,"owner":"11111111111111111111111111111111","rentEpoch":18446744073709551615,"space":4}},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.invalid_address.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.invalid_address.json new file mode 100644 index 0000000..c1d8aa8 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.invalid_address.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000005},"value":[{"address":"invalid-address","lamports":1}]},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.success.json new file mode 100644 index 0000000..2f6cb89 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000004},"value":[{"address":"11111111111111111111111111111111","lamports":999999999},{"address":"ComputeBudget111111111111111111111111111111","lamports":888888888}]},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.error.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.error.json new file mode 100644 index 0000000..fc974a9 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.error.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid param"},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.success.json new file mode 100644 index 0000000..6065b9e --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":890880,"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_multiple_accounts.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_multiple_accounts.success.json new file mode 100644 index 0000000..773392f --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_multiple_accounts.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000006},"value":[{"data":["","base64"],"executable":false,"lamports":10,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":0},null,{"data":{"program":"system","parsed":{"type":"nonce"},"space":80},"executable":false,"lamports":20,"owner":"11111111111111111111111111111111","rentEpoch":1,"space":80}]},"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.bare.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.bare.success.json new file mode 100644 index 0000000..58a78e4 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.bare.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":[{"pubkey":"ComputeBudget111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":42,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":3}}],"id":1} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.context.success.json b/crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.context.success.json new file mode 100644 index 0000000..93ea25c --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.context.success.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000007},"value":[{"pubkey":"ComputeBudget111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":42,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":3}}]},"id":1} diff --git a/crates/ksp-onchain-transport-lib/src/error.rs b/crates/ksp-onchain-transport-lib/src/error.rs index 04dc2e7..147ff28 100644 --- a/crates/ksp-onchain-transport-lib/src/error.rs +++ b/crates/ksp-onchain-transport-lib/src/error.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/error.rs -// version: 1 +// version: 2 /// Error code used when HTTP transport runtime settings are invalid. pub const ERROR_CODE_INVALID_SETTINGS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_settings"); @@ -25,3 +25,5 @@ pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_l pub const ERROR_CODE_METHOD_REMOVED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "method_removed"); /// Error code used when a decoded response cannot satisfy the KSP transport contract expected by the caller. pub const ERROR_CODE_INVALID_RESPONSE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_response"); +/// Error code used when typed Solana RPC parameters violate a locally enforceable method contract. +pub const ERROR_CODE_INVALID_RPC_PARAMETERS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_rpc_parameters"); diff --git a/crates/ksp-onchain-transport-lib/src/lib.rs b/crates/ksp-onchain-transport-lib/src/lib.rs index ca57f4b..84a8053 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: 8 +// version: 9 #![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 first typed Solana HTTP canaries execute real -//! JSON-RPC requests while the remaining audited methods stay staged by subsequent `0.2.x` releases. +//! are available. The four typed Solana HTTP foundation canaries and the `0.2.2` Accounts wrappers execute real JSON-RPC requests through the shared +//! transport path while the remaining audited methods stay staged by subsequent `0.2.x` prereleases. mod client; mod constants; @@ -45,6 +45,8 @@ pub use self::error::ERROR_CODE_HTTP_CONNECTION_FAILED; pub use self::error::ERROR_CODE_HTTP_REQUEST_FAILED; /// Error code used when a decoded response cannot satisfy the expected KSP transport contract. pub use self::error::ERROR_CODE_INVALID_RESPONSE; +/// Error code used when typed Solana RPC parameters violate a locally enforceable method contract. +pub use self::error::ERROR_CODE_INVALID_RPC_PARAMETERS; /// Error code used when HTTP transport runtime settings are invalid. pub use self::error::ERROR_CODE_INVALID_SETTINGS; /// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON. diff --git a/crates/ksp-onchain-transport-lib/src/rpc_accounts.rs b/crates/ksp-onchain-transport-lib/src/rpc_accounts.rs index d8097cd..99b7a20 100644 --- a/crates/ksp-onchain-transport-lib/src/rpc_accounts.rs +++ b/crates/ksp-onchain-transport-lib/src/rpc_accounts.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/rpc_accounts.rs -// version: 2 +// version: 3 /// Account-data encoding accepted by Solana HTTP account methods. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -29,7 +29,6 @@ impl SolanaAccountEncoding { }; } - #[cfg(test)] fn from_wire(value: &str) -> std::option::Option { return match value { "binary" => std::option::Option::Some(Self::Binary), @@ -68,7 +67,6 @@ impl SolanaDataSliceConfig { return self.length; } - #[cfg(test)] fn to_json_value(self) -> serde_json::Value { return serde_json::json!({"offset": self.offset, "length": self.length}); } @@ -118,9 +116,12 @@ impl SolanaAccountInfoConfig { return self.context.min_context_slot(); } + fn is_empty(&self) -> bool { + return self.encoding.is_none() && self.data_slice.is_none() && self.commitment().is_none() && self.min_context_slot().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 context_value = self.context.to_json_value(); let mut object = match context_value { @@ -147,7 +148,6 @@ pub enum SolanaLargestAccountsFilter { } impl SolanaLargestAccountsFilter { - #[cfg(test)] fn as_str(self) -> &'static str { return match self { Self::Circulating => "circulating", @@ -193,9 +193,12 @@ impl SolanaLargestAccountsConfig { return self.sort_results; } + fn is_empty(&self) -> bool { + return self.commitment.is_none() && self.filter.is_none() && self.sort_results.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 { @@ -248,7 +251,6 @@ impl SolanaMemcmpFilter { return &self.bytes; } - #[cfg(test)] fn to_json_value(&self) -> serde_json::Value { return match &self.bytes { crate::SolanaMemcmpBytes::Base58(bytes) => serde_json::json!({"offset": self.offset, "bytes": bytes, "encoding": "base58"}), @@ -270,7 +272,6 @@ pub enum SolanaProgramAccountFilter { } impl SolanaProgramAccountFilter { - #[cfg(test)] fn to_json_value(&self) -> serde_json::Value { return match self { Self::DataSize(size) => serde_json::json!({"dataSize": size}), @@ -325,9 +326,12 @@ impl SolanaProgramAccountsConfig { return self.sort_results; } + fn is_empty(&self) -> bool { + return self.account_config.is_empty() && self.filters.is_empty() && self.with_context.is_none() && self.sort_results.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 account_value = self.account_config.to_json_value(); let mut object = match account_value { @@ -393,7 +397,6 @@ pub enum SolanaAccountData { } impl SolanaAccountData { - #[cfg(test)] 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 { @@ -477,7 +480,6 @@ impl SolanaAccount { } /// Decodes one account DTO 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 { @@ -526,7 +528,6 @@ impl SolanaKeyedAccount { } /// Decodes one keyed account 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 { @@ -568,7 +569,6 @@ impl SolanaAccountBalance { } /// Decodes one account-balance entry 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 { @@ -592,7 +592,361 @@ pub enum SolanaProgramAccountsResult { Context(crate::SolanaRpcResponse>), } -#[cfg(test)] +const MAX_MULTIPLE_ACCOUNTS: usize = 100; +const MAX_PROGRAM_ACCOUNT_FILTERS: usize = 4; +const MAX_MEMCMP_BYTES: usize = 128; + +impl crate::HttpTransportPool { + /// Executes typed `getAccountInfo` through the common KSP HTTP transport path. + pub async fn get_account_info( + &self, + role: &crate::HttpRoleName, + account: &ksp_core_lib::Pubkey, + config: std::option::Option<&crate::SolanaAccountInfoConfig>, + ) -> ksp_core_lib::Result>> { + let method_result = account_descriptor("getAccountInfo"); + let method = match method_result { + std::result::Result::Ok(method) => method, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut params = std::vec![serde_json::Value::String(account.to_string())]; + if let std::option::Option::Some(config) = config + && !config.is_empty() + { + params.push(config.to_json_value()); + } + let result = self.execute_standard_rpc(role, method, params).await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return decode_account_info_response("getAccountInfo", value); + } + + /// Executes typed `getLargestAccounts` through the common KSP HTTP transport path. + pub async fn get_largest_accounts( + &self, + role: &crate::HttpRoleName, + config: std::option::Option<&crate::SolanaLargestAccountsConfig>, + ) -> ksp_core_lib::Result>> { + let method_result = account_descriptor("getLargestAccounts"); + let method = match method_result { + std::result::Result::Ok(method) => method, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + 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 result = self.execute_standard_rpc(role, method, params).await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return decode_largest_accounts_response("getLargestAccounts", value); + } + + /// Executes typed `getMinimumBalanceForRentExemption` through the common KSP HTTP transport path. + pub async fn get_minimum_balance_for_rent_exemption( + &self, + role: &crate::HttpRoleName, + data_len: usize, + config: std::option::Option<&crate::SolanaCommitmentConfig>, + ) -> ksp_core_lib::Result { + let method_result = account_descriptor("getMinimumBalanceForRentExemption"); + let method = match method_result { + std::result::Result::Ok(method) => method, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let data_len_value = serde_json::to_value(data_len); + let data_len_value = match data_len_value { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_ENCODE_FAILED, "rent-exemption data length could not be encoded") + .with_context("rpc_method", "getMinimumBalanceForRentExemption") + .with_source(error), + ); + }, + }; + let mut params = std::vec![data_len_value]; + if let std::option::Option::Some(config) = config + && config.commitment().is_some() + { + params.push(config.to_json_value()); + } + let result = self.execute_standard_rpc(role, method, params).await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let decoded = crate::decode_wire_json::("getMinimumBalanceForRentExemption", value); + return match decoded { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } + + /// Executes typed `getMultipleAccounts` through the common KSP HTTP transport path. + pub async fn get_multiple_accounts( + &self, + role: &crate::HttpRoleName, + accounts: &[ksp_core_lib::Pubkey], + config: std::option::Option<&crate::SolanaAccountInfoConfig>, + ) -> ksp_core_lib::Result>>> { + if accounts.len() > MAX_MULTIPLE_ACCOUNTS { + return invalid_account_parameters("getMultipleAccounts", "getMultipleAccounts accepts at most 100 public keys", "account_count", accounts.len()); + } + let method_result = account_descriptor("getMultipleAccounts"); + let method = match method_result { + std::result::Result::Ok(method) => method, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let addresses = accounts.iter().map(std::string::ToString::to_string).map(serde_json::Value::String).collect::>(); + let mut params = std::vec![serde_json::Value::Array(addresses)]; + if let std::option::Option::Some(config) = config + && !config.is_empty() + { + params.push(config.to_json_value()); + } + let result = self.execute_standard_rpc(role, method, params).await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return decode_multiple_accounts_response("getMultipleAccounts", value, accounts.len()); + } + + /// Executes typed `getProgramAccounts` through the common KSP HTTP transport path. + pub async fn get_program_accounts( + &self, + role: &crate::HttpRoleName, + program_id: &ksp_core_lib::Pubkey, + config: std::option::Option<&crate::SolanaProgramAccountsConfig>, + ) -> ksp_core_lib::Result { + if let std::option::Option::Some(config) = config { + let validation = validate_program_account_filters(config.filters()); + if let std::result::Result::Err(error) = validation { + return std::result::Result::Err(error); + } + } + let method_result = account_descriptor("getProgramAccounts"); + let method = match method_result { + std::result::Result::Ok(method) => method, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut params = std::vec![serde_json::Value::String(program_id.to_string())]; + if let std::option::Option::Some(config) = config + && !config.is_empty() + { + params.push(config.to_json_value()); + } + let result = self.execute_standard_rpc(role, method, params).await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return decode_program_accounts_response("getProgramAccounts", value); + } +} + +fn account_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> { + let descriptor = crate::find_http_rpc_method(method); + return match descriptor { + std::option::Option::Some(descriptor) + if descriptor.category() == crate::HttpRpcCategory::Accounts && descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_2 => + { + std::result::Result::Ok(descriptor) + }, + _ => std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Accounts descriptor is missing from the audited 0.2.2 registry") + .with_context("rpc_method", method), + ), + }; +} + +fn invalid_account_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 validate_program_account_filters(filters: &[crate::SolanaProgramAccountFilter]) -> ksp_core_lib::Result<()> { + if filters.len() > MAX_PROGRAM_ACCOUNT_FILTERS { + return invalid_account_parameters( + "getProgramAccounts", + "getProgramAccounts accepts at most 4 filters on the targeted Agave runtime", + "filter_count", + filters.len(), + ); + } + for filter in filters { + if let crate::SolanaProgramAccountFilter::Memcmp(memcmp) = filter + && let crate::SolanaMemcmpBytes::Bytes(bytes) = memcmp.bytes() + && bytes.len() > MAX_MEMCMP_BYTES + { + return invalid_account_parameters( + "getProgramAccounts", + "raw getProgramAccounts memcmp data accepts at most 128 bytes", + "memcmp_byte_count", + bytes.len(), + ); + } + } + return std::result::Result::Ok(()); +} + +fn decode_account_info_response( + method: &str, + value: serde_json::Value, +) -> ksp_core_lib::Result>> { + let decoded = crate::decode_wire_json::>>(method, value); + let wire = match decoded { + std::result::Result::Ok(wire) => wire, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let context = crate::SolanaRpcContext::decode_wire(method, wire.context); + let context = match context { + std::result::Result::Ok(context) => context, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let account = match wire.value { + std::option::Option::Some(value) => { + let account = crate::SolanaAccount::decode_wire(method, value); + match account { + std::result::Result::Ok(account) => std::option::Option::Some(account), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + }, + std::option::Option::None => std::option::Option::None, + }; + return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, account)); +} + +fn decode_largest_accounts_response( + method: &str, + value: serde_json::Value, +) -> ksp_core_lib::Result>> { + let decoded = crate::decode_wire_json::>>(method, value); + let wire = match decoded { + std::result::Result::Ok(wire) => wire, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let context = crate::SolanaRpcContext::decode_wire(method, wire.context); + let context = match context { + std::result::Result::Ok(context) => context, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut values = std::vec::Vec::with_capacity(wire.value.len()); + for value in wire.value { + let decoded = crate::SolanaAccountBalance::decode_wire(method, value); + match decoded { + std::result::Result::Ok(decoded) => values.push(decoded), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + } + return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, values)); +} + +fn decode_multiple_accounts_response( + method: &str, + value: serde_json::Value, + expected_count: usize, +) -> ksp_core_lib::Result>>> { + let decoded = crate::decode_wire_json::>>>(method, value); + let wire = match decoded { + std::result::Result::Ok(wire) => wire, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let context = crate::SolanaRpcContext::decode_wire(method, wire.context); + let context = match context { + std::result::Result::Ok(context) => context, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut values = std::vec::Vec::with_capacity(wire.value.len()); + for value in wire.value { + match value { + std::option::Option::Some(value) => { + let decoded = crate::SolanaAccount::decode_wire(method, value); + match decoded { + std::result::Result::Ok(decoded) => values.push(std::option::Option::Some(decoded)), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + }, + std::option::Option::None => values.push(std::option::Option::None), + } + } + if values.len() != expected_count { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "getMultipleAccounts result count does not match the requested account count") + .with_context("rpc_method", method) + .with_context("expected_count", expected_count.to_string()) + .with_context("actual_count", values.len().to_string()), + ); + } + return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, values)); +} + +fn decode_program_accounts_response(method: &str, value: serde_json::Value) -> ksp_core_lib::Result { + let decoded = crate::decode_wire_json::(method, value); + let wire = match decoded { + std::result::Result::Ok(wire) => wire, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return match wire { + WireProgramAccountsResult::Accounts(values) => { + let decoded = decode_keyed_accounts(method, values); + match decoded { + std::result::Result::Ok(values) => std::result::Result::Ok(crate::SolanaProgramAccountsResult::Accounts(values)), + std::result::Result::Err(error) => std::result::Result::Err(error), + } + }, + WireProgramAccountsResult::Context(wire) => { + let context = crate::SolanaRpcContext::decode_wire(method, wire.context); + let context = match context { + std::result::Result::Ok(context) => context, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let values = decode_keyed_accounts(method, wire.value); + match values { + std::result::Result::Ok(values) => { + std::result::Result::Ok(crate::SolanaProgramAccountsResult::Context(crate::SolanaRpcResponse::new(context, values))) + }, + std::result::Result::Err(error) => std::result::Result::Err(error), + } + }, + }; +} + +fn decode_keyed_accounts(method: &str, values: std::vec::Vec) -> ksp_core_lib::Result> { + let mut decoded_values = std::vec::Vec::with_capacity(values.len()); + for value in values { + let decoded = crate::SolanaKeyedAccount::decode_wire(method, value); + match decoded { + std::result::Result::Ok(decoded) => decoded_values.push(decoded), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + } + return std::result::Result::Ok(decoded_values); +} + +#[derive(serde::Deserialize)] +struct WireRpcResponse { + context: serde_json::Value, + value: T, +} + +#[derive(serde::Deserialize)] +#[serde(untagged)] +enum WireProgramAccountsResult { + Context(WireRpcResponse>), + Accounts(std::vec::Vec), +} + #[derive(serde::Deserialize)] #[serde(untagged)] enum WireAccountData { @@ -601,7 +955,6 @@ enum WireAccountData { Encoded((std::string::String, std::string::String)), } -#[cfg(test)] #[derive(serde::Deserialize)] struct WireParsedAccountData { program: std::string::String, @@ -609,7 +962,6 @@ struct WireParsedAccountData { space: u64, } -#[cfg(test)] #[derive(serde::Deserialize)] struct WireAccount { lamports: u64, @@ -622,14 +974,12 @@ struct WireAccount { space: std::option::Option, } -#[cfg(test)] #[derive(serde::Deserialize)] struct WireKeyedAccount { pubkey: std::string::String, account: serde_json::Value, } -#[cfg(test)] #[derive(serde::Deserialize)] struct WireAccountBalance { address: std::string::String, diff --git a/crates/ksp-onchain-transport-lib/src/rpc_common.rs b/crates/ksp-onchain-transport-lib/src/rpc_common.rs index 22186f4..6a3716f 100644 --- a/crates/ksp-onchain-transport-lib/src/rpc_common.rs +++ b/crates/ksp-onchain-transport-lib/src/rpc_common.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/rpc_common.rs -// version: 3 +// version: 4 /// Commitment level accepted by typed Solana HTTP RPC adapters. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -45,7 +45,6 @@ impl SolanaCommitmentConfig { /// 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 { @@ -149,8 +148,7 @@ impl SolanaRpcResponse { return &self.value; } - /// Creates a contextual response after wire decoding and validation for staged DTO tests. - #[cfg(test)] + /// Creates a contextual response after wire decoding and validation. #[must_use] pub(crate) const fn new(context: crate::SolanaRpcContext, value: T) -> Self { return Self { context, value }; @@ -171,7 +169,6 @@ pub(crate) fn decode_wire_json(method: &str, val } /// Parses a base58 public key from one wire field without echoing its value into diagnostics. -#[cfg(test)] pub(crate) fn parse_wire_pubkey(method: &str, field: &str, value: &str) -> ksp_core_lib::Result { let parsed = value.parse::(); return match parsed { diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index 01ee836..db51b4c 100644 --- a/crates/ksp-onchain-transport-lib/tests/public_api.rs +++ b/crates/ksp-onchain-transport-lib/tests/public_api.rs @@ -198,3 +198,14 @@ fn public_pre_002_shared_rpc_types_are_constructible_from_crate_root() { ); assert_eq!(vote.vote_pubkey(), std::option::Option::Some(&pubkey)); } + +#[test] +fn public_pre_003_account_wrappers_are_available_from_crate_root() { + let _get_account_info = ksp_onchain_transport_lib::HttpTransportPool::get_account_info; + let _get_largest_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_largest_accounts; + let _get_minimum_balance_for_rent_exemption = ksp_onchain_transport_lib::HttpTransportPool::get_minimum_balance_for_rent_exemption; + let _get_multiple_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_multiple_accounts; + let _get_program_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_program_accounts; + assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_INVALID_RPC_PARAMETERS.domain(), "onchain_transport"); + assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_INVALID_RPC_PARAMETERS.code(), "invalid_rpc_parameters"); +} diff --git a/crates/ksp-onchain-transport-lib/tests/release_completeness.rs b/crates/ksp-onchain-transport-lib/tests/release_completeness.rs index e420aa3..3ca347b 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: 1 +// version: 2 //! Release-level completeness canaries for the `0.2.1` HTTP foundation contract. @@ -46,3 +46,22 @@ fn release_foundation_canaries_and_historical_statuses_are_exact() { assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::NotApplicable); } } + +#[test] +fn release_pre_003_accounts_subset_is_exact_without_advancing_other_v0_2_2_families() { + let mut account_names = std::vec::Vec::<&str>::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::Accounts + { + account_names.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); + } + } + account_names.sort_unstable(); + assert_eq!( + account_names, + std::vec!["getAccountInfo", "getLargestAccounts", "getMinimumBalanceForRentExemption", "getMultipleAccounts", "getProgramAccounts",], + ); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs b/crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs index 9404d22..1b8191a 100644 --- a/crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs +++ b/crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs -// version: 2 +// version: 3 #[test] fn account_config_serializes_all_common_fields() { @@ -88,3 +88,359 @@ fn staged_largest_and_keyed_account_helpers_match_wire_shapes() { assert_eq!(balance.address().to_string(), "11111111111111111111111111111111"); assert_eq!(balance.lamports(), 99); } + +fn pool_for_url(url: &str) -> crate::HttpTransportPool { + let role = crate::HttpEndpointRoleSettings::new( + crate::HttpRoleName::new("default"), + true, + std::vec![crate::HttpRequestKind::wildcard()], + 10, + crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None), + ); + let endpoint = crate::HttpEndpointSettings::new( + "fixture", + true, + crate::HttpProviderName::new("fixture"), + crate::HttpClusterName::new("local"), + crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"), + std::time::Duration::from_secs(1), + std::time::Duration::from_secs(1), + std::option::Option::Some(1), + std::vec![role], + ); + let settings = crate::HttpTransportSettings::new( + std::vec![endpoint], + crate::HttpRetrySettings::new(0, std::time::Duration::from_millis(1), std::time::Duration::from_millis(1)), + ); + return crate::HttpTransportPool::new(settings).expect("fixture pool must build"); +} + +fn serve_once(body: &'static str) -> (std::string::String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind"); + let address = listener.local_addr().expect("fixture listener address must resolve"); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("fixture server must accept one request"); + let request = read_request(&mut stream); + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body); + std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write"); + return request; + }); + return (format!("http://{address}"), handle); +} + +fn read_request(stream: &mut std::net::TcpStream) -> std::string::String { + let mut bytes = std::vec::Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let count = std::io::Read::read(stream, &mut buffer).expect("fixture request must read"); + if count == 0 { + break; + } + bytes.extend_from_slice(&buffer[..count]); + if request_complete(bytes.as_slice()) { + break; + } + } + return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8"); +} + +fn request_complete(bytes: &[u8]) -> bool { + let text = match std::str::from_utf8(bytes) { + std::result::Result::Ok(text) => text, + std::result::Result::Err(_) => return false, + }; + let header_end = match text.find("\r\n\r\n") { + std::option::Option::Some(value) => value, + std::option::Option::None => return false, + }; + let mut content_length = 0_usize; + for line in text[..header_end].lines() { + let (name, value) = match line.split_once(':') { + std::option::Option::Some(parts) => parts, + std::option::Option::None => continue, + }; + if name.eq_ignore_ascii_case("content-length") { + content_length = value.trim().parse::().expect("content length must parse"); + } + } + return bytes.len() >= header_end.saturating_add(4).saturating_add(content_length); +} + +fn request_body(request: &str) -> serde_json::Value { + let body = request.split("\r\n\r\n").nth(1).expect("fixture request body must exist"); + return serde_json::from_str(body).expect("fixture request body must be JSON"); +} + +fn fixture_pubkey(value: &str) -> ksp_core_lib::Pubkey { + return value.parse::().expect("fixture pubkey must parse"); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_account_info_serializes_config_and_preserves_account_wire() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_account_info.success.json")); + let pool = pool_for_url(url.as_str()); + let account = fixture_pubkey("11111111111111111111111111111111"); + let config = crate::SolanaAccountInfoConfig::new( + std::option::Option::Some(crate::SolanaAccountEncoding::Base64), + std::option::Option::Some(crate::SolanaDataSliceConfig::new(2, 4)), + std::option::Option::Some(crate::SolanaCommitment::Finalized), + std::option::Option::Some(400_000_000), + ); + let response = pool + .get_account_info(&crate::HttpRoleName::new("default"), &account, std::option::Option::Some(&config)) + .await + .expect("getAccountInfo fixture must succeed"); + assert_eq!(response.context().slot(), 410_000_001); + let returned = response.value().as_ref().expect("fixture account must be present"); + assert_eq!(returned.lamports(), 2_039_280); + assert_eq!(returned.owner(), &account); + assert_eq!(returned.space(), std::option::Option::Some(4)); + assert!(matches!(returned.data(), crate::SolanaAccountData::Encoded { encoding: crate::SolanaAccountEncoding::Base64, .. })); + let request = handle.join().expect("fixture server must join"); + let body = request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("getAccountInfo")); + assert_eq!(body["params"][0], serde_json::json!(account.to_string())); + assert_eq!(body["params"][1]["encoding"], serde_json::json!("base64")); + assert_eq!(body["params"][1]["dataSlice"], serde_json::json!({"offset":2,"length":4})); + assert_eq!(body["params"][1]["commitment"], serde_json::json!("finalized")); + assert_eq!(body["params"][1]["minContextSlot"], serde_json::json!(400_000_000_u64)); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_account_info_preserves_missing_account_as_none() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_account_info.null.json")); + let pool = pool_for_url(url.as_str()); + let account = fixture_pubkey("11111111111111111111111111111111"); + let response = pool + .get_account_info(&crate::HttpRoleName::new("default"), &account, std::option::Option::None) + .await + .expect("missing getAccountInfo fixture must succeed"); + assert_eq!(response.value(), &std::option::Option::None); + let request = handle.join().expect("fixture server must join"); + assert_eq!(request_body(request.as_str())["params"].as_array().map(std::vec::Vec::len), std::option::Option::Some(1)); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_account_info_rejects_invalid_owner_without_exposing_value() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_account_info.invalid_owner.json")); + let pool = pool_for_url(url.as_str()); + let account = fixture_pubkey("11111111111111111111111111111111"); + let result = pool.get_account_info(&crate::HttpRoleName::new("default"), &account, std::option::Option::None).await; + let error = result.expect_err("invalid account owner must fail typed decoding"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE); + assert!(!format!("{error:?}").contains("not-a-pubkey")); + handle.join().expect("fixture server must join"); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_largest_accounts_serializes_extended_config_and_decodes_order() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_largest_accounts.success.json")); + let pool = pool_for_url(url.as_str()); + let config = crate::SolanaLargestAccountsConfig::new( + std::option::Option::Some(crate::SolanaCommitment::Finalized), + std::option::Option::Some(crate::SolanaLargestAccountsFilter::Circulating), + std::option::Option::Some(true), + ); + let response = pool + .get_largest_accounts(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config)) + .await + .expect("getLargestAccounts fixture must succeed"); + assert_eq!(response.value().len(), 2); + assert_eq!(response.value()[0].lamports(), 999_999_999); + assert_eq!(response.value()[1].lamports(), 888_888_888); + let request = handle.join().expect("fixture server must join"); + let body = request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("getLargestAccounts")); + assert_eq!(body["params"][0], serde_json::json!({"commitment":"finalized","filter":"circulating","sortResults":true})); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_largest_accounts_rejects_invalid_address() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_largest_accounts.invalid_address.json")); + let pool = pool_for_url(url.as_str()); + let result = pool.get_largest_accounts(&crate::HttpRoleName::new("default"), std::option::Option::None).await; + let error = result.expect_err("invalid largest-account address must fail typed decoding"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE); + assert!(!format!("{error:?}").contains("invalid-address")); + handle.join().expect("fixture server must join"); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_minimum_balance_for_rent_exemption_serializes_length_and_commitment() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_minimum_balance_for_rent_exemption.success.json")); + let pool = pool_for_url(url.as_str()); + let config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Processed)); + let result = pool + .get_minimum_balance_for_rent_exemption(&crate::HttpRoleName::new("default"), 50, std::option::Option::Some(&config)) + .await + .expect("rent-exemption fixture must succeed"); + assert_eq!(result, 890_880); + let request = handle.join().expect("fixture server must join"); + let body = request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("getMinimumBalanceForRentExemption")); + assert_eq!(body["params"], serde_json::json!([50,{"commitment":"processed"}])); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_minimum_balance_for_rent_exemption_preserves_rpc_application_error() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_minimum_balance_for_rent_exemption.error.json")); + let pool = pool_for_url(url.as_str()); + let result = pool.get_minimum_balance_for_rent_exemption(&crate::HttpRoleName::new("default"), usize::MAX, std::option::Option::None).await; + let error = result.expect_err("remote invalid parameter 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_multiple_accounts_preserves_order_nulls_and_config() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_multiple_accounts.success.json")); + let pool = pool_for_url(url.as_str()); + let accounts = std::vec![ + fixture_pubkey("11111111111111111111111111111111"), + fixture_pubkey("ComputeBudget111111111111111111111111111111"), + fixture_pubkey("Stake11111111111111111111111111111111111111"), + ]; + let config = crate::SolanaAccountInfoConfig::new( + std::option::Option::Some(crate::SolanaAccountEncoding::JsonParsed), + std::option::Option::None, + std::option::Option::Some(crate::SolanaCommitment::Confirmed), + std::option::Option::Some(400_000_001), + ); + let response = pool + .get_multiple_accounts(&crate::HttpRoleName::new("default"), accounts.as_slice(), std::option::Option::Some(&config)) + .await + .expect("getMultipleAccounts fixture must succeed"); + assert_eq!(response.value().len(), 3); + assert_eq!(response.value()[0].as_ref().map(crate::SolanaAccount::lamports), std::option::Option::Some(10)); + assert!(response.value()[1].is_none()); + assert_eq!(response.value()[2].as_ref().map(crate::SolanaAccount::lamports), std::option::Option::Some(20)); + assert!(matches!(response.value()[2].as_ref().map(crate::SolanaAccount::data), std::option::Option::Some(crate::SolanaAccountData::JsonParsed(_)))); + let request = handle.join().expect("fixture server must join"); + let body = request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("getMultipleAccounts")); + assert_eq!(body["params"][0], serde_json::json!(accounts.iter().map(std::string::ToString::to_string).collect::>())); + assert_eq!(body["params"][1]["encoding"], serde_json::json!("jsonParsed")); + assert_eq!(body["params"][1]["commitment"], serde_json::json!("confirmed")); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_multiple_accounts_rejects_more_than_100_before_io() { + let pool = pool_for_url("http://127.0.0.1:9"); + let account = fixture_pubkey("11111111111111111111111111111111"); + let accounts = std::vec![account; 101]; + let result = pool.get_multiple_accounts(&crate::HttpRoleName::new("default"), accounts.as_slice(), std::option::Option::None).await; + let error = result.expect_err("more than 100 accounts must be rejected locally"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); + assert_eq!(error.context()[0].key(), "rpc_method"); + assert_eq!(error.context()[1].key(), "account_count"); + assert_eq!(error.context()[1].value(), "101"); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_program_accounts_serializes_filters_and_decodes_bare_result() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_program_accounts.bare.success.json")); + let pool = pool_for_url(url.as_str()); + let program_id = fixture_pubkey("11111111111111111111111111111111"); + let filters = std::vec![ + crate::SolanaProgramAccountFilter::DataSize(3), + crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(0, crate::SolanaMemcmpBytes::Base64("AQID".to_owned()))), + crate::SolanaProgramAccountFilter::TokenAccountState, + ]; + let account_config = crate::SolanaAccountInfoConfig::new( + std::option::Option::Some(crate::SolanaAccountEncoding::Base64), + std::option::Option::Some(crate::SolanaDataSliceConfig::new(0, 3)), + std::option::Option::Some(crate::SolanaCommitment::Finalized), + std::option::Option::Some(400_000_002), + ); + let config = crate::SolanaProgramAccountsConfig::new(account_config, filters, std::option::Option::Some(false), std::option::Option::Some(true)); + let result = pool + .get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&config)) + .await + .expect("bare getProgramAccounts fixture must succeed"); + assert!(matches!(&result, crate::SolanaProgramAccountsResult::Accounts(_)), "bare fixture must preserve the bare account-list result"); + if let crate::SolanaProgramAccountsResult::Accounts(accounts) = result { + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].account().lamports(), 42); + } + let request = handle.join().expect("fixture server must join"); + let body = request_body(request.as_str()); + assert_eq!(body["method"], serde_json::json!("getProgramAccounts")); + assert_eq!( + body["params"][1]["filters"], + serde_json::json!([{"dataSize":3},{"memcmp":{"offset":0,"bytes":"AQID","encoding":"base64"}},"tokenAccountState"]), + ); + assert_eq!(body["params"][1]["withContext"], serde_json::json!(false)); + assert_eq!(body["params"][1]["sortResults"], serde_json::json!(true)); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_program_accounts_preserves_contextual_result() { + let (url, handle) = serve_once(include_str!("../fixtures/http/get_program_accounts.context.success.json")); + let pool = pool_for_url(url.as_str()); + let program_id = fixture_pubkey("11111111111111111111111111111111"); + let config = crate::SolanaProgramAccountsConfig::new( + crate::SolanaAccountInfoConfig::default(), + std::vec::Vec::new(), + std::option::Option::Some(true), + std::option::Option::None, + ); + let result = pool + .get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&config)) + .await + .expect("contextual getProgramAccounts fixture must succeed"); + assert!(matches!(&result, crate::SolanaProgramAccountsResult::Context(_)), "contextual fixture must preserve RpcResponse wrapper"); + if let crate::SolanaProgramAccountsResult::Context(response) = result { + assert_eq!(response.context().slot(), 410_000_007); + assert_eq!(response.value().len(), 1); + } + let request = handle.join().expect("fixture server must join"); + assert_eq!(request_body(request.as_str())["params"][1]["withContext"], serde_json::json!(true)); +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_get_program_accounts_rejects_filter_cardinality_and_oversized_raw_memcmp_before_io() { + let pool = pool_for_url("http://127.0.0.1:9"); + let program_id = fixture_pubkey("11111111111111111111111111111111"); + let too_many = crate::SolanaProgramAccountsConfig::new( + crate::SolanaAccountInfoConfig::default(), + std::vec![ + crate::SolanaProgramAccountFilter::DataSize(1), + crate::SolanaProgramAccountFilter::DataSize(2), + crate::SolanaProgramAccountFilter::DataSize(3), + crate::SolanaProgramAccountFilter::DataSize(4), + crate::SolanaProgramAccountFilter::DataSize(5), + ], + std::option::Option::None, + std::option::Option::None, + ); + let result = pool.get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&too_many)).await; + let error = result.expect_err("more than four program-account filters must be rejected locally"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); + assert_eq!(error.context()[1].value(), "5"); + + let oversized = crate::SolanaProgramAccountsConfig::new( + crate::SolanaAccountInfoConfig::default(), + std::vec![crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(0, crate::SolanaMemcmpBytes::Bytes(std::vec![0_u8; 129]),))], + std::option::Option::None, + std::option::Option::None, + ); + let result = pool.get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&oversized)).await; + let error = result.expect_err("raw memcmp data above 128 bytes must be rejected locally"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS); + assert_eq!(error.context()[1].value(), "129"); +} + +#[test] +fn typed_get_multiple_accounts_rejects_response_count_mismatch() { + let value = serde_json::json!({ + "context":{"apiVersion":"4.2.1","slot":410000008}, + "value":[] + }); + let result = super::decode_multiple_accounts_response("getMultipleAccounts", value, 1); + let error = result.expect_err("response count mismatch must fail 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(), "1"); + assert_eq!(error.context()[2].key(), "actual_count"); + assert_eq!(error.context()[2].value(), "0"); +} diff --git a/deltas/0.2.2/pre.003.md b/deltas/0.2.2/pre.003.md new file mode 100644 index 0000000..6bf9de6 --- /dev/null +++ b/deltas/0.2.2/pre.003.md @@ -0,0 +1,335 @@ + + + +# Delta `0.2.2-pre.003` — cinq wrappers HTTP Accounts typés + +## Base requise + +Livraison précédente corrigée et validée localement par l'opérateur : + +```text +0.2.2-pre.002-fix.002 +workspace.package.version = "0.2.2-pre.2.fix.2" +``` + +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 +85 unit tests +9 public API tests +2 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. + +## Objectif + +Clôturer la famille Accounts attribuée à `0.2.2` avec cinq wrappers publics typés : + +```text +getAccountInfo +getLargestAccounts +getMinimumBalanceForRentExemption +getMultipleAccounts +getProgramAccounts +``` + +Tous passent par la foundation HTTP `0.2.1` : + +```text +wrapper typé + -> descriptor audité + -> execute_standard_rpc + -> pool/admission/retry/deadline + -> reqwest HTTP + -> JSON-RPC validation + -> décodage DTO KSP +``` + +Aucun wrapper ne contacte `reqwest` directement. + +## Version Cargo + +Conformément à `VER-ID-009` : + +```text +0.2.2-pre.2.fix.2 -> 0.2.2-pre.3 +``` + +Aucune dépendance ni feature Cargo n'est ajoutée. + +## Surface typée Accounts + +### `getAccountInfo` + +Signature publique conceptuelle : + +```text +role + Pubkey + Option + -> SolanaRpcResponse> +``` + +Le wrapper : + +- sérialise `encoding`, `dataSlice`, `commitment` et `minContextSlot` ; +- omet l'objet config lorsqu'il est vide ; +- conserve l'absence du compte sous `None` ; +- conserve les formes Account data legacy, tuple encodé et `jsonParsed` ; +- convertit l'owner en `ksp_core_lib::Pubkey` sans recopier une valeur invalide dans le diagnostic. + +### `getLargestAccounts` + +Signature publique conceptuelle : + +```text +role + Option + -> SolanaRpcResponse> +``` + +Le wrapper conserve l'ordre du wire et sérialise les champs KSP déjà audités dans `pre.002`, y compris `filter` et `sortResults` lorsqu'ils sont +explicitement renseignés. + +### `getMinimumBalanceForRentExemption` + +Signature publique conceptuelle : + +```text +role + usize data_len + Option + -> u64 lamports +``` + +Le wrapper encode la longueur de données sans valeur sentinelle et conserve les erreurs JSON-RPC applicatives comme telles. + +### `getMultipleAccounts` + +Signature publique conceptuelle : + +```text +role + &[Pubkey] + Option + -> SolanaRpcResponse>> +``` + +Le wrapper : + +- refuse localement plus de 100 public keys ; +- sérialise les adresses dans l'ordre fourni ; +- conserve les `null` individuels ; +- vérifie que le nombre d'éléments retournés correspond au nombre d'adresses demandées, afin de ne pas fabriquer silencieusement un mapping + positionnel incohérent. + +### `getProgramAccounts` + +Signature publique conceptuelle : + +```text +role + program Pubkey + Option + -> SolanaProgramAccountsResult +``` + +Le résultat préserve les deux formes wire : + +```text +Accounts(Vec) +Context(SolanaRpcResponse>) +``` + +Le wrapper sérialise `dataSize`, `memcmp`, `tokenAccountState`, `withContext`, `sortResults` et la config Account commune. + +## Validation locale des paramètres + +Nouveau code d'erreur public Transport : + +```text +ERROR_CODE_INVALID_RPC_PARAMETERS +onchain_transport.invalid_rpc_parameters +``` + +Il distingue une requête KSP localement invalide d'une erreur JSON-RPC distante ou d'une réponse invalide. + +Les invariants appliqués avant I/O sont : + +```text +getMultipleAccounts : <= 100 public keys +getProgramAccounts : <= 4 filters +memcmp raw bytes : <= 128 bytes +``` + +Pour `memcmp` encodé sous forme base58/base64, KSP ne décode toujours pas localement la chaîne : la limite des 128 octets décodés reste donc +contrôlée par le serveur. Cette décision évite d'ajouter `bs58` ou `base64` uniquement pour une validation anticipée. + +## Wire Account activé en production + +Les helpers privés préparés par `pre.002` et réellement nécessaires à ces wrappers deviennent production-live : + +- sérialisation des configs Account/Largest/Program ; +- conversion des encodings Account ; +- décodage `SolanaAccountData` ; +- décodage Account/KeyedAccount/AccountBalance ; +- conversion `Pubkey` wire ; +- construction interne de `SolanaRpcResponse`. + +Les helpers Cluster/Token préparatoires restent inchangés et ne deviennent pas production-live avant leurs tranches respectives. + +## Fixtures HTTP déterministes ajoutées + +```text +crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.null.json +crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.invalid_owner.json +crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.invalid_address.json +crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.error.json +crates/ksp-onchain-transport-lib/fixtures/http/get_multiple_accounts.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.bare.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.context.success.json +``` + +Les tests locaux utilisent un serveur HTTP loopback et n'accèdent pas à Internet. + +## Couverture de tests ajoutée + +Les tests Accounts couvrent : + +- request exacte pour chaque méthode ; +- config et omission de config vide ; +- succès typé ; +- account absent ; +- `null` dans `getMultipleAccounts` ; +- ordre de `getMultipleAccounts` ; +- résultat bare et contextualisé de `getProgramAccounts` ; +- invalid owner/address ; +- erreur JSON-RPC applicative ; +- limite des 100 comptes ; +- limite des 4 filtres ; +- limite raw `memcmp` de 128 octets ; +- mismatch cardinalité request/result de `getMultipleAccounts`. + +Le test public compile explicitement les cinq méthodes sur `HttpTransportPool` et vérifie le nouveau code d'erreur. + +Un canari release supplémentaire fige l'ensemble Accounts `0.2.2` exact : + +```text +getAccountInfo +getLargestAccounts +getMinimumBalanceForRentExemption +getMultipleAccounts +getProgramAccounts +``` + +Il vérifie aussi que ces descriptors restent `Read + RetrySafe`. Aucune famille Tokens/Cluster n'est déclarée typed-complete par cette tranche. + +Après application, la cible Transport attendue devient : + +```text +98 unit tests +10 public API tests +3 release completeness tests +``` + +## Fichiers ajoutés + +```text +crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.null.json +crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.invalid_owner.json +crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.invalid_address.json +crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.error.json +crates/ksp-onchain-transport-lib/fixtures/http/get_multiple_accounts.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.bare.success.json +crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.context.success.json +deltas/0.2.2/pre.003.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +crates/ksp-onchain-transport-lib/src/error.rs +crates/ksp-onchain-transport-lib/src/lib.rs +crates/ksp-onchain-transport-lib/src/rpc_accounts.rs +crates/ksp-onchain-transport-lib/src/rpc_common.rs +crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs +crates/ksp-onchain-transport-lib/tests/public_api.rs +crates/ksp-onchain-transport-lib/tests/release_completeness.rs +``` + +## Fichiers supprimés + +Aucun. + +## Fichiers volontairement inchangés + +```text +CHANGELOG.md +ROADMAP.md +docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md +crates/ksp-onchain-transport-lib/Cargo.toml +crates/ksp-onchain-transport-lib/src/executor.rs +crates/ksp-onchain-transport-lib/src/rpc_method.rs +crates/ksp-onchain-transport-lib/src/rpc_tokens.rs +crates/ksp-onchain-transport-lib/src/rpc_cluster.rs +crates/ksp-config-lib/** +config/** +``` + +La tranche suivante reste `pre.004 — cinq wrappers Tokens`. + +## Validations exécutées + +- prise en compte de la validation opérateur propre de `pre.002-fix.002` comme base ; +- relecture des règles `VERSION_WORKFLOW.md` et `RULES_RUST.md` applicables ; +- revérification de la documentation HTTP Solana actuelle pour les cinq méthodes Accounts ; +- recoupement des signatures/limites avec la source Agave `v4.2.1` auditée par le plan ; +- validation JSON syntaxique des dix nouvelles fixtures ; +- contrôle statique de l'absence de `unwrap`, `expect`, `panic!` et `#[allow(dead_code)]` dans le code de production modifié ; +- contrôle statique que les cinq wrappers utilisent `execute_standard_rpc` et aucun appel `reqwest` direct ; +- contrôle statique de l'absence de nouvelle dépendance/feature ; +- contrôle exact des fichiers inclus dans l'archive d'échange. + +## Validations non exécutées + +Le sandbox d'échange ne fournit pas `cargo`, `rustc` ou `rustfmt`. Les commandes suivantes doivent être exécutées sur le checkout opérateur avant +commit : + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-onchain-transport-lib +``` + +`cargo test --workspace` reste réservé au checkpoint de clôture/session conformément au workflow KSP. Aucun `cargo tree` n'est requis par cette +tranche puisqu'aucune dépendance ni feature n'a changé. + +## Décisions prises + +- utiliser les DTOs `pre.002` sans créer de seconde couche RPC ; +- conserver `execute_standard_rpc()` comme unique entrée d'exécution HTTP standard ; +- introduire un code d'erreur dédié aux paramètres typed invalides plutôt que détourner `invalid_settings` ; +- vérifier localement les limites dont KSP peut connaître la violation sans dépendance de décodage supplémentaire ; +- préserver les `null`, l'ordre et la dualité bare/context au lieu de normaliser artificiellement les réponses ; +- ne pas modifier Config ; +- ne pas ajouter de logging spécifique par méthode. + +## Questions ouvertes + +Aucune question bloquante pour `pre.004`. + +## Suite + +`0.2.2-pre.004` : implémenter les cinq wrappers Tokens (`getTokenAccountBalance`, `getTokenAccountsByDelegate`, `getTokenAccountsByOwner`, +`getTokenLargestAccounts`, `getTokenSupply`) avec les DTOs préparés, selector `Mint | ProgramId`, fixtures HTTP déterministes et conservation de +`uiAmount: null`.